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
109 lines
4.1 KiB
TypeScript
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',
|
|
});
|
|
});
|
|
});
|