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
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
127 lines
4.5 KiB
TypeScript
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]);
|
|
});
|
|
});
|