import { Injectable, NotFoundException } from '@nestjs/common'; import { CreateVersionInput, PAGE_RESTORE_CHANNEL, PageRestoreRequest, PageVersionContentView, PageVersionTrigger, PageVersionView, } from '@dorfteich/shared'; import { Page, PageVersion, PageVersionTrigger as PrismaTrigger, User } from '@prisma/client'; import { PinoLogger } from 'nestjs-pino'; import * as Y from 'yjs'; import { deriveContent } from '../pages/yjs-content'; import { PrismaService } from '../prisma/prisma.service'; /** * Auto-versions older than this are thinned to one snapshot per day; everything * within the window is kept in full (ADR 0013, default 90 days). Manual and * pre-restore versions are intentional and never thinned. */ export const VERSION_RETENTION_DAYS = 90; const TRIGGER_TO_VIEW: Record = { AUTO: 'auto', MANUAL: 'manual', PRE_RESTORE: 'pre_restore', }; /** * Page version history (issue #41, ADR 0013). Named versions are created here, * permission-gated; automatic versions are created by the collab server. Both * consume the shared `page_pending_contributors` accumulator so a version's * contributor set reflects who edited since the previous version. The daily * thinning job keeps history bounded. */ @Injectable() export class VersionsService { constructor( private readonly prisma: PrismaService, private readonly logger: PinoLogger, ) { this.logger.setContext(VersionsService.name); } viewOf(version: Omit): PageVersionView { return { id: version.id, pageId: version.pageId, trigger: TRIGGER_TO_VIEW[version.trigger], label: version.label, createdBy: version.createdBy, contributorIds: version.contributorIds, createdAt: version.createdAt.toISOString(), }; } /** * Load a live page. Viewing history requires the same permission as * editing (ADR 0013) — the guard enforces write access on every history * route (#52); here only existence is checked. */ private async findLivePage(pageId: string): Promise { const page = await this.prisma.page.findFirst({ where: { id: pageId, deletedAt: null } }); if (!page) throw new NotFoundException(); return page; } /** The page's versions, newest first (no snapshot bytes). Write access only. */ async list(_user: User, pageId: string): Promise { await this.findLivePage(pageId); const versions = await this.prisma.pageVersion.findMany({ where: { pageId }, orderBy: { createdAt: 'desc' }, // Exclude the (potentially large) snapshot bytes from the list. omit: { ydocSnapshot: true }, }); return versions.map((version) => this.viewOf(version)); } /** A single version rendered read-only (HTML) with its Markdown for diffing. */ async getContent( _user: User, pageId: string, versionId: string, ): Promise { await this.findLivePage(pageId); const version = await this.prisma.pageVersion.findFirst({ where: { id: versionId, pageId }, }); if (!version) throw new NotFoundException(); const derived = deriveContent(new Uint8Array(version.ydocSnapshot)); return { ...this.viewOf(version), html: derived.html, markdown: derived.markdown }; } /** * Restore the page to `versionId` (requires write access, ADR 0013). The * permission check happens here; the collab server, which owns the live * document, does the actual work: it snapshots the current state as a * `PRE_RESTORE` version and applies the restored content as a normal edit so * every open client converges. History is append-only — nothing is deleted. * Returns the version being restored. */ async restore(user: User, pageId: string, versionId: string): Promise { await this.findLivePage(pageId); const version = await this.prisma.pageVersion.findFirst({ where: { id: versionId, pageId }, omit: { ydocSnapshot: true }, }); if (!version) throw new NotFoundException(); const payload: PageRestoreRequest = { pageId, versionId, userId: user.id }; await this.prisma .$executeRaw`SELECT pg_notify(${PAGE_RESTORE_CHANNEL}, ${JSON.stringify(payload)})`; this.logger.info( { event: 'audit: version restore requested', pageId, versionId, userId: user.id }, 'version restore requested', ); return this.viewOf(version); } /** * Create a named version (requires write access, ADR 0013 / permissions.md). * The snapshot is the page's current persisted state (base state plus the * update log); it can lag the very latest live keystrokes by the collab * store debounce, a deliberate simplification for a manual "save this state". */ async createNamed( user: User, pageId: string, input: CreateVersionInput, ): Promise { await this.findLivePage(pageId); const snapshot = await this.reconstructSnapshot(pageId); const created = await this.prisma.$transaction(async (tx) => { // Consume the contributors accumulated since the previous version. const pending = await tx.pagePendingContributor.findMany({ where: { pageId } }); const contributorIds = pending.map((row) => row.userId); await tx.pagePendingContributor.deleteMany({ where: { pageId } }); return tx.pageVersion.create({ data: { pageId, ydocSnapshot: snapshot, trigger: 'MANUAL', label: input.label, createdBy: user.id, contributorIds, }, }); }); this.logger.info( { event: 'audit: version created', pageId, versionId: created.id, userId: user.id }, 'named version created', ); return this.viewOf(created); } /** Reconstruct the page's full current Yjs state (base + update log). */ private async reconstructSnapshot(pageId: string): Promise> { const page = await this.prisma.page.findUniqueOrThrow({ where: { id: pageId }, select: { ydocState: true }, }); const updates = await this.prisma.pageUpdate.findMany({ where: { pageId }, orderBy: { seq: 'asc' }, select: { update: true }, }); const doc = new Y.Doc(); try { Y.applyUpdate(doc, new Uint8Array(page.ydocState)); for (const row of updates) Y.applyUpdate(doc, new Uint8Array(row.update)); // A fresh copy: Prisma's Bytes input type is Uint8Array, // which the ArrayBufferLike-typed encode result does not satisfy. return new Uint8Array(Y.encodeStateAsUpdate(doc)); } finally { doc.destroy(); } } /** * Thin automatic versions older than the retention window down to the newest * one per day, keeping every manual/pre-restore version and everything within * the window (ADR 0013). Returns the number of versions removed. */ async thinDueVersions(): Promise { const result = await this.prisma.$executeRaw` DELETE FROM page_versions v WHERE v.trigger = 'AUTO' AND v.created_at < now() - make_interval(days => ${VERSION_RETENTION_DAYS}::int) AND EXISTS ( SELECT 1 FROM page_versions newer WHERE newer.page_id = v.page_id AND newer.trigger = 'AUTO' AND newer.created_at < now() - make_interval(days => ${VERSION_RETENTION_DAYS}::int) AND date_trunc('day', newer.created_at) = date_trunc('day', v.created_at) AND newer.created_at > v.created_at )`; if (result > 0) { this.logger.info({ event: 'version.thinning.run', removed: result }, 'thinned old versions'); } return result; } }