dorfteich/apps/collab/src/persistence.ts
Claude Opus 4.8 91dfccf226
All checks were successful
CD / Build and push images (push) Successful in 3m5s
CI / Lint, typecheck, test (push) Successful in 2m19s
CI / Auth e2e pack (push) Successful in 2m51s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 12s
Add SearchProvider interface with PostgreSQL FTS (#49)
Full-text search behind a swappable interface (ADR 0010).

- prisma: `page_content_cache.search_vector tsvector` (Unsupported column);
  migration adds it plus a GIN index (raw SQL — the index is a production
  perf optimization; correctness holds without it, so schema-pushed test DBs
  work unchanged).
- shared: `normalizeForSearch` (NFKD + strip diacritics + lowercase) folds
  both the indexed text and the query, so 'Baume' finds 'Bäume' without the
  Postgres `unaccent` extension; search query schema + result view + highlight
  sentinels.
- api search module:
  - abstract `SearchProvider` (DI token: indexPage / removePage / search /
    reindexAll) so an external engine can replace the binding — a fake proves
    the seam in a test.
  - `PostgresSearchProvider`: weighted vector (title A, labels B, body C),
    `websearch_to_tsquery`, `ts_headline` snippets, results filtered to the
    ponds the user may read; `GET /search?q=&pondId=&labels=`.
  - `search:reindex` CLI (rebuilds from the content cache, idempotent).
  - reindex hooks: page create/rename (title) and label assign/unassign/
    rename/delete (labels are weight-B).
- collab: the persistence hook maintains `search_vector` in the same
  transaction as the content cache (same weighting, normalized).
- tests: shared normalize/schema; api db (title ranks above body, highlight,
  diacritic-insensitive match, permission filter, idempotent reindex) and the
  fake-provider DI test; collab persistence already covers the write path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-09 13:25:05 +02:00

221 lines
8.4 KiB
TypeScript

import { MAX_PAGE_DOCUMENT_BYTES, normalizeForSearch } 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; title: string }>(
'SELECT pond_id, title 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],
);
}
// Maintain the weighted full-text search vector (issue #49) in the same
// transaction as the cache — the same weighting the api's SearchProvider
// uses, folded through normalizeForSearch for diacritic-insensitive match.
const labelRow = await client.query<{ names: string | null }>(
`SELECT string_agg(l.name, ' ') AS names
FROM page_labels pl JOIN labels l ON l.id = pl.label_id
WHERE pl.page_id = $1`,
[pageId],
);
await client.query(
`UPDATE page_content_cache SET search_vector =
setweight(to_tsvector('simple', $2), 'A')
|| setweight(to_tsvector('simple', $3), 'B')
|| setweight(to_tsvector('simple', $4), 'C')
WHERE page_id = $1`,
[
pageId,
normalizeForSearch(pageMeta.title ?? ''),
normalizeForSearch(labelRow.rows[0]?.names ?? ''),
normalizeForSearch(derived.plainText),
],
);
// Rewrite this page's outgoing wikilink index (issue #47): replace all its
// rows with one per distinct target slug, resolved to a page in the same
// pond (null `to_page_id` = phantom, target does not exist yet).
await client.query('DELETE FROM page_links WHERE from_page_id = $1', [pageId]);
if (derived.wikilinkSlugs.length > 0) {
await client.query(
`INSERT INTO page_links (id, from_page_id, to_page_id, target_slug)
SELECT gen_random_uuid(), $1, target.id, s.link_slug
FROM unnest($2::text[]) AS s(link_slug)
LEFT JOIN pages AS target
ON target.pond_id = $3 AND target.slug = s.link_slug AND target.deleted_at IS NULL`,
[pageId, derived.wikilinkSlugs, 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);
}
}