dorfteich/apps/collab/src/persistence.test.ts
Claude Opus 4.8 7d8f331870
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
Add collab document persistence hooks and content-cache refresh (#35)
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
2026-07-08 17:06:41 +02:00

109 lines
4.1 KiB
TypeScript

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',
});
});
});