// One project's document kept while it is open: every update stored as it // comes, in order (a queue, so that a compaction never overtakes an update // before it), and compacted after COMPACT_AFTER_UPDATES updates or when the // service asks (a minute after the last change, and when the last person // leaves; plan ยง5.4). A compaction stores the document as it is and drops // the updates stored before it; one stored after it stays, and applying it // again on loading changes nothing (Yjs). A project being deleted is // discarded: what is still asked for is not stored any more. import * as Y from 'yjs'; import { errorText, type Log } from './log.js'; import type { DocStore } from './store.js'; export const COMPACT_AFTER_UPDATES = 500; /** The origin of what is loaded from the store: not stored again. */ export const STORE_ORIGIN = 'store'; export class DocumentPersistence { private queue: Promise = Promise.resolve(); private lastSeq = '0'; /** Updates stored since the last compaction (in the queue's order). */ private sinceCompaction = 0; /** Updates queued since the last compaction was queued: what decides the next one. */ private queued = 0; private discarded = false; constructor( private readonly store: DocStore, private readonly project: string, private readonly log: Log, ) {} /** The stored document into a Y.Doc; false without the project. */ async load(doc: Y.Doc): Promise { const stored = await this.store.load(this.project); if (stored === undefined) return false; Y.transact( doc, () => { Y.applyUpdate(doc, stored.state); for (const update of stored.updates) Y.applyUpdate(doc, update); }, STORE_ORIGIN, ); this.lastSeq = stored.lastSeq; this.sinceCompaction = stored.updates.length; this.queued = stored.updates.length; return true; } private enqueue(work: () => Promise): Promise { this.queue = this.queue.then(() => (this.discarded ? undefined : work())).catch((error: unknown) => this.log.error('store.failed', { project: this.project, error: errorText(error) })); return this.queue; } /** An update stored; the document compacted once enough have come. */ append(update: Uint8Array, doc: Y.Doc): Promise { const stored = this.enqueue(async () => { this.lastSeq = await this.store.append(this.project, update); this.sinceCompaction += 1; }); this.queued += 1; return this.queued >= COMPACT_AFTER_UPDATES ? this.compact(doc) : stored; } /** The document as it is now stored as the project's state, the updates before dropped. */ compact(doc: Y.Doc): Promise { this.queued = 0; return this.enqueue(async () => { if (this.sinceCompaction === 0) return; const updates = this.sinceCompaction; await this.store.compact(this.project, Y.encodeStateAsUpdate(doc), this.lastSeq); this.sinceCompaction = 0; this.log.info('doc.compacted', { project: this.project, updates }); }); } /** Nothing stored any more; done once what is being stored now is. */ discard(): Promise { this.discarded = true; return this.flush(); } /** Everything stored that was asked for. */ flush(): Promise { return this.enqueue(async () => {}); } }