diff --git a/apps/api/prisma/migrations/20260709062007_page_versions/migration.sql b/apps/api/prisma/migrations/20260709062007_page_versions/migration.sql new file mode 100644 index 0000000..0031ad5 --- /dev/null +++ b/apps/api/prisma/migrations/20260709062007_page_versions/migration.sql @@ -0,0 +1,32 @@ +-- CreateEnum +CREATE TYPE "PageVersionTrigger" AS ENUM ('AUTO', 'MANUAL', 'PRE_RESTORE'); + +-- CreateTable +CREATE TABLE "page_versions" ( + "id" TEXT NOT NULL, + "page_id" TEXT NOT NULL, + "ydoc_snapshot" BYTEA NOT NULL, + "trigger" "PageVersionTrigger" NOT NULL, + "label" TEXT, + "contributor_ids" TEXT[], + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "page_versions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "page_pending_contributors" ( + "page_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + + CONSTRAINT "page_pending_contributors_pkey" PRIMARY KEY ("page_id","user_id") +); + +-- CreateIndex +CREATE INDEX "page_versions_page_id_created_at_idx" ON "page_versions"("page_id", "created_at"); + +-- AddForeignKey +ALTER TABLE "page_versions" ADD CONSTRAINT "page_versions_page_id_fkey" FOREIGN KEY ("page_id") REFERENCES "pages"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "page_pending_contributors" ADD CONSTRAINT "page_pending_contributors_page_id_fkey" FOREIGN KEY ("page_id") REFERENCES "pages"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index d0c8ac5..f4a46d3 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -101,11 +101,13 @@ model Page { deletedAt DateTime? @map("deleted_at") deletedBy String? @map("deleted_by") - pond Pond @relation(fields: [pondId], references: [id]) - creator User @relation(fields: [createdBy], references: [id]) - updates PageUpdate[] - contentCache PageContentCache? - attachments Attachment[] + pond Pond @relation(fields: [pondId], references: [id]) + creator User @relation(fields: [createdBy], references: [id]) + updates PageUpdate[] + contentCache PageContentCache? + attachments Attachment[] + versions PageVersion[] + pendingContributors PagePendingContributor[] @@unique([pondId, slug]) @@index([pondId]) @@ -127,6 +129,48 @@ model PageUpdate { @@map("page_updates") } +enum PageVersionTrigger { + AUTO + MANUAL + PRE_RESTORE +} + +/// Version snapshot of a page (ADR 0013, data-model.md). `ydocSnapshot` is a +/// full, self-contained encoded Yjs state — restore never depends on the +/// update log, so compaction (#40) cannot lose restorable history. +/// `contributorIds` is the set of users who edited since the previous version +/// (derived from the live session, #41). Created automatically at session end +/// and on an active-editing interval by collab, and on demand (named) by the +/// api; `PRE_RESTORE` snapshots are written before a restore (#42). +model PageVersion { + id String @id @default(uuid()) + pageId String @map("page_id") + ydocSnapshot Bytes @map("ydoc_snapshot") + trigger PageVersionTrigger + label String? + contributorIds String[] @map("contributor_ids") + createdAt DateTime @default(now()) @map("created_at") + + page Page @relation(fields: [pageId], references: [id], onDelete: Cascade) + + @@index([pageId, createdAt]) + @@map("page_versions") +} + +/// Accumulator of users who have edited a page since its last version (#41). +/// Collab flushes the current session's contributors here (deduplicated by the +/// composite key); version creation on either side reads and clears it in the +/// same transaction as writing the snapshot. Cascades on page purge (ADR 0013). +model PagePendingContributor { + pageId String @map("page_id") + userId String @map("user_id") + + page Page @relation(fields: [pageId], references: [id], onDelete: Cascade) + + @@id([pageId, userId]) + @@map("page_pending_contributors") +} + /// Live-session registry the collab server keeps current: one row per page /// with an open collaboration session, refreshed by a heartbeat (issue #40). /// The compaction job reads it to skip pages that are being edited; a stale diff --git a/apps/api/src/compaction/compaction.service.db.test.ts b/apps/api/src/compaction/compaction.service.db.test.ts index fe234f8..611c4e4 100644 --- a/apps/api/src/compaction/compaction.service.db.test.ts +++ b/apps/api/src/compaction/compaction.service.db.test.ts @@ -85,21 +85,24 @@ describe.skipIf(!hasTestDb)('CompactionService (db, issue #40)', () => { pondId, title: 'Test', slug: `p-${id.slice(0, 8)}`, - ydocState: Buffer.from(Y.encodeStateAsUpdate(new Y.Doc())), + // A fresh Uint8Array copy, not a Buffer: Prisma's Bytes input type is + // Uint8Array, which a Buffer (ArrayBufferLike) does not + // satisfy under strict types (handoff gotcha). + ydocState: new Uint8Array(Y.encodeStateAsUpdate(new Y.Doc())), sortKey: 'a0', createdBy: userId, }, }); const master = new Y.Doc(); let vector = Y.encodeStateVector(master); - const rows: { id: string; pageId: string; seq: number; update: Buffer }[] = []; + const rows: { id: string; pageId: string; seq: number; update: Uint8Array }[] = []; for (let i = 0; i < logRows; i += 1) { master.getText('log').insert(i, 'x'); rows.push({ id: randomUUID(), pageId: id, seq: i, - update: Buffer.from(Y.encodeStateAsUpdate(master, vector)), + update: new Uint8Array(Y.encodeStateAsUpdate(master, vector)), }); vector = Y.encodeStateVector(master); }