dorfteich/apps/collab/src/version-store.db.test.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

127 lines
4.5 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import { Pool } from 'pg';
import { pino } from 'pino';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { PostgresVersionStore } from './version-store.js';
import { collabTestDatabaseUrlOrUndefined } from './testing/test-db.js';
const url = collabTestDatabaseUrlOrUndefined;
const logger = pino({ enabled: false });
/** A small non-empty Yjs doc to snapshot. */
function makeDoc(text: string): Y.Doc {
const doc = new Y.Doc();
doc.getText('t').insert(0, text);
return doc;
}
describe.skipIf(!url)('PostgresVersionStore (DB-backed, issue #41)', () => {
let pool: Pool;
const userId = randomUUID();
const authorA = randomUUID();
const authorB = randomUUID();
const pondId = randomUUID();
let pageId: string;
beforeAll(async () => {
pool = new Pool({ connectionString: url });
await pool.query(
'INSERT INTO users (id, username, email, display_name) VALUES ($1, $2, $3, $4)',
[userId, `vs-${userId.slice(0, 8)}`, `${userId}@example.test`, 'Version Tester'],
);
await pool.query(
`INSERT INTO ponds (id, slug, name, type, owner_id, updated_at)
VALUES ($1, $2, 'Version Pond', 'PERSONAL', $3, now())`,
[pondId, `vs-pond-${pondId.slice(0, 8)}`, userId],
);
});
beforeEach(async () => {
// Fresh page per test so version/contributor state never leaks between them.
pageId = 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())`,
[
pageId,
pondId,
`p-${pageId.slice(0, 8)}`,
Buffer.from(Y.encodeStateAsUpdate(new Y.Doc())),
userId,
],
);
});
afterAll(async () => {
// Pages (one per test) cascade to versions + pending contributors.
await pool.query('DELETE FROM pages WHERE pond_id = $1', [pondId]);
await pool.query('DELETE FROM ponds WHERE id = $1', [pondId]);
await pool.query('DELETE FROM users WHERE id = $1', [userId]);
await pool.end();
});
async function versionsOf(
id: string,
): Promise<{ trigger: string; contributor_ids: string[]; label: string | null }[]> {
const res = await pool.query<{
trigger: string;
contributor_ids: string[];
label: string | null;
}>(
'SELECT trigger, contributor_ids, label FROM page_versions WHERE page_id = $1 ORDER BY created_at',
[id],
);
return res.rows;
}
it('creates exactly one auto version on session end with the full contributor set', async () => {
const store = new PostgresVersionStore({ pool, logger });
store.recordContributor(pageId, authorA);
store.recordContributor(pageId, authorB);
const doc = makeDoc('two authors edited');
await store.onSessionEnd(pageId, doc);
const versions = await versionsOf(pageId);
expect(versions).toHaveLength(1);
expect(versions[0]!.trigger).toBe('AUTO');
expect([...versions[0]!.contributor_ids].sort()).toEqual([authorA, authorB].sort());
doc.destroy();
});
it('creates no version when nothing changed (empty contributor set)', async () => {
const store = new PostgresVersionStore({ pool, logger });
await store.onSessionEnd(pageId, makeDoc('untouched'));
expect(await versionsOf(pageId)).toHaveLength(0);
});
it('does not create a duplicate version on a quick reconnect with no edits', async () => {
const store = new PostgresVersionStore({ pool, logger });
store.recordContributor(pageId, authorA);
await store.onSessionEnd(pageId, makeDoc('edited once'));
expect(await versionsOf(pageId)).toHaveLength(1);
// Reconnect + disconnect without any edit: pending is empty, so no version.
store.noteOpened(pageId);
await store.onSessionEnd(pageId, makeDoc('edited once'));
expect(await versionsOf(pageId)).toHaveLength(1);
});
it('creates an interval snapshot from onStore when the interval has elapsed', async () => {
// intervalMs 0 => any store after an edit is due for an interval snapshot.
const store = new PostgresVersionStore({ pool, logger, intervalMs: 0 });
store.noteOpened(pageId);
store.recordContributor(pageId, authorA);
await store.onStore(pageId, makeDoc('interval edit'));
const versions = await versionsOf(pageId);
expect(versions).toHaveLength(1);
expect(versions[0]!.trigger).toBe('AUTO');
expect(versions[0]!.contributor_ids).toEqual([authorA]);
});
});