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:
2026-10-04 13:30:43 +00:00
co-authored by Claude Opus 5.5
parent bd2e725791
commit 88db6eff7e
17 changed files with 1245 additions and 3 deletions
+80
View File
@@ -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 () => {});
}
}