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]); }); });