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
171 lines
5.8 KiB
TypeScript
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);
|
|
});
|
|
});
|