Files
mdg-filius/packages/server/src/persistence.ts
T
julian.kiedaischandClaude Opus 5.5 5ded84cb33 feat: F5.2 sign-in with the dashboard's ticket and the service way
Server F checks the dashboard's entry ticket as itsdangerous does
(tools/ticket-oracle: tickets made by itsdangerous 2.2.0 itself), redeems
it at /__auth for a session cookie (only the key's SHA-256 is kept) and
decides at /sync by the session's grant: none, read or write; a frozen
project is read by everyone; every refusal names its reason (one list in
packages/protocol/src/sync.ts). A WebSocket from a page of another origin
is refused. /svc/* with the service key makes, deletes, freezes and
thaws projects, ends a person's grant or all their sessions (connections
let go) and lists the sessions. Snapshots, restoring and copying move to
F6 (they need the canonical doc.json and an epoch).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:56:45 +00:00

89 lines
3.3 KiB
TypeScript

// 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<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;
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<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(() => (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<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 });
});
}
/** Nothing stored any more; done once what is being stored now is. */
discard(): Promise<void> {
this.discarded = true;
return this.flush();
}
/** Everything stored that was asked for. */
flush(): Promise<void> {
return this.enqueue(async () => {});
}
}