dorfteich/apps/collab/src/version-store.ts
Claude Opus 4.8 6fb6f6fce7
All checks were successful
CD / Build and push images (push) Successful in 2m53s
CI / Lint, typecheck, test (push) Successful in 2m1s
CI / Auth e2e pack (push) Successful in 2m24s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
Add version snapshots: automatic, named, thinning (#41)
Version history is a core kickoff decision (ADR 0013). Snapshots are full,
self-contained encoded Yjs states, so restore never depends on the update
log and compaction (#40) cannot lose history.

(The page_versions / page_pending_contributors tables and base schema
landed a commit early, bundled into 3583a04; this commit completes #41.)

- schema: page_versions gains created_by (editor of manual/pre-restore
  versions; null for automatic snapshots). shared: PageVersionView,
  CreateVersionInput, PageVersionTrigger.
- collab: PostgresVersionStore tracks contributors per open doc (onChange),
  flushes them to the shared page_pending_contributors accumulator on store,
  creates an automatic snapshot on last-participant disconnect (only if
  something changed — no duplicate on a quick reconnect) and every 30
  active-editing minutes. Contributors and snapshot are consumed atomically.
- api: POST /pages/:id/versions creates a named version (write permission,
  label + creator, snapshot reconstructed from persisted state, consumes the
  same contributor accumulator). Daily version-thinning scheduler job keeps
  all versions for 90 days, then the newest auto snapshot per day; manual and
  pre-restore versions are never thinned. pre_restore trigger reserved for #42.

Tests: collab (one auto version on session end with the full two-author
contributor set, none when unchanged, no duplicate on reconnect, interval
snapshot); api (named version stores label+creator, contributor set consumed,
non-owner refused, thinning time-travel keeps newest-per-day beyond window).

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

160 lines
6.2 KiB
TypeScript

import type { Pool } from 'pg';
import type { Logger } from 'pino';
import * as Y from 'yjs';
/**
* Automatic version snapshots created by the collab server (issue #41, ADR
* 0013). Contributors are tracked per open document: {@link recordContributor}
* collects the users who edit, {@link onStore} flushes them to the shared
* `page_pending_contributors` accumulator (so the api's named-version endpoint
* sees them too) and creates the periodic interval snapshot, and
* {@link onSessionEnd} creates the end-of-session snapshot.
*
* A snapshot is only written when there were edits since the previous version
* (the accumulator is non-empty), which both implements "skip no-op periods"
* and prevents duplicate versions on a quick reconnect with no changes.
*/
export interface VersionStore {
/** Attribute a change to a user (called from `onChange`). */
recordContributor(pageId: string, userId: string): void;
/** Initialise interval bookkeeping when a document opens. */
noteOpened(pageId: string): void;
/** Flush contributors and create an interval snapshot if one is due. */
onStore(pageId: string, doc: Y.Doc): Promise<void>;
/** Create the end-of-session snapshot when the last participant leaves. */
onSessionEnd(pageId: string, doc: Y.Doc): Promise<void>;
/** Drop per-document bookkeeping when the document unloads. */
forget(pageId: string): void;
}
export interface VersionStoreDeps {
pool: Pool;
logger: Logger;
/** Active-editing interval between automatic snapshots (default 30 min). */
intervalMs?: number;
}
const DEFAULT_INTERVAL_MS = 30 * 60 * 1000;
export class PostgresVersionStore implements VersionStore {
private readonly intervalMs: number;
/** Users who have edited since the last flush, per open document. */
private readonly dirty = new Map<string, Set<string>>();
/** Timestamp (ms) of the last automatic snapshot, per open document. */
private readonly lastVersionAt = new Map<string, number>();
constructor(private readonly deps: VersionStoreDeps) {
this.intervalMs = deps.intervalMs ?? DEFAULT_INTERVAL_MS;
}
recordContributor(pageId: string, userId: string): void {
let set = this.dirty.get(pageId);
if (!set) {
set = new Set();
this.dirty.set(pageId, set);
}
set.add(userId);
}
noteOpened(pageId: string): void {
this.lastVersionAt.set(pageId, Date.now());
}
async onStore(pageId: string, doc: Y.Doc): Promise<void> {
await this.flushContributors(pageId);
const last = this.lastVersionAt.get(pageId) ?? Date.now();
if (Date.now() - last >= this.intervalMs) {
const created = await this.createVersion(pageId, doc);
// Reset the interval clock only when a snapshot was actually written, so
// an idle-but-open page doesn't churn empty checks into versions.
if (created) this.lastVersionAt.set(pageId, Date.now());
}
}
async onSessionEnd(pageId: string, doc: Y.Doc): Promise<void> {
await this.flushContributors(pageId);
await this.createVersion(pageId, doc);
}
forget(pageId: string): void {
this.dirty.delete(pageId);
this.lastVersionAt.delete(pageId);
}
/** Move the in-memory contributor set into the shared DB accumulator. */
private async flushContributors(pageId: string): Promise<void> {
const set = this.dirty.get(pageId);
if (!set || set.size === 0) return;
// Swap in a fresh set first so contributors arriving during the await are
// not lost with the ones being flushed.
this.dirty.set(pageId, new Set());
const users = [...set];
try {
await this.deps.pool.query(
`INSERT INTO page_pending_contributors (page_id, user_id)
SELECT $1, u FROM unnest($2::text[]) AS u
ON CONFLICT (page_id, user_id) DO NOTHING`,
[pageId, users],
);
} catch (error) {
// Put the users back so the next flush retries them; never throw out of a
// hook (a page purged mid-session is the expected benign failure here).
const current = this.dirty.get(pageId) ?? new Set<string>();
for (const u of users) current.add(u);
this.dirty.set(pageId, current);
this.deps.logger.warn(
{ event: 'version.contributors.flush_failed', pageId, err: (error as Error).message },
'could not flush contributors',
);
}
}
/**
* Write an automatic snapshot if there are pending contributors, consuming
* them atomically. Returns whether a version row was created.
*/
private async createVersion(pageId: string, doc: Y.Doc): Promise<boolean> {
const snapshot = Buffer.from(Y.encodeStateAsUpdate(doc));
const client = await this.deps.pool.connect();
try {
await client.query('BEGIN');
const pending = await client.query<{ user_id: string }>(
'SELECT user_id FROM page_pending_contributors WHERE page_id = $1 FOR UPDATE',
[pageId],
);
if (pending.rows.length === 0) {
await client.query('ROLLBACK');
return false; // nothing changed since the last version
}
const contributors = pending.rows.map((row) => row.user_id);
// Guard the FK: skip (but still clear the accumulator) if the page was
// trashed/purged mid-session.
const inserted = await client.query(
`INSERT INTO page_versions (id, page_id, ydoc_snapshot, trigger, label, contributor_ids, created_at)
SELECT gen_random_uuid(), $1, $2, 'AUTO', NULL, $3::text[], now()
WHERE EXISTS (SELECT 1 FROM pages WHERE id = $1 AND deleted_at IS NULL)`,
[pageId, snapshot, contributors],
);
await client.query('DELETE FROM page_pending_contributors WHERE page_id = $1', [pageId]);
await client.query('COMMIT');
const created = (inserted.rowCount ?? 0) > 0;
if (created) {
this.deps.logger.debug(
{ event: 'version.auto.created', pageId, contributors: contributors.length },
'automatic version snapshot created',
);
}
return created;
} catch (error) {
await client.query('ROLLBACK').catch(() => undefined);
this.deps.logger.warn(
{ event: 'version.auto.failed', pageId, err: (error as Error).message },
'could not create automatic version snapshot',
);
return false;
} finally {
client.release();
}
}
}