Add collab document persistence hooks and content-cache refresh (#35)
All checks were successful
CD / Build and push images (push) Successful in 2m51s
CI / Lint, typecheck, test (push) Successful in 1m55s
CI / Auth e2e pack (push) Successful in 2m0s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 11s

The collaboration server becomes the writer of page state (ADR 0003,
realtime-collaboration.md §lifecycle):

- onLoadDocument reconstructs a page's Y.Doc from PostgreSQL by applying
  `pages.ydoc_state` and then every `page_updates` row in order, so a page
  with a long update log loads correctly.
- onStoreDocument persists debounced (2 s, max 30 s): it appends the delta
  since the last flush to `page_updates`, periodically merges the log back
  into `ydoc_state` (inline threshold; the session-aware compaction of idle
  pages remains the separate job, #40), refreshes `page_content_cache`
  (plain text / Markdown / HTML / outline via the shared derivation, #24),
  bumps `pages.updated_at`, and keeps `Attachment.pageId` pointed at the
  embedding page (#31). Each flush runs in one transaction and its duration
  is logged.
- The document size ceiling (MAX_PAGE_DOCUMENT_BYTES) is enforced on store:
  an oversize document is not persisted and the clients are notified with a
  stateless error so they can revert.

Persistence is an injected port (PagePersistence): the Postgres
implementation is covered by a DB-backed test (store/load round-trip,
content-cache refresh, a 1000-entry update log, size-ceiling rejection,
not-found), and the hook wiring — two-client sync, survival across a server
restart, and the size-ceiling stateless notification — by an integration
test using an in-memory fake. The collab package gains its own vitest setup
that provisions an isolated `_collab` test database.

The REST `PUT /pages/:id/state` write path stays in place for now and is
retired (410) together with switching the editor to live collaboration in
#36, so the deployed editor is never left unable to save between the two
deploys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
This commit is contained in:
Claude Opus 4.8 2026-07-08 17:06:41 +02:00
parent d4ebcfcfbe
commit 7d8f331870
14 changed files with 739 additions and 11 deletions

View File

@ -17,14 +17,17 @@
"@dorfteich/shared": "workspace:*", "@dorfteich/shared": "workspace:*",
"@hocuspocus/server": "^4.3.0", "@hocuspocus/server": "^4.3.0",
"pg": "^8.16.0", "pg": "^8.16.0",
"pino": "^9.6.0" "pino": "^9.6.0",
"prosemirror-model": "^1.25.9",
"y-prosemirror": "^1.3.7",
"yjs": "^13.6.31"
}, },
"devDependencies": { "devDependencies": {
"@hocuspocus/provider": "^4.3.0", "@hocuspocus/provider": "^4.3.0",
"@types/node": "^26.1.0", "@types/node": "^26.1.0",
"@types/pg": "^8.11.0", "@types/pg": "^8.11.0",
"prisma": "^6.3.0",
"tsx": "^4.19.0", "tsx": "^4.19.0",
"vitest": "^3.0.0", "vitest": "^3.0.0"
"yjs": "^13.6.0"
} }
} }

View File

@ -7,6 +7,7 @@ import * as Y from 'yjs';
import { createCollabServer } from './server.js'; import { createCollabServer } from './server.js';
import { freePort } from './testing/free-port.js'; import { freePort } from './testing/free-port.js';
import { InMemoryPagePersistence } from './testing/fake-persistence.js';
const secret = 'integration-test-secret-32-chars!!'; const secret = 'integration-test-secret-32-chars!!';
const logger = pino({ enabled: false }); const logger = pino({ enabled: false });
@ -31,6 +32,7 @@ describe('collab authentication', () => {
logger, logger,
tokenSecret: secret, tokenSecret: secret,
pingDatabase: async () => ({ ok: true }), pingDatabase: async () => ({ ok: true }),
persistence: new InMemoryPagePersistence(),
}); });
const port = await freePort(); const port = await freePort();
await server.listen(port); await server.listen(port);

View File

@ -2,6 +2,7 @@ import { collabEnvSchema, parseEnv } from '@dorfteich/shared';
import { createPool, pingDatabase } from './db.js'; import { createPool, pingDatabase } from './db.js';
import { createLogger } from './logger.js'; import { createLogger } from './logger.js';
import { PostgresPagePersistence } from './persistence.js';
import { createCollabServer } from './server.js'; import { createCollabServer } from './server.js';
/** /**
@ -20,6 +21,7 @@ async function bootstrap(): Promise<void> {
logger, logger,
tokenSecret: env.COLLAB_TOKEN_SECRET, tokenSecret: env.COLLAB_TOKEN_SECRET,
pingDatabase: () => pingDatabase(pool), pingDatabase: () => pingDatabase(pool),
persistence: new PostgresPagePersistence(pool),
}); });
await server.listen(env.PORT); await server.listen(env.PORT);

View File

@ -0,0 +1,152 @@
import { randomUUID } from 'node:crypto';
import { editorSchema } from '@dorfteich/shared';
import { Pool } from 'pg';
import { prosemirrorJSONToYXmlFragment } from 'y-prosemirror';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { PostgresPagePersistence } from './persistence.js';
import { deriveContentFromDoc } from './yjs-content.js';
import { collabTestDatabaseUrlOrUndefined } from './testing/test-db.js';
const url = collabTestDatabaseUrlOrUndefined;
/** A Y.Doc whose "default" XmlFragment holds a single paragraph of `text`. */
function makeDoc(text: string): Y.Doc {
const doc = new Y.Doc();
const paragraph = editorSchema.node('paragraph', null, text ? [editorSchema.text(text)] : []);
const pmDoc = editorSchema.node('doc', null, [paragraph]);
prosemirrorJSONToYXmlFragment(editorSchema, pmDoc.toJSON(), doc.getXmlFragment('default'));
return doc;
}
/**
* A minimal valid Yjs state with no document content. Seeding pages with an
* empty state (rather than an empty paragraph) keeps these SQL round-trip tests
* from merging two independent doc lineages a test artifact, not something the
* real lifecycle produces, where the editor always edits the loaded document.
*/
function emptyState(): Buffer {
const doc = new Y.Doc();
const update = Buffer.from(Y.encodeStateAsUpdate(doc));
doc.destroy();
return update;
}
describe.skipIf(!url)('PostgresPagePersistence (DB-backed)', () => {
let pool: Pool;
const userId = randomUUID();
const pondId = randomUUID();
const createdPageIds: string[] = [];
async function createPage(): Promise<string> {
const id = randomUUID();
await pool.query(
`INSERT INTO pages (id, pond_id, title, slug, ydoc_state, sort_key, created_by, updated_at)
VALUES ($1, $2, 'Test', $3, $4, 'a0', $5, now())`,
[id, pondId, `p-${id.slice(0, 8)}`, emptyState(), userId],
);
createdPageIds.push(id);
return id;
}
beforeAll(async () => {
pool = new Pool({ connectionString: url });
await pool.query(
'INSERT INTO users (id, username, email, display_name) VALUES ($1, $2, $3, $4)',
[userId, `collab-${userId.slice(0, 8)}`, `${userId}@example.test`, 'Collab Tester'],
);
await pool.query(
`INSERT INTO ponds (id, slug, name, type, owner_id, updated_at)
VALUES ($1, $2, 'Collab Pond', 'PERSONAL', $3, now())`,
[pondId, `collab-pond-${pondId.slice(0, 8)}`, userId],
);
});
afterAll(async () => {
if (createdPageIds.length > 0) {
// page_updates and page_content_cache cascade on page delete.
await pool.query('DELETE FROM pages WHERE id = ANY($1::text[])', [createdPageIds]);
}
await pool.query('DELETE FROM ponds WHERE id = $1', [pondId]);
await pool.query('DELETE FROM users WHERE id = $1', [userId]);
await pool.end();
});
it('stores a document and reloads identical content, refreshing the cache', async () => {
const pageId = await createPage();
const persistence = new PostgresPagePersistence(pool);
const doc = makeDoc('Hello from the DB test');
const result = await persistence.store(pageId, doc);
expect(result.outcome).toBe('stored');
const cache = await pool.query<{ markdown: string; plain_text: string }>(
'SELECT markdown, plain_text FROM page_content_cache WHERE page_id = $1',
[pageId],
);
expect(cache.rows[0]?.markdown).toContain('Hello from the DB test');
expect(cache.rows[0]?.plain_text).toContain('Hello from the DB test');
const reloaded = new Y.Doc();
const loaded = await persistence.loadInto(pageId, reloaded);
expect(loaded).toBe(true);
expect(deriveContentFromDoc(reloaded).markdown).toBe(deriveContentFromDoc(doc).markdown);
});
it('reconstructs a document from a long update log (1000 entries)', async () => {
const pageId = await createPage();
// Build 1000 independent Yjs updates, each a single character inserted into
// a scratch text type, then bulk-insert them as the page's update log.
const master = new Y.Doc();
let vector = Y.encodeStateVector(master);
const seqs: number[] = [];
const updates: Buffer[] = [];
for (let i = 0; i < 1000; i += 1) {
master.getText('log').insert(i, 'x');
seqs.push(i);
updates.push(Buffer.from(Y.encodeStateAsUpdate(master, vector)));
vector = Y.encodeStateVector(master);
}
await pool.query(
`INSERT INTO page_updates (id, page_id, seq, update)
SELECT gen_random_uuid(), $1, s, u FROM unnest($2::int[], $3::bytea[]) AS t(s, u)`,
[pageId, seqs, updates],
);
const persistence = new PostgresPagePersistence(pool);
const reloaded = new Y.Doc();
const loaded = await persistence.loadInto(pageId, reloaded);
expect(loaded).toBe(true);
expect(reloaded.getText('log').toString()).toBe('x'.repeat(1000));
});
it('rejects an oversize document without persisting anything', async () => {
const pageId = await createPage();
const persistence = new PostgresPagePersistence(pool);
const doc = makeDoc('');
doc.getText('bloat').insert(0, 'a'.repeat(6 * 1024 * 1024)); // > 5 MiB ceiling
const result = await persistence.store(pageId, doc);
expect(result.outcome).toBe('too_large');
const updates = await pool.query('SELECT 1 FROM page_updates WHERE page_id = $1', [pageId]);
expect(updates.rowCount).toBe(0);
const cache = await pool.query('SELECT 1 FROM page_content_cache WHERE page_id = $1', [pageId]);
expect(cache.rowCount).toBe(0);
});
it('reports not-found for a page that does not exist', async () => {
const persistence = new PostgresPagePersistence(pool);
const missingId = randomUUID();
const stored = await persistence.store(missingId, makeDoc('orphan'));
expect(stored.outcome).toBe('not_found');
const loaded = await persistence.loadInto(missingId, new Y.Doc());
expect(loaded).toBe(false);
});
});

View File

@ -0,0 +1,108 @@
import { HocuspocusProvider } from '@hocuspocus/provider';
import { signCollabToken } from '@dorfteich/shared/token-crypto';
import { pino } from 'pino';
import { afterEach, describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { createCollabServer } from './server.js';
import { freePort } from './testing/free-port.js';
import { InMemoryPagePersistence } from './testing/fake-persistence.js';
const secret = 'persistence-test-secret-32-chars!!';
const logger = pino({ enabled: false });
/** Poll `predicate` until it is true or the timeout elapses. */
async function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error('timed out waiting for condition');
}
describe('collab persistence hooks', () => {
const cleanups: Array<() => Promise<void> | void> = [];
afterEach(async () => {
// Tear down in reverse order (providers before their server).
for (const cleanup of cleanups.splice(0).reverse()) await cleanup();
});
async function startServer(persistence: InMemoryPagePersistence): Promise<string> {
const server = createCollabServer({
version: 'test',
logger,
tokenSecret: secret,
pingDatabase: async () => ({ ok: true }),
persistence,
});
const port = await freePort();
await server.listen(port);
cleanups.push(() => server.destroy());
return `ws://127.0.0.1:${port}`;
}
function connect(url: string, pageId: string): { provider: HocuspocusProvider; doc: Y.Doc } {
const doc = new Y.Doc();
const token = signCollabToken({ userId: `u-${pageId}`, pageId, mode: 'rw' }, secret, 60);
const provider = new HocuspocusProvider({ url, name: pageId, document: doc, token });
cleanups.push(() => provider.destroy());
return { provider, doc };
}
it('syncs edits between two read-write clients on the same page', async () => {
const persistence = new InMemoryPagePersistence();
const url = await startServer(persistence);
const pageId = '11111111-1111-1111-1111-111111111111';
const a = connect(url, pageId);
const b = connect(url, pageId);
a.doc.getText('t').insert(0, 'hello from A');
await waitFor(() => b.doc.getText('t').toString() === 'hello from A');
expect(b.doc.getText('t').toString()).toBe('hello from A');
});
it('persists edits so they survive a collab-server restart', async () => {
const persistence = new InMemoryPagePersistence();
const pageId = '22222222-2222-2222-2222-222222222222';
const url1 = await startServer(persistence);
const first = connect(url1, pageId);
first.doc.getText('t').insert(0, 'durable content');
// Disconnecting the only client triggers an immediate final store.
await waitFor(() => first.provider.isSynced);
await first.provider.destroy();
await waitFor(() => persistence.storeCalls > 0);
// A brand-new server instance reusing the same persistence must reload it.
const url2 = await startServer(persistence);
const second = connect(url2, pageId);
await waitFor(() => second.doc.getText('t').toString() === 'durable content');
expect(second.doc.getText('t').toString()).toBe('durable content');
});
it('rejects an oversize document and notifies the client via a stateless error', async () => {
const persistence = new InMemoryPagePersistence();
persistence.sizeLimit = 10; // any real edit exceeds this
const url = await startServer(persistence);
const pageId = '33333333-3333-3333-3333-333333333333';
const { provider, doc } = connect(url, pageId);
let statelessPayload: string | undefined;
provider.on('stateless', ({ payload }: { payload: string }) => {
statelessPayload = payload;
});
doc.getText('t').insert(0, 'this document is over the ceiling');
await waitFor(() => statelessPayload !== undefined);
expect(JSON.parse(statelessPayload!)).toMatchObject({
type: 'error',
code: 'page_document_too_large',
});
});
});

View File

@ -0,0 +1,182 @@
import { MAX_PAGE_DOCUMENT_BYTES } from '@dorfteich/shared';
import type { Pool } from 'pg';
import * as Y from 'yjs';
import { deriveContentFromDoc } from './yjs-content.js';
/** Outcome of a store attempt, surfaced to the caller for logging/notification. */
export interface StoreResult {
outcome: 'stored' | 'too_large' | 'not_found';
/** Size of the full merged state in bytes (for logging and the size ceiling). */
bytes: number;
/** Wall-clock duration of the flush, in milliseconds (ADR: measure flushes). */
durationMs: number;
/** Whether this flush merged the update log back into `pages.ydoc_state`. */
merged: boolean;
}
/**
* The persistence port the collab hooks depend on (ADR 0003). Kept as an
* interface so the server hooks can be integration-tested with an in-memory
* fake, while the Postgres implementation is exercised by a DB-backed test.
*/
export interface PagePersistence {
/**
* Apply the persisted state of `pageId` into `doc`. Returns `false` when the
* page does not exist or is trashed the caller then keeps the empty doc.
*/
loadInto(pageId: string, doc: Y.Doc): Promise<boolean>;
/** Persist the current `doc`, enforcing the document size ceiling. */
store(pageId: string, doc: Y.Doc): Promise<StoreResult>;
/** Release per-document bookkeeping when Hocuspocus unloads the document. */
forget(pageId: string): void;
}
/**
* When the append log for an open document grows past this many rows, a store
* flush merges it back into `pages.ydoc_state` and truncates the log. This
* bounds the cost of `loadInto` for long-lived sessions; the heavier,
* session-aware compaction of idle pages is the separate maintenance job (#40,
* default threshold 500 in realtime-collaboration.md).
*/
const INLINE_MERGE_THRESHOLD = 200;
export class PostgresPagePersistence implements PagePersistence {
/**
* The Yjs state vector last persisted for each open document, so each store
* appends only the delta since the previous flush. Seeded by `loadInto` and
* cleared by `forget`; a missing entry safely falls back to storing the full
* state as the delta.
*/
private readonly lastStoredVector = new Map<string, Uint8Array>();
constructor(private readonly pool: Pool) {}
async loadInto(pageId: string, doc: Y.Doc): Promise<boolean> {
const pageRow = await this.pool.query<{ ydoc_state: Buffer }>(
'SELECT ydoc_state FROM pages WHERE id = $1 AND deleted_at IS NULL',
[pageId],
);
const stored = pageRow.rows[0];
if (!stored) return false;
Y.applyUpdate(doc, new Uint8Array(stored.ydoc_state));
const updates = await this.pool.query<{ update: Buffer }>(
'SELECT update FROM page_updates WHERE page_id = $1 ORDER BY seq ASC',
[pageId],
);
for (const row of updates.rows) {
Y.applyUpdate(doc, new Uint8Array(row.update));
}
this.lastStoredVector.set(pageId, Y.encodeStateVector(doc));
return true;
}
async store(pageId: string, doc: Y.Doc): Promise<StoreResult> {
const start = performance.now();
// Capture everything from the live doc synchronously, before any await, so
// concurrent inbound updates cannot change what this flush persists.
const full = Y.encodeStateAsUpdate(doc);
const durationOf = (): number => performance.now() - start;
if (full.byteLength > MAX_PAGE_DOCUMENT_BYTES) {
return {
outcome: 'too_large',
bytes: full.byteLength,
durationMs: durationOf(),
merged: false,
};
}
const previousVector = this.lastStoredVector.get(pageId);
const delta = previousVector ? Y.encodeStateAsUpdate(doc, previousVector) : full;
const derived = deriveContentFromDoc(doc);
const nextVector = Y.encodeStateVector(doc);
const client = await this.pool.connect();
try {
await client.query('BEGIN');
// Lock the page row for the duration of the flush: this serialises seq
// allocation and guards against storing to a page trashed mid-session.
const page = await client.query<{ pond_id: string }>(
'SELECT pond_id FROM pages WHERE id = $1 AND deleted_at IS NULL FOR UPDATE',
[pageId],
);
const pageMeta = page.rows[0];
if (!pageMeta) {
await client.query('ROLLBACK');
return {
outcome: 'not_found',
bytes: full.byteLength,
durationMs: durationOf(),
merged: false,
};
}
const pondId = pageMeta.pond_id;
const seqRow = await client.query<{ seq: number }>(
'SELECT COALESCE(MAX(seq) + 1, 0) AS seq FROM page_updates WHERE page_id = $1',
[pageId],
);
const seq = seqRow.rows[0]?.seq ?? 0;
await client.query(
'INSERT INTO page_updates (id, page_id, seq, update) VALUES (gen_random_uuid(), $1, $2, $3)',
[pageId, seq, Buffer.from(delta)],
);
const merged = seq + 1 >= INLINE_MERGE_THRESHOLD;
if (merged) {
await client.query('UPDATE pages SET ydoc_state = $2, updated_at = now() WHERE id = $1', [
pageId,
Buffer.from(full),
]);
await client.query('DELETE FROM page_updates WHERE page_id = $1', [pageId]);
} else {
await client.query('UPDATE pages SET updated_at = now() WHERE id = $1', [pageId]);
}
await client.query(
`INSERT INTO page_content_cache (page_id, plain_text, markdown, html, outline, updated_at)
VALUES ($1, $2, $3, $4, $5::jsonb, now())
ON CONFLICT (page_id) DO UPDATE
SET plain_text = EXCLUDED.plain_text,
markdown = EXCLUDED.markdown,
html = EXCLUDED.html,
outline = EXCLUDED.outline,
updated_at = now()`,
[
pageId,
derived.plainText,
derived.markdown,
derived.html,
JSON.stringify(derived.outline),
],
);
if (derived.imageFileIds.length > 0) {
// Keep Attachment.pageId pointed at the page embedding the file (#31),
// scoped to the pond so a client cannot claim another pond's file.
await client.query(
'UPDATE attachments SET page_id = $1 WHERE id = ANY($2::text[]) AND pond_id = $3',
[pageId, derived.imageFileIds, pondId],
);
}
await client.query('COMMIT');
this.lastStoredVector.set(pageId, nextVector);
return { outcome: 'stored', bytes: full.byteLength, durationMs: durationOf(), merged };
} catch (error) {
await client.query('ROLLBACK').catch(() => undefined);
throw error;
} finally {
client.release();
}
}
forget(pageId: string): void {
this.lastStoredVector.delete(pageId);
}
}

View File

@ -3,6 +3,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { createCollabServer } from './server.js'; import { createCollabServer } from './server.js';
import { freePort } from './testing/free-port.js'; import { freePort } from './testing/free-port.js';
import { InMemoryPagePersistence } from './testing/fake-persistence.js';
import type { CollabHealthReport, DatabaseProbe } from './health.js'; import type { CollabHealthReport, DatabaseProbe } from './health.js';
// A silent logger; these tests assert behaviour, not log output. // A silent logger; these tests assert behaviour, not log output.
@ -20,6 +21,7 @@ describe('collab server', () => {
logger, logger,
tokenSecret: 'server-test-secret-32-characters!', tokenSecret: 'server-test-secret-32-characters!',
pingDatabase: probe, pingDatabase: probe,
persistence: new InMemoryPagePersistence(),
}); });
// Hocuspocus' listen(port) ignores a falsy port (0 → default 80), so bind // Hocuspocus' listen(port) ignores a falsy port (0 → default 80), so bind
// an explicit OS-assigned free port instead. // an explicit OS-assigned free port instead.

View File

@ -1,8 +1,10 @@
import { Server } from '@hocuspocus/server'; import { Server } from '@hocuspocus/server';
import { MAX_PAGE_DOCUMENT_BYTES } from '@dorfteich/shared';
import { verifyCollabToken } from '@dorfteich/shared/token-crypto'; import { verifyCollabToken } from '@dorfteich/shared/token-crypto';
import type { Logger } from 'pino'; import type { Logger } from 'pino';
import { buildHealthReport, isHealthRequest, type DatabaseProbe } from './health.js'; import { buildHealthReport, isHealthRequest, type DatabaseProbe } from './health.js';
import type { PagePersistence } from './persistence.js';
/** Per-connection context returned by onAuthenticate and used by later hooks. */ /** Per-connection context returned by onAuthenticate and used by later hooks. */
export interface CollabContext { export interface CollabContext {
@ -18,16 +20,29 @@ export interface CollabServerDeps {
tokenSecret: string; tokenSecret: string;
/** Probe used by the `/healthz` endpoint. Injected so it is easy to test. */ /** Probe used by the `/healthz` endpoint. Injected so it is easy to test. */
pingDatabase: () => Promise<DatabaseProbe>; pingDatabase: () => Promise<DatabaseProbe>;
/** Loads and persists page documents against PostgreSQL (#35). */
persistence: PagePersistence;
} }
/** /**
* The Hocuspocus collaboration server (ADR 0003). This skeleton (issue #33) * Stateless message the server broadcasts when a document exceeds the size
* wires structured connection logging and a `/healthz` endpoint; it does not * ceiling on store: the client surfaces this and reverts the offending edit.
* yet authenticate connections (issue #34) or persist documents (issue #35), */
* so any WebSocket handshake is currently accepted. export interface CollabErrorMessage {
type: 'error';
code: 'page_document_too_large';
limitBytes: number;
}
/**
* The Hocuspocus collaboration server (ADR 0003). It authenticates every
* connection with the api-minted token (#34) and is the writer of page state:
* `onLoadDocument` reconstructs the document from PostgreSQL and
* `onStoreDocument` persists it debounced, refreshing the derived content
* cache (#35).
*/ */
export function createCollabServer(deps: CollabServerDeps): Server { export function createCollabServer(deps: CollabServerDeps): Server {
const { version, logger, tokenSecret, pingDatabase } = deps; const { version, logger, tokenSecret, pingDatabase, persistence } = deps;
return new Server({ return new Server({
name: 'dorfteich-collab', name: 'dorfteich-collab',
@ -36,6 +51,10 @@ export function createCollabServer(deps: CollabServerDeps): Server {
// index.ts owns graceful shutdown (it also closes the db pool); don't let // index.ts owns graceful shutdown (it also closes the db pool); don't let
// Hocuspocus install its own signal handlers that would call process.exit. // Hocuspocus install its own signal handlers that would call process.exit.
stopOnSignals: false, stopOnSignals: false,
// Debounced persistence (realtime-collaboration.md §lifecycle): store 2 s
// after the last change, and at least every 30 s under continuous editing.
debounce: 2000,
maxDebounce: 30000,
/** /**
* Authorize every connection with the token the api minted after its own * Authorize every connection with the token the api minted after its own
@ -85,6 +104,65 @@ export function createCollabServer(deps: CollabServerDeps): Server {
); );
}, },
/**
* Reconstruct the document from PostgreSQL (`ydoc_state` merged with the
* `page_updates` log) on first open. A page that no longer exists loads as
* an empty document; the token check already gates real access (#34).
*/
async onLoadDocument({ documentName, document }) {
const loaded = await persistence.loadInto(documentName, document);
logger.debug(
{ event: 'document.load', documentName, loaded },
loaded ? 'document loaded' : 'document not found, starting empty',
);
return document;
},
/**
* Persist the document (debounced by the config above) and refresh the
* derived `page_content_cache`. An oversize document is rejected without
* persisting and the offending clients are notified via a stateless
* message so they can revert (realtime-collaboration.md failure modes).
*/
async onStoreDocument({ documentName, document }) {
const result = await persistence.store(documentName, document);
if (result.outcome === 'too_large') {
const message: CollabErrorMessage = {
type: 'error',
code: 'page_document_too_large',
limitBytes: MAX_PAGE_DOCUMENT_BYTES,
};
document.broadcastStateless(JSON.stringify(message));
logger.warn(
{ event: 'document.store.rejected', documentName, bytes: result.bytes },
'document exceeds size ceiling; update not persisted',
);
return;
}
if (result.outcome === 'not_found') {
logger.warn(
{ event: 'document.store.skipped', documentName },
'document no longer exists; update not persisted',
);
return;
}
logger.debug(
{
event: 'document.store',
documentName,
bytes: result.bytes,
durationMs: Math.round(result.durationMs),
merged: result.merged,
},
'document persisted',
);
},
/** Drop the per-document store bookkeeping once Hocuspocus unloads it. */
async afterUnloadDocument({ documentName }) {
persistence.forget(documentName);
},
async onRequest({ request, response }) { async onRequest({ request, response }) {
if (!isHealthRequest(request.method, request.url)) { if (!isHealthRequest(request.method, request.url)) {
// Not a health probe: let Hocuspocus handle the request. // Not a health probe: let Hocuspocus handle the request.

View File

@ -0,0 +1,43 @@
import * as Y from 'yjs';
import type { PagePersistence, StoreResult } from '../persistence.js';
/**
* In-memory {@link PagePersistence} for tests: it keeps the last merged state
* per page so a "server restart" (destroy + recreate around the same instance)
* still reloads content, without needing a database. The Postgres implementation
* is covered separately by `persistence.db.test.ts`.
*/
export class InMemoryPagePersistence implements PagePersistence {
private readonly states = new Map<string, Uint8Array>();
/** Page ids that should report as missing (to exercise the not-found path). */
readonly missing = new Set<string>();
/** Overridable size ceiling so a test can trip it without a 5 MiB document. */
sizeLimit = Number.POSITIVE_INFINITY;
/** store() call count, for assertions. */
storeCalls = 0;
async loadInto(pageId: string, doc: Y.Doc): Promise<boolean> {
if (this.missing.has(pageId)) return false;
const state = this.states.get(pageId);
if (state) Y.applyUpdate(doc, state);
return true;
}
async store(pageId: string, doc: Y.Doc): Promise<StoreResult> {
this.storeCalls += 1;
const full = Y.encodeStateAsUpdate(doc);
if (full.byteLength > this.sizeLimit) {
return { outcome: 'too_large', bytes: full.byteLength, durationMs: 0, merged: false };
}
if (this.missing.has(pageId)) {
return { outcome: 'not_found', bytes: full.byteLength, durationMs: 0, merged: false };
}
this.states.set(pageId, full);
return { outcome: 'stored', bytes: full.byteLength, durationMs: 0, merged: false };
}
forget(): void {
// Nothing to release for the in-memory fake.
}
}

View File

@ -0,0 +1,17 @@
/**
* The DB-backed collab tests run against their own database, derived from
* `TEST_DATABASE_URL` by suffixing the database name with `_collab`. This keeps
* them isolated from the api's DB tests, which share `TEST_DATABASE_URL` and run
* concurrently under `pnpm -r test` (both a schema-push race and a data race
* would otherwise be possible). The vitest global setup creates and migrates
* this database; the tests skip themselves when `TEST_DATABASE_URL` is absent.
*/
export function collabTestDatabaseUrl(base: string): string {
const url = new URL(base);
url.pathname = url.pathname.replace(/\/([^/]+)$/, (_full, db: string) => `/${db}_collab`);
return url.toString();
}
export const collabTestDatabaseUrlOrUndefined = process.env.TEST_DATABASE_URL
? collabTestDatabaseUrl(process.env.TEST_DATABASE_URL)
: undefined;

View File

@ -0,0 +1,69 @@
import {
docToHtml,
docToMarkdown,
docToPlainText,
editorSchema,
extractOutline,
type OutlineEntry,
} from '@dorfteich/shared';
import { Node } from 'prosemirror-model';
import { yXmlFragmentToProseMirrorRootNode } from 'y-prosemirror';
import * as Y from 'yjs';
/**
* The Yjs XmlFragment name the editor binds to TipTap's collaboration
* extension defaults to "default" (#25). api (`apps/api/src/pages/yjs-content.ts`),
* web, and collab must all agree on this or Yjs states become unreadable across
* them. This file deliberately mirrors the api's derivation (the shared
* functions come from `@dorfteich/shared`, #24); the two were kept separate on
* purpose rather than abstracted prematurely (see the M3 handoff).
*/
const FRAGMENT_NAME = 'default';
/** Thrown for state bytes that are not a well-formed Yjs update for this schema. */
export class InvalidPageStateError extends Error {}
function docFromDoc(ydoc: Y.Doc): Node {
try {
return yXmlFragmentToProseMirrorRootNode(ydoc.getXmlFragment(FRAGMENT_NAME), editorSchema);
} catch (error) {
throw new InvalidPageStateError(error instanceof Error ? error.message : 'invalid Yjs state');
}
}
export interface DerivedPageContent {
plainText: string;
markdown: string;
html: string;
outline: OutlineEntry[];
/** fileIds of every `image` node embedded in the document keeps
* `Attachment.pageId` pointed at the page that embeds the file (issue #31),
* mirroring the api's REST save path. */
imageFileIds: string[];
}
function imageFileIdsOf(doc: Node): string[] {
const ids: string[] = [];
doc.descendants((node) => {
if (node.type.name === 'image' && typeof node.attrs.fileId === 'string') {
ids.push(node.attrs.fileId);
}
});
return ids;
}
/**
* Decode a live Yjs document into the derived representations stored in
* `page_content_cache` (issue #23/#35), using the shared editor schema (#24)
* so the cache matches exactly what the api derives from the same state.
*/
export function deriveContentFromDoc(ydoc: Y.Doc): DerivedPageContent {
const doc = docFromDoc(ydoc);
return {
plainText: docToPlainText(doc),
markdown: docToMarkdown(doc),
html: docToHtml(doc),
outline: extractOutline(doc),
imageFileIds: imageFileIdsOf(doc),
};
}

View File

@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
globalSetup: './vitest.global-setup.ts',
// The DB-backed persistence suite shares one database; run files serially
// so they cannot race each other (mirrors the api's vitest config).
fileParallelism: false,
},
});

View File

@ -0,0 +1,50 @@
import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Client } from 'pg';
import { collabTestDatabaseUrl } from './src/testing/test-db.js';
const require = createRequire(import.meta.url);
const here = dirname(fileURLToPath(import.meta.url));
/**
* Prepare the isolated collab test database (see `test-db.ts`): create it if
* missing, then push the current Prisma schema (owned by the api package) into
* it. Runs once per test run; a no-op when `TEST_DATABASE_URL` is unset, in
* which case the DB-backed suite skips itself.
*/
export default async function globalSetup(): Promise<void> {
const base = process.env.TEST_DATABASE_URL;
if (!base) return;
const target = collabTestDatabaseUrl(base);
const dbName = decodeURIComponent(new URL(target).pathname.slice(1));
const admin = new Client({ connectionString: base });
await admin.connect();
try {
const exists = await admin.query('SELECT 1 FROM pg_database WHERE datname = $1', [dbName]);
if (exists.rowCount === 0) {
// Identifier can't be parameterised; dbName is derived from our own env.
await admin.query(`CREATE DATABASE "${dbName}"`);
}
} finally {
await admin.end();
}
execFileSync(
process.execPath,
[
require.resolve('prisma/build/index.js'),
'db',
'push',
'--skip-generate',
'--schema',
resolve(here, '../api/prisma/schema.prisma'),
],
{ env: { ...process.env, DATABASE_URL: target }, stdio: 'inherit', cwd: here },
);
}

15
pnpm-lock.yaml generated
View File

@ -156,6 +156,15 @@ importers:
pino: pino:
specifier: ^9.6.0 specifier: ^9.6.0
version: 9.14.0 version: 9.14.0
prosemirror-model:
specifier: ^1.25.9
version: 1.25.9
y-prosemirror:
specifier: ^1.3.7
version: 1.3.7(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.42.0)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31)
yjs:
specifier: ^13.6.31
version: 13.6.31
devDependencies: devDependencies:
'@hocuspocus/provider': '@hocuspocus/provider':
specifier: ^4.3.0 specifier: ^4.3.0
@ -166,15 +175,15 @@ importers:
'@types/pg': '@types/pg':
specifier: ^8.11.0 specifier: ^8.11.0
version: 8.20.0 version: 8.20.0
prisma:
specifier: ^6.3.0
version: 6.19.3(typescript@5.9.3)
tsx: tsx:
specifier: ^4.19.0 specifier: ^4.19.0
version: 4.23.0 version: 4.23.0
vitest: vitest:
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.2.6(@types/node@26.1.0)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0)(tsx@4.23.0) version: 3.2.6(@types/node@26.1.0)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0)(tsx@4.23.0)
yjs:
specifier: ^13.6.0
version: 13.6.31
apps/web: apps/web:
dependencies: dependencies: