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
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:
parent
d4ebcfcfbe
commit
7d8f331870
@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
152
apps/collab/src/persistence.db.test.ts
Normal file
152
apps/collab/src/persistence.db.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
108
apps/collab/src/persistence.test.ts
Normal file
108
apps/collab/src/persistence.test.ts
Normal 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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
182
apps/collab/src/persistence.ts
Normal file
182
apps/collab/src/persistence.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
43
apps/collab/src/testing/fake-persistence.ts
Normal file
43
apps/collab/src/testing/fake-persistence.ts
Normal 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
17
apps/collab/src/testing/test-db.ts
Normal file
17
apps/collab/src/testing/test-db.ts
Normal 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;
|
||||||
69
apps/collab/src/yjs-content.ts
Normal file
69
apps/collab/src/yjs-content.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
11
apps/collab/vitest.config.ts
Normal file
11
apps/collab/vitest.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
50
apps/collab/vitest.global-setup.ts
Normal file
50
apps/collab/vitest.global-setup.ts
Normal 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
15
pnpm-lock.yaml
generated
@ -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:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user