dorfteich/apps/api/src/versions/versions.service.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

171 lines
5.8 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import { INestApplication, NotFoundException } from '@nestjs/common';
import { PrismaClient, User } from '@prisma/client';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { createTestApp } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { VERSION_RETENTION_DAYS, VersionsService } from './versions.service';
describe.skipIf(!hasTestDb)('VersionsService (db, issue #41)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let versions: VersionsService;
const suffix = uniqueSuffix();
let owner: User;
let outsider: User;
let pondId: string;
const pageIds: string[] = [];
async function createPage(): Promise<string> {
const id = randomUUID();
await prisma.page.create({
data: {
id,
pondId,
title: 'Versioned',
slug: `p-${id.slice(0, 8)}`,
ydocState: new Uint8Array(Y.encodeStateAsUpdate(new Y.Doc())),
sortKey: 'a0',
createdBy: owner.id,
},
});
pageIds.push(id);
return id;
}
beforeAll(async () => {
prisma = createTestPrisma();
app = await createTestApp();
versions = app.get(VersionsService);
owner = await prisma.user.create({
data: {
username: `ver-owner-${suffix}`,
email: `ver-owner-${suffix}@example.test`,
displayName: 'Version Owner',
},
});
outsider = await prisma.user.create({
data: {
username: `ver-out-${suffix}`,
email: `ver-out-${suffix}@example.test`,
displayName: 'Version Outsider',
},
});
const pond = await prisma.pond.create({
data: {
slug: `ver-pond-${suffix}`,
name: 'Version Pond',
type: 'PERSONAL',
ownerId: owner.id,
},
});
pondId = pond.id;
});
afterAll(async () => {
if (pageIds.length > 0) await prisma.page.deleteMany({ where: { id: { in: pageIds } } });
await prisma.pond.deleteMany({ where: { id: pondId } });
await prisma.user.deleteMany({ where: { id: { in: [owner.id, outsider.id] } } });
await prisma.$disconnect();
await app.close();
});
it('creates a named version storing label and creator', async () => {
const pageId = await createPage();
const view = await versions.createNamed(owner, pageId, { label: 'before restructuring' });
expect(view).toMatchObject({
trigger: 'manual',
label: 'before restructuring',
createdBy: owner.id,
});
const row = await prisma.pageVersion.findUniqueOrThrow({ where: { id: view.id } });
expect(row.label).toBe('before restructuring');
expect(row.createdBy).toBe(owner.id);
expect(row.trigger).toBe('MANUAL');
expect(row.ydocSnapshot.byteLength).toBeGreaterThan(0);
});
it('captures and clears the pending contributor set', async () => {
const pageId = await createPage();
const authorA = randomUUID();
const authorB = randomUUID();
await prisma.pagePendingContributor.createMany({
data: [
{ pageId, userId: authorA },
{ pageId, userId: authorB },
],
});
const view = await versions.createNamed(owner, pageId, { label: 'snapshot' });
expect([...view.contributorIds].sort()).toEqual([authorA, authorB].sort());
// The accumulator is consumed so the next version does not re-attribute them.
expect(await prisma.pagePendingContributor.count({ where: { pageId } })).toBe(0);
});
it('refuses a named version for a user without write access', async () => {
const pageId = await createPage();
await expect(versions.createNamed(outsider, pageId, { label: 'nope' })).rejects.toBeInstanceOf(
NotFoundException,
);
expect(await prisma.pageVersion.count({ where: { pageId } })).toBe(0);
});
it('thins auto versions beyond the window to the newest per day, keeping the rest', async () => {
const pageId = await createPage();
const daysAgo = (days: number, hour: number): Date => {
const d = new Date();
d.setDate(d.getDate() - days);
d.setHours(hour, 0, 0, 0);
return d;
};
const snapshot = new Uint8Array(Y.encodeStateAsUpdate(new Y.Doc()));
const auto = (createdAt: Date) => ({
pageId,
ydocSnapshot: snapshot,
trigger: 'AUTO' as const,
contributorIds: [],
createdAt,
});
// Two auto versions on one day beyond the window (older + newer), one auto
// on another day beyond the window, one manual beyond the window, and one
// auto inside the window.
const oldDayOlder = daysAgo(120, 8);
const oldDayNewer = daysAgo(120, 20);
const otherOldDay = daysAgo(200, 12);
await prisma.pageVersion.createMany({
data: [
auto(oldDayOlder),
auto(oldDayNewer),
auto(otherOldDay),
{ ...auto(daysAgo(150, 10)), trigger: 'MANUAL', label: 'keep me', createdBy: owner.id },
auto(daysAgo(3, 10)), // within the window
],
});
const removed = await versions.thinDueVersions();
expect(removed).toBeGreaterThanOrEqual(1);
const remaining = await prisma.pageVersion.findMany({
where: { pageId },
orderBy: { createdAt: 'asc' },
select: { trigger: true, createdAt: true, label: true },
});
const times = remaining.map((v) => v.createdAt.getTime());
// The older of the two same-day beyond-window auto versions is gone…
expect(times).not.toContain(oldDayOlder.getTime());
// …its newer same-day sibling survives, as does the other-day one.
expect(times).toContain(oldDayNewer.getTime());
expect(times).toContain(otherOldDay.getTime());
// Manual and within-window auto are always kept.
expect(remaining.some((v) => v.label === 'keep me')).toBe(true);
expect(remaining.length).toBe(4);
expect(VERSION_RETENTION_DAYS).toBe(90);
});
});