150 lines
3.7 KiB
TypeScript
150 lines
3.7 KiB
TypeScript
import * as Y from 'yjs';
|
|
import { docsDb, LOCAL_DELTA_STORE } from './sync';
|
|
import { assert } from './util';
|
|
import _ from 'lodash';
|
|
|
|
const COMPACTION_THRESHOLD = 200;
|
|
|
|
export async function openDoc(id: string): Promise<Y.Doc> {
|
|
// Return existing doc if we still have it open
|
|
const previousState = idToState.get(id);
|
|
if (previousState) {
|
|
previousState.refCount += 1;
|
|
return previousState.ydoc;
|
|
}
|
|
|
|
const state = await initialLoad(id);
|
|
idToState.set(id, state);
|
|
docToState.set(state.ydoc, state);
|
|
|
|
return state.ydoc;
|
|
}
|
|
|
|
export function closeDoc(doc: Y.Doc) {
|
|
const state = docToState.get(doc);
|
|
assert(state, 'Document is not already open');
|
|
|
|
state.refCount -= 1;
|
|
if (state.refCount <= 0) {
|
|
idToState.delete(state.id);
|
|
docToState.delete(doc);
|
|
doc.destroy();
|
|
}
|
|
}
|
|
|
|
const idToState = new Map<string, State>();
|
|
const docToState = new Map<Y.Doc, State>();
|
|
|
|
type State = {
|
|
// TODO: Move more of this into class
|
|
id: string;
|
|
deltaBuffer: Uint8Array[];
|
|
numRecords: number;
|
|
refCount: number;
|
|
ydoc: Y.Doc;
|
|
};
|
|
|
|
async function initialLoad(id: string): Promise<State> {
|
|
const ydoc = new Y.Doc();
|
|
const records = await docsDb.getAll(
|
|
LOCAL_DELTA_STORE,
|
|
IDBKeyRange.bound([id, -Infinity], [id, Infinity])
|
|
);
|
|
Y.applyUpdate(ydoc, Y.mergeUpdates(records));
|
|
return {
|
|
id,
|
|
deltaBuffer: [],
|
|
numRecords: records.length,
|
|
refCount: 1,
|
|
ydoc,
|
|
};
|
|
}
|
|
|
|
class BufferedWriter {
|
|
private deltaBuffer: Uint8Array[];
|
|
private docId: string;
|
|
private numRecords: number;
|
|
private debouncedFlush: () => void;
|
|
private ydoc: Y.Doc;
|
|
|
|
constructor(docId: string) {
|
|
this.deltaBuffer = [];
|
|
this.docId = docId;
|
|
this.numRecords = 0;
|
|
this.debouncedFlush = _.debounce(
|
|
() => {
|
|
this.flush();
|
|
},
|
|
1_000,
|
|
{ maxWait: 30_000 }
|
|
);
|
|
this.ydoc = new Y.Doc();
|
|
}
|
|
|
|
public async connect(): Promise<Y.Doc> {
|
|
const records = await docsDb.getAll(
|
|
LOCAL_DELTA_STORE,
|
|
IDBKeyRange.bound([this.docId, -Infinity], [this.docId, Infinity])
|
|
);
|
|
this.numRecords = records.length;
|
|
Y.applyUpdate(this.ydoc, Y.mergeUpdates(records));
|
|
this.ydoc.on('update', (blob) => this.addUpdate(blob));
|
|
return this.ydoc;
|
|
}
|
|
|
|
public addUpdate(update: Uint8Array) {
|
|
this.deltaBuffer.push(update);
|
|
this.debouncedFlush();
|
|
}
|
|
|
|
public async flush(): Promise<void> {
|
|
if (this.deltaBuffer.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Write delta
|
|
const delta = Y.mergeUpdates(this.deltaBuffer.splice(0));
|
|
try {
|
|
await docsDb.put(LOCAL_DELTA_STORE, delta, [
|
|
this.docId,
|
|
new Date().valueOf(),
|
|
]);
|
|
this.numRecords += 1;
|
|
} catch (err) {
|
|
this.addUpdate(delta);
|
|
console.log(`Error flushing document ${this.docId}: ${err}`);
|
|
throw err;
|
|
}
|
|
|
|
// Compact if necessary
|
|
try {
|
|
await this.compact();
|
|
} catch (err) {
|
|
console.log(`Error compacting document ${this.docId}: ${err}`);
|
|
}
|
|
}
|
|
|
|
public async compact() {
|
|
if (this.numRecords < COMPACTION_THRESHOLD) {
|
|
return;
|
|
}
|
|
const tx = docsDb.transaction(LOCAL_DELTA_STORE, 'readwrite');
|
|
|
|
// Make sure we're not missing anything by pulling in all changes.
|
|
// This could happen if a document is being edited in two different tabs.
|
|
const fullRange = IDBKeyRange.bound(
|
|
[this.docId, -Infinity],
|
|
[this.docId, Infinity]
|
|
);
|
|
const records = await tx.store.getAll(fullRange);
|
|
Y.applyUpdate(this.ydoc, Y.mergeUpdates(records));
|
|
|
|
// Replace with a compacted blob
|
|
const compacted = Y.encodeStateAsUpdate(this.ydoc);
|
|
await tx.store.delete(fullRange);
|
|
await tx.store.put(compacted, [this.docId, new Date().valueOf()]);
|
|
tx.commit();
|
|
this.numRecords = 1;
|
|
}
|
|
}
|