feat: F5.1 server F keeps the shared documents
A new package, packages/server: Hocuspocus 3.4.4 on /sync, a project a document, refused with a reason when the store has not got it; every update stored in Postgres as it comes and compacted after 500 updates, a minute of quiet and when the last person leaves (FOR UPDATE on the row written when the project is made); the janitor runs only here, at loading and 250 ms after a change that is not its own; readers change nothing; /healthz; JSON log. On closing it takes no one new and closes the sockets too (Hocuspocus' close is a message after which a client signs in again on the same socket). The tests use real clients against a throwaway Postgres in Docker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
// 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).
|
||||
|
||||
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<void> = 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;
|
||||
|
||||
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<boolean> {
|
||||
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<void>): Promise<void> {
|
||||
this.queue = this.queue.then(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<void> {
|
||||
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<void> {
|
||||
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 });
|
||||
});
|
||||
}
|
||||
|
||||
/** Everything stored that was asked for. */
|
||||
flush(): Promise<void> {
|
||||
return this.enqueue(async () => {});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user