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 { // 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(); const docToState = new Map(); 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 { 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 { 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 { 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; } }