From 0ab1fe29b3445e6973e087ea1e02c47862ebe4a4 Mon Sep 17 00:00:00 2001 From: mshddev Date: Fri, 4 Sep 2026 14:10:29 +0700 Subject: [PATCH 1/4] feat!: replace workspaces with nested collections Documents are now top-level with a globally unique slug, served at /docs/ in the browser and /api/docs/ in the API. Collections are optional folders that nest through a parent; a document lives at the root or in exactly one collection. Schema 10 migrates existing data: documents from the "default" workspace move to the root, every other workspace becomes a root collection, and the migration refuses to run if a slug exists in more than one workspace. The skill scripts drop --workspace; publish-document.mjs gains --collection and --root. Reinstall the skill after upgrading. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 7 + README.md | 11 +- apps/server/src/app.test.ts | 468 ++++-- apps/server/src/app.ts | 376 ++--- apps/server/src/feedback-waiters.test.ts | 18 +- apps/server/src/feedback-waiters.ts | 7 +- apps/server/src/storage/pena-store.ts | 178 +-- .../src/storage/sqlite-pena-store.test.ts | 1424 ++++++++++------- apps/server/src/storage/sqlite-pena-store.ts | 817 ++++++---- apps/server/src/watch-feedback-script.test.ts | 4 - apps/web/src/App.tsx | 21 +- apps/web/src/api.ts | 175 +- apps/web/src/collections.ts | 128 ++ apps/web/src/components/UtilityBar.tsx | 26 +- .../src/features/archive/ArchivePage.test.tsx | 139 +- apps/web/src/features/archive/ArchivePage.tsx | 209 ++- .../collections/CollectionsPage.test.tsx | 250 +++ .../features/collections/CollectionsPage.tsx | 813 ++++++++++ .../DocumentReviewPage.test.tsx | 465 ++++-- .../document-review/DocumentReviewPage.tsx | 148 +- .../components/DocumentViewer.test.tsx | 2 +- .../components/PenaLayout.test.tsx | 22 +- .../document-review/components/PenaLayout.tsx | 18 +- .../components/VersionHistory.tsx | 15 +- .../features/document-review/routing.test.ts | 58 +- .../src/features/document-review/routing.ts | 67 +- apps/web/src/features/home/CollectionHome.tsx | 535 +++++++ apps/web/src/features/home/HomePage.test.tsx | 223 +-- apps/web/src/features/home/HomePage.tsx | 85 +- apps/web/src/features/home/WorkspaceHome.tsx | 470 ------ .../workspaces/WorkspacesPage.test.tsx | 187 --- .../features/workspaces/WorkspacesPage.tsx | 589 ------- apps/web/src/format.ts | 2 +- apps/web/src/styles.css | 402 +++-- packages/contracts/src/index.ts | 70 +- resources/skills/pena/SKILL.md | 117 +- .../skills/pena/scripts/publish-document.mjs | 36 +- .../skills/pena/scripts/watch-feedback.mjs | 10 +- 38 files changed, 5088 insertions(+), 3504 deletions(-) create mode 100644 apps/web/src/collections.ts create mode 100644 apps/web/src/features/collections/CollectionsPage.test.tsx create mode 100644 apps/web/src/features/collections/CollectionsPage.tsx create mode 100644 apps/web/src/features/home/CollectionHome.tsx delete mode 100644 apps/web/src/features/home/WorkspaceHome.tsx delete mode 100644 apps/web/src/features/workspaces/WorkspacesPage.test.tsx delete mode 100644 apps/web/src/features/workspaces/WorkspacesPage.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c5c012..40b1ff0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **Breaking:** workspaces are replaced by collections, optional folders that nest. A document lives at the root or in one collection, and its slug is global +- **Breaking:** document URLs move to `/docs/` in the browser and `/api/docs/` in the API; collections live at `/collections` and `/api/collections` +- **Breaking:** the skill scripts drop `--workspace`; `publish-document.mjs` gains `--collection ` and `--root`, and the publish body accepts an optional `collectionSlug` +- The database migrates to schema 10: documents from the `default` workspace move to the root, every other workspace becomes a root collection, and the migration refuses to run if a document slug exists in more than one workspace + ## [0.0.2] - 2026-08-02 ### Added diff --git a/README.md b/README.md index f6f1b06..99cc04b 100644 --- a/README.md +++ b/README.md @@ -51,13 +51,17 @@ mkdir -p ~/.claude/skills/pena cp -R resources/skills/pena/. ~/.claude/skills/pena/ ``` -verify: in a new Claude Code session, ask it to *"publish this plan to Pena"* — it should respond with a `http://127.0.0.1:5173/workspaces/...` URL. +verify: in a new Claude Code session, ask it to *"publish this plan to Pena"* — it should respond with a `http://127.0.0.1:5173/docs/...` URL. + +If you upgraded Pena from a version that had workspaces, reinstall the +skill with the same commands: its script flags and URLs changed. # How To Use 1. Ask Claude Code to publish a document to Pena. It uploads referenced local PNG, JPEG, WebP, and GIF images, chooses an explicit title, then publishes - the title and staged Markdown under a workspace and stable document slug. + the title and staged Markdown under a stable document slug, at the root + or inside a collection you name. 2. Open the URL it gives you, select any text, and leave comments. Documents can also carry interactive decision blocks — single-choice questions you answer inline. 3. Submit the feedback. The active Claude Code session picks it up automatically, applies the comments, and republishes to the same slug. @@ -67,7 +71,8 @@ The monitor stops when that Claude Code session ends. When the Monitor tool is not available, Pena keeps the feedback and you can still ask Claude to fetch it manually. -Documents live in workspaces. Each immutable version contains its explicit +Documents live at the root or inside collections, which nest like folders. +Each immutable version contains its explicit title and Markdown content, with feedback attached to that exact version. Changing only the title still creates a version. The document view separates operational metadata from the reviewed body and renders the explicit title once diff --git a/apps/server/src/app.test.ts b/apps/server/src/app.test.ts index 3238876..6548514 100644 --- a/apps/server/src/app.test.ts +++ b/apps/server/src/app.test.ts @@ -15,7 +15,7 @@ import { } from "./storage/pena-store.js"; import { SqlitePenaStore } from "./storage/sqlite-pena-store.js"; -const DOCUMENT_URL = "/api/workspaces/default/documents/initial-spec"; +const DOCUMENT_URL = "/api/docs/initial-spec"; const FEEDBACK_URL = `${DOCUMENT_URL}/feedback`; const FEEDBACK_WAIT_URL = `${FEEDBACK_URL}/wait`; const feedbackPayload = { @@ -249,118 +249,305 @@ describe("Pena API", () => { ).toBe(404); }); - it("requires workspace scope for every document route", async () => { + it("no longer serves workspace routes and rejects invalid slugs", async () => { const app = createApp(); - const response = await app.inject({ - method: "GET", - url: "/api/documents/initial-spec", - }); - expect(response.statusCode).toBe(404); + expect( + ( + await app.inject({ + method: "GET", + url: "/api/workspaces/default/documents/initial-spec", + }) + ).statusCode, + ).toBe(404); + expect( + (await app.inject({ method: "GET", url: "/api/workspaces" })).statusCode, + ).toBe(404); + expect( + (await app.inject({ method: "GET", url: "/api/docs/Invalid_Slug" })) + .statusCode, + ).toBe(400); + expect( + ( + await app.inject({ + method: "PATCH", + url: "/api/collections/Invalid_Slug", + payload: { name: "Renamed" }, + }) + ).statusCode, + ).toBe(400); + expect( + (await app.inject({ method: "GET", url: "/api/docs?collection=Bad!" })) + .statusCode, + ).toBe(400); }); - it("creates, renames, lists, and deletes an empty workspace", async () => { + it("creates, lists, renames, reparents, and deletes collections", async () => { const app = createApp(); - const defaultList = await app.inject({ method: "GET", url: "/api/workspaces" }); - - expect(defaultList.json()).toEqual({ - workspaces: [ - expect.objectContaining({ - slug: "default", - name: "Default", - documentCount: 0, - }), - ], - }); + const emptyList = await app.inject({ method: "GET", url: "/api/collections" }); + expect(emptyList.json()).toEqual({ collections: [] }); const created = await app.inject({ method: "POST", - url: "/api/workspaces", + url: "/api/collections", payload: { name: "Product Notes" }, }); expect(created.statusCode).toBe(201); expect(created.json()).toMatchObject({ slug: "product-notes", name: "Product Notes", + parentSlug: null, + }); + + const child = await app.inject({ + method: "POST", + url: "/api/collections", + payload: { name: "Roadmaps", parentSlug: "product-notes" }, + }); + expect(child.statusCode).toBe(201); + expect(child.json()).toMatchObject({ + slug: "roadmaps", + parentSlug: "product-notes", + }); + + await publishDocument(app, "/api/docs/q3-roadmap", "Q3", "Q3 Roadmap"); + await app.inject({ + method: "POST", + url: "/api/docs/q3-roadmap/move", + headers: { "if-match": await documentEtag(app, "/api/docs/q3-roadmap") }, + payload: { collectionSlug: "roadmaps" }, + }); + + const listed = await app.inject({ method: "GET", url: "/api/collections" }); + expect(listed.json()).toEqual({ + collections: [ + expect.objectContaining({ + slug: "product-notes", + parentSlug: null, + documentCount: 0, + childCount: 1, + }), + expect.objectContaining({ + slug: "roadmaps", + parentSlug: "product-notes", + documentCount: 1, + childCount: 0, + }), + ], }); const renamed = await app.inject({ method: "PATCH", - url: "/api/workspaces/product-notes", + url: "/api/collections/product-notes", payload: { name: "Product Team" }, }); expect(renamed.statusCode).toBe(200); expect(renamed.json()).toMatchObject({ slug: "product-notes", name: "Product Team", + parentSlug: null, + }); + + const reparented = await app.inject({ + method: "PATCH", + url: "/api/collections/roadmaps", + payload: { parentSlug: null }, + }); + expect(reparented.statusCode).toBe(200); + expect(reparented.json()).toMatchObject({ + slug: "roadmaps", + parentSlug: null, }); const deleted = await app.inject({ method: "DELETE", - url: "/api/workspaces/product-notes", + url: "/api/collections/product-notes", }); expect(deleted.statusCode).toBe(204); + expect( + (await app.inject({ method: "GET", url: "/api/collections" })).json() + .collections, + ).toHaveLength(1); }); - it("protects default and blocks deletion of a non-empty workspace", async () => { + it("rejects invalid collection changes", async () => { const app = createApp(); - const defaultDelete = await app.inject({ + await app.inject({ + method: "POST", + url: "/api/collections", + payload: { name: "Parent" }, + }); + await app.inject({ + method: "POST", + url: "/api/collections", + payload: { name: "Child", parentSlug: "parent" }, + }); + + const cycle = await app.inject({ + method: "PATCH", + url: "/api/collections/parent", + payload: { parentSlug: "child" }, + }); + expect(cycle.statusCode).toBe(409); + expect(cycle.json().error).toContain("descendants"); + + const selfParent = await app.inject({ + method: "PATCH", + url: "/api/collections/parent", + payload: { parentSlug: "parent" }, + }); + expect(selfParent.statusCode).toBe(409); + + const nonEmptyParent = await app.inject({ method: "DELETE", - url: "/api/workspaces/default", + url: "/api/collections/parent", }); - expect(defaultDelete.statusCode).toBe(403); + expect(nonEmptyParent.statusCode).toBe(409); + await publishDocument(app, "/api/docs/child-doc"); await app.inject({ method: "POST", - url: "/api/workspaces", - payload: { name: "Research" }, + url: "/api/docs/child-doc/move", + headers: { "if-match": await documentEtag(app, "/api/docs/child-doc") }, + payload: { collectionSlug: "child" }, }); - await publishDocument( - app, - "/api/workspaces/research/documents/shared-spec", - ); - const nonEmptyDelete = await app.inject({ + const nonEmptyChild = await app.inject({ method: "DELETE", - url: "/api/workspaces/research", + url: "/api/collections/child", }); - expect(nonEmptyDelete.statusCode).toBe(409); + expect(nonEmptyChild.statusCode).toBe(409); + + const missingParent = await app.inject({ + method: "POST", + url: "/api/collections", + payload: { name: "Orphan", parentSlug: "nowhere" }, + }); + expect(missingParent.statusCode).toBe(404); + + const missingCollection = await app.inject({ + method: "PATCH", + url: "/api/collections/nowhere", + payload: { name: "Anything" }, + }); + expect(missingCollection.statusCode).toBe(404); + + const emptyUpdate = await app.inject({ + method: "PATCH", + url: "/api/collections/parent", + payload: {}, + }); + expect(emptyUpdate.statusCode).toBe(400); + + const invalidName = await app.inject({ + method: "POST", + url: "/api/collections", + payload: { name: "!!!" }, + }); + expect(invalidName.statusCode).toBe(400); + + const blankName = await app.inject({ + method: "POST", + url: "/api/collections", + payload: { name: " " }, + }); + expect(blankName.statusCode).toBe(400); + + const duplicateName = await app.inject({ + method: "POST", + url: "/api/collections", + payload: { name: "parent" }, + }); + expect(duplicateName.statusCode).toBe(409); }); - it("scopes identical document slugs and feedback by workspace", async () => { + it("keeps document slugs global and files a new document by collection", async () => { const app = createApp(); await app.inject({ method: "POST", - url: "/api/workspaces", + url: "/api/collections", payload: { name: "Research" }, }); - await publishDocument(app, DOCUMENT_URL, "Default copy"); - const researchUrl = "/api/workspaces/research/documents/initial-spec"; - await publishDocument(app, researchUrl, "Research copy"); - await app.inject({ - method: "POST", - url: `${researchUrl}/feedback`, - headers: { "if-match": await documentEtag(app, researchUrl) }, - payload: feedbackPayload, + await publishDocument(app, DOCUMENT_URL, "Root copy"); + + const duplicate = await app.inject({ + method: "PUT", + url: DOCUMENT_URL, + headers: { + "content-type": "application/json", + "if-none-match": "*", + }, + payload: { + title: "Initial Specification", + content: "Second copy", + collectionSlug: "research", + }, }); + expect(duplicate.statusCode).toBe(412); - expect((await app.inject({ method: "GET", url: DOCUMENT_URL })).json()) - .toMatchObject({ workspaceSlug: "default", content: "Default copy" }); - expect((await app.inject({ method: "GET", url: researchUrl })).json()) - .toMatchObject({ workspaceSlug: "research", content: "Research copy" }); + const filed = await app.inject({ + method: "PUT", + url: "/api/docs/research-notes", + headers: { + "content-type": "application/json", + "if-none-match": "*", + }, + payload: { + title: "Research Notes", + content: "Filed on create", + collectionSlug: "research", + }, + }); + expect(filed.statusCode).toBe(201); + expect(filed.json()).toMatchObject({ + slug: "research-notes", + collectionSlug: "research", + }); expect( - (await app.inject({ method: "GET", url: FEEDBACK_URL })).json().batches, - ).toEqual([]); + (await app.inject({ method: "GET", url: "/api/docs/research-notes" })) + .json(), + ).toMatchObject({ collectionSlug: "research", content: "Filed on create" }); expect( - (await app.inject({ method: "GET", url: `${researchUrl}/feedback` })) - .json().batches, - ).toHaveLength(1); + (await app.inject({ method: "GET", url: DOCUMENT_URL })).json(), + ).toMatchObject({ collectionSlug: null, content: "Root copy" }); + + const inCollection = await app.inject({ + method: "GET", + url: "/api/docs?collection=research", + }); + expect(inCollection.json().documents).toEqual([ + expect.objectContaining({ slug: "research-notes" }), + ]); + const atRoot = await app.inject({ + method: "GET", + url: "/api/docs?collection=root", + }); + expect(atRoot.json().documents).toEqual([ + expect.objectContaining({ slug: "initial-spec" }), + ]); + const everything = await app.inject({ method: "GET", url: "/api/docs" }); + expect(everything.json().documents).toHaveLength(2); + + const missingCollection = await app.inject({ + method: "PUT", + url: "/api/docs/lost-notes", + headers: { + "content-type": "application/json", + "if-none-match": "*", + }, + payload: { + title: "Lost Notes", + content: "Nowhere", + collectionSlug: "nowhere", + }, + }); + expect(missingCollection.statusCode).toBe(404); }); - it("moves an active document to another workspace", async () => { + it("moves an active document into a collection and back to the root", async () => { const app = createApp(); await app.inject({ method: "POST", - url: "/api/workspaces", + url: "/api/collections", payload: { name: "Research" }, }); await publishDocument(app); @@ -370,55 +557,81 @@ describe("Pena API", () => { headers: { "if-match": await documentEtag(app) }, payload: feedbackPayload, }); + const beforeMove = await documentEtag(app); const response = await app.inject({ method: "POST", url: `${DOCUMENT_URL}/move`, - headers: { "if-match": await documentEtag(app) }, - payload: { workspaceSlug: "research" }, + headers: { "if-match": beforeMove }, + payload: { collectionSlug: "research" }, }); expect(response.statusCode).toBe(200); - expect(response.headers.location).toBe( - "/api/workspaces/research/documents/initial-spec", - ); expect(response.json()).toMatchObject({ - workspaceSlug: "research", + collectionSlug: "research", slug: "initial-spec", }); - expect((await app.inject({ method: "GET", url: DOCUMENT_URL })).statusCode) - .toBe(404); + expect(requiredEtag(response)).not.toBe(beforeMove); + expect((await app.inject({ method: "GET", url: DOCUMENT_URL })).json()) + .toMatchObject({ collectionSlug: "research" }); expect( - ( - await app.inject({ - method: "GET", - url: "/api/workspaces/research/documents/initial-spec/feedback", - }) - ).json().batches, + (await app.inject({ method: "GET", url: FEEDBACK_URL })).json().batches, ).toHaveLength(1); + + const stale = await app.inject({ + method: "POST", + url: `${DOCUMENT_URL}/move`, + headers: { "if-match": beforeMove }, + payload: { collectionSlug: null }, + }); + expect(stale.statusCode).toBe(412); + + const backToRoot = await app.inject({ + method: "POST", + url: `${DOCUMENT_URL}/move`, + headers: { "if-match": await documentEtag(app) }, + payload: { collectionSlug: null }, + }); + expect(backToRoot.statusCode).toBe(200); + expect(backToRoot.json()).toMatchObject({ collectionSlug: null }); + expect( + (await app.inject({ method: "GET", url: "/api/docs?collection=root" })) + .json().documents, + ).toEqual([expect.objectContaining({ slug: "initial-spec" })]); }); - it("blocks moving archived documents and destination slug collisions", async () => { + it("blocks moving archived documents and rejects bad destinations", async () => { const app = createApp(); await app.inject({ method: "POST", - url: "/api/workspaces", + url: "/api/collections", payload: { name: "Research" }, }); await publishDocument(app); - await publishDocument( - app, - "/api/workspaces/research/documents/initial-spec", - "Research copy", - ); - const collision = await app.inject({ + const unknown = await app.inject({ + method: "POST", + url: `${DOCUMENT_URL}/move`, + headers: { "if-match": await documentEtag(app) }, + payload: { collectionSlug: "nowhere" }, + }); + expect(unknown.statusCode).toBe(404); + + const malformed = await app.inject({ + method: "POST", + url: `${DOCUMENT_URL}/move`, + headers: { "if-match": await documentEtag(app) }, + payload: { collectionSlug: "Bad Slug" }, + }); + expect(malformed.statusCode).toBe(400); + + const missingField = await app.inject({ method: "POST", url: `${DOCUMENT_URL}/move`, headers: { "if-match": await documentEtag(app) }, - payload: { workspaceSlug: "research" }, + payload: {}, }); - expect(collision.statusCode).toBe(409); + expect(missingField.statusCode).toBe(400); await app.inject({ method: "PATCH", @@ -430,7 +643,7 @@ describe("Pena API", () => { method: "POST", url: `${DOCUMENT_URL}/move`, headers: { "if-match": await documentEtag(app) }, - payload: { workspaceSlug: "research" }, + payload: { collectionSlug: "research" }, }); expect(archived.statusCode).toBe(409); expect(archived.json().error).toContain("Unarchive"); @@ -438,21 +651,10 @@ describe("Pena API", () => { it("lists published documents by most recent update", async () => { const app = createApp(); - await publishDocument( - app, - "/api/workspaces/default/documents/older-draft", - "## Older draft", - ); - await publishDocument( - app, - "/api/workspaces/default/documents/newer-draft", - "## Newer draft", - ); + await publishDocument(app, "/api/docs/older-draft", "## Older draft"); + await publishDocument(app, "/api/docs/newer-draft", "## Newer draft"); - const response = await app.inject({ - method: "GET", - url: "/api/workspaces/default/documents", - }); + const response = await app.inject({ method: "GET", url: "/api/docs" }); expect(response.statusCode).toBe(200); expect(response.json()).toEqual({ @@ -491,11 +693,11 @@ describe("Pena API", () => { const activeDocuments = await app.inject({ method: "GET", - url: "/api/workspaces/default/documents", + url: "/api/docs", }); const archivedDocuments = await app.inject({ method: "GET", - url: "/api/workspaces/default/documents?status=archived", + url: "/api/docs?status=archived", }); expect(activeDocuments.json()).toEqual({ documents: [] }); expect(archivedDocuments.json().documents).toHaveLength(1); @@ -529,21 +731,27 @@ describe("Pena API", () => { expect(documentResponse.statusCode).toBe(404); }); - it("lists the global archive and filters it by workspace", async () => { + it("lists the global archive and filters it by collection", async () => { const app = createApp(); await app.inject({ method: "POST", - url: "/api/workspaces", + url: "/api/collections", payload: { name: "Research" }, }); - const defaultUrl = "/api/workspaces/default/documents/shared-draft"; - const researchUrl = "/api/workspaces/research/documents/shared-draft"; - await publishDocument(app, defaultUrl, "Default copy"); + const rootUrl = "/api/docs/root-draft"; + const researchUrl = "/api/docs/research-draft"; + await publishDocument(app, rootUrl, "Root copy"); await publishDocument(app, researchUrl, "Research copy"); + await app.inject({ + method: "POST", + url: `${researchUrl}/move`, + headers: { "if-match": await documentEtag(app, researchUrl) }, + payload: { collectionSlug: "research" }, + }); await app.inject({ method: "PATCH", - url: defaultUrl, - headers: { "if-match": await documentEtag(app, defaultUrl) }, + url: rootUrl, + headers: { "if-match": await documentEtag(app, rootUrl) }, payload: { status: "archived" }, }); await app.inject({ @@ -556,25 +764,37 @@ describe("Pena API", () => { const allArchive = await app.inject({ method: "GET", url: "/api/archive" }); expect(allArchive.statusCode).toBe(200); expect(allArchive.json().documents).toEqual([ - expect.objectContaining({ workspaceSlug: "research", slug: "shared-draft" }), - expect.objectContaining({ workspaceSlug: "default", slug: "shared-draft" }), + expect.objectContaining({ + collectionSlug: "research", + slug: "research-draft", + }), + expect.objectContaining({ collectionSlug: null, slug: "root-draft" }), ]); const researchArchive = await app.inject({ method: "GET", - url: "/api/archive?workspace=research", + url: "/api/archive?collection=research", }); expect(researchArchive.statusCode).toBe(200); expect(researchArchive.json().documents).toEqual([ - expect.objectContaining({ workspaceSlug: "research", slug: "shared-draft" }), + expect.objectContaining({ + collectionSlug: "research", + slug: "research-draft", + }), ]); + + const missingArchive = await app.inject({ + method: "GET", + url: "/api/archive?collection=nowhere", + }); + expect(missingArchive.statusCode).toBe(404); }); it("rejects an invalid document list status", async () => { const app = createApp(); const response = await app.inject({ method: "GET", - url: "/api/workspaces/default/documents?status=deleted", + url: "/api/docs?status=deleted", }); expect(response.statusCode).toBe(400); @@ -593,8 +813,8 @@ describe("Pena API", () => { expect(publishResponse.statusCode).toBe(201); expect(requiredEtag(publishResponse)).toMatch(/^"pena-.+"$/); expect(publishResponse.json()).toEqual({ - workspaceSlug: "default", slug: "initial-spec", + collectionSlug: null, title: "Initial Specification", version: 1, updatedAt: expect.any(String), @@ -775,7 +995,6 @@ describe("Pena API", () => { expect(response.statusCode).toBe(200); expect(requiredEtag(response)).toBe(etag); expect(response.json()).toEqual({ - workspaceSlug: "default", documentSlug: "initial-spec", documentVersion: 1, latestBatchId: 2, @@ -825,7 +1044,7 @@ describe("Pena API", () => { it("isolates feedback waits by document", async () => { const app = createApp(); - const articleUrl = "/api/workspaces/default/documents/article-draft"; + const articleUrl = "/api/docs/article-draft"; const articleWaitUrl = `${articleUrl}/feedback/wait`; await publishDocument(app); await publishDocument(app, articleUrl, "Article draft"); @@ -1067,8 +1286,8 @@ describe("Pena API", () => { }); expect(updated.statusCode).toBe(200); expect(updated.json()).toEqual({ - workspaceSlug: "default", slug: "initial-spec", + collectionSlug: null, title: "Initial Specification", version: 2, updatedAt: expect.any(String), @@ -1142,7 +1361,7 @@ describe("Pena API", () => { it("isolates documents and feedback by slug", async () => { const app = createApp(); - const articleUrl = "/api/workspaces/default/documents/article-draft"; + const articleUrl = "/api/docs/article-draft"; const articleFeedbackUrl = `${articleUrl}/feedback`; await publishDocument(app); @@ -1221,7 +1440,7 @@ describe("Pena API", () => { const invalidSlugResponse = await publishDocument( app, - "/api/workspaces/default/documents/Invalid_Slug", + "/api/docs/Invalid_Slug", ); expect(invalidSlugResponse.statusCode).toBe(400); @@ -1289,16 +1508,16 @@ describe("Pena API", () => { it("returns HTTP 500 for invalid persisted feedback", async () => { const store: PenaStore = { - listWorkspaces() { + listCollections() { throw new Error("Not used in this test."); }, - createWorkspace() { + createCollection() { throw new Error("Not used in this test."); }, - renameWorkspace() { + updateCollection() { throw new Error("Not used in this test."); }, - deleteWorkspace() { + deleteCollection() { throw new Error("Not used in this test."); }, publishDocument() { @@ -1307,12 +1526,21 @@ describe("Pena API", () => { getDocument() { throw new Error("Not used in this test."); }, + listDocumentVersions() { + throw new Error("Not used in this test."); + }, + getDocumentVersion() { + throw new Error("Not used in this test."); + }, + restoreDocumentVersion() { + throw new Error("Not used in this test."); + }, getDocumentResource() { return { etag: '"pena-test"', value: { - workspaceSlug: "default", slug: "initial-spec", + collectionSlug: null, title: "Initial Specification", content: "Current draft", version: 1, diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index c036d3f..2f45b53 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -1,4 +1,7 @@ import { + CollectionCreateRequestSchema, + CollectionSlugSchema, + CollectionUpdateRequestSchema, DecisionBlockSyntaxError, DocumentMetadataSchema, DocumentMoveRequestSchema, @@ -9,9 +12,6 @@ import { FeedbackReceiptSchema, FeedbackSubmissionSchema, FeedbackWaitResponseSchema, - WorkspaceCreateRequestSchema, - WorkspaceSlugSchema, - WorkspaceUpdateRequestSchema, parseDecisionDocument, type FeedbackResponse, type FeedbackWaitResponse, @@ -24,20 +24,19 @@ import Fastify, { } from "fastify"; import { - DefaultWorkspaceProtectedError, + CollectionCycleError, + CollectionNameConflictError, + CollectionNameInvalidError, + CollectionNotEmptyError, + CollectionNotFoundError, + CollectionSlugConflictError, DocumentArchivedError, DocumentNotArchivedError, DocumentNotFoundError, DocumentPreconditionFailedError, - DocumentSlugConflictError, DocumentVersionNotFoundError, FeedbackPreconditionFailedError, PersistedDataError, - WorkspaceNameConflictError, - WorkspaceNameInvalidError, - WorkspaceNotEmptyError, - WorkspaceNotFoundError, - WorkspaceSlugConflictError, type DocumentWriteCondition, type PenaStore, } from "./storage/pena-store.js"; @@ -53,11 +52,11 @@ import { } from "./feedback-waiters.js"; import { extractLeadingDocumentTitle } from "./storage/document-preview.js"; -interface WorkspaceParams { - workspaceSlug: string; +interface CollectionParams { + collectionSlug: string; } -interface DocumentParams extends WorkspaceParams { +interface DocumentParams { documentSlug: string; } @@ -65,6 +64,11 @@ interface DocumentVersionParams extends DocumentParams { version: string; } +interface DocumentListQuery { + status?: string; + collection?: string; +} + interface FeedbackWaitQuery { after?: string; timeout?: string; @@ -77,6 +81,9 @@ interface AssetParams { const DEFAULT_FEEDBACK_WAIT_TIMEOUT_MS = 25_000; const MAX_FEEDBACK_WAIT_TIMEOUT_MS = 30_000; +/** Query value that selects documents at the root, outside every collection. */ +const ROOT_COLLECTION_QUERY = "root"; + export function buildApp( store: PenaStore, assetStore: AssetStore, @@ -175,132 +182,154 @@ export function buildApp( }, ); - app.get("/api/workspaces", async (_request, reply) => - reply.send({ workspaces: store.listWorkspaces() }), + app.get("/api/collections", async (_request, reply) => + reply.send({ collections: store.listCollections() }), ); - app.get<{ Querystring: { workspace?: string } }>( - "/api/archive", - async (request, reply) => { - const requestedWorkspace = request.query.workspace; - let workspaceSlug: string | undefined; - - if (requestedWorkspace) { - const parsedWorkspaceSlug = parseWorkspaceSlug( - requestedWorkspace, - reply, - ); - - if (!parsedWorkspaceSlug) { - return; - } - - workspaceSlug = parsedWorkspaceSlug; - } - - try { - return reply.send({ - documents: store.listArchivedDocuments(workspaceSlug), - }); - } catch (error) { - return sendDocumentError(reply, error); - } - }, - ); - - app.post("/api/workspaces", async (request, reply) => { - const parsedRequest = WorkspaceCreateRequestSchema.safeParse(request.body); + app.post("/api/collections", async (request, reply) => { + const parsedRequest = CollectionCreateRequestSchema.safeParse( + request.body, + ); if (!parsedRequest.success) { return reply.code(400).send({ - error: "The workspace name must be between 1 and 80 characters.", + error: + "The collection name must be between 1 and 80 characters, and the parent slug must be a valid slug or null.", }); } try { - return reply.code(201).send(store.createWorkspace(parsedRequest.data.name)); + return reply + .code(201) + .send( + store.createCollection( + parsedRequest.data.name, + parsedRequest.data.parentSlug ?? null, + ), + ); } catch (error) { - return sendWorkspaceMutationError(reply, error); + return sendCollectionMutationError(reply, error); } }); - app.patch<{ Params: WorkspaceParams }>( - "/api/workspaces/:workspaceSlug", + app.patch<{ Params: CollectionParams }>( + "/api/collections/:collectionSlug", async (request, reply) => { - const workspaceSlug = parseWorkspaceSlug( - request.params.workspaceSlug, + const collectionSlug = parseCollectionSlug( + request.params.collectionSlug, reply, ); - const parsedRequest = WorkspaceUpdateRequestSchema.safeParse(request.body); + const parsedRequest = CollectionUpdateRequestSchema.safeParse( + request.body, + ); - if (!workspaceSlug) { + if (!collectionSlug) { return; } if (!parsedRequest.success) { return reply.code(400).send({ - error: "The workspace name must be between 1 and 80 characters.", + error: + "Provide a collection name of 1 to 80 characters and/or a parent slug (or null for the root).", }); } try { return reply.send( - store.renameWorkspace(workspaceSlug, parsedRequest.data.name), + store.updateCollection(collectionSlug, parsedRequest.data), ); } catch (error) { - return sendWorkspaceMutationError(reply, error); + return sendCollectionMutationError(reply, error); } }, ); - app.delete<{ Params: WorkspaceParams }>( - "/api/workspaces/:workspaceSlug", + app.delete<{ Params: CollectionParams }>( + "/api/collections/:collectionSlug", async (request, reply) => { - const workspaceSlug = parseWorkspaceSlug( - request.params.workspaceSlug, + const collectionSlug = parseCollectionSlug( + request.params.collectionSlug, reply, ); - if (!workspaceSlug) { + if (!collectionSlug) { return; } try { - store.deleteWorkspace(workspaceSlug); + store.deleteCollection(collectionSlug); return reply.code(204).send(); } catch (error) { - return sendWorkspaceMutationError(reply, error); + return sendCollectionMutationError(reply, error); } }, ); - app.get<{ - Params: WorkspaceParams; - Querystring: { status?: string }; - }>( - "/api/workspaces/:workspaceSlug/documents", + app.get<{ Querystring: { collection?: string } }>( + "/api/archive", + async (request, reply) => { + const requestedCollection = request.query.collection; + let collectionSlug: string | undefined; + + if (requestedCollection) { + const parsedCollectionSlug = parseCollectionSlug( + requestedCollection, + reply, + ); + + if (!parsedCollectionSlug) { + return; + } + + collectionSlug = parsedCollectionSlug; + } + + try { + return reply.send({ + documents: store.listArchivedDocuments(collectionSlug), + }); + } catch (error) { + return sendDocumentError(reply, error); + } + }, + ); + + app.get<{ Querystring: DocumentListQuery }>( + "/api/docs", async (request, reply) => { - const workspaceSlug = parseWorkspaceSlug( - request.params.workspaceSlug, - reply, - ); const parsedStatus = DocumentStatusSchema.safeParse( request.query.status ?? "active", ); - if (!workspaceSlug) { - return; - } - if (!parsedStatus.success) { return reply.code(400).send({ error: 'The document status must be either "active" or "archived".', }); } + let collectionSlug: string | null | undefined; + + if (request.query.collection === ROOT_COLLECTION_QUERY) { + collectionSlug = null; + } else if (request.query.collection) { + const parsedCollectionSlug = parseCollectionSlug( + request.query.collection, + reply, + ); + + if (!parsedCollectionSlug) { + return; + } + + collectionSlug = parsedCollectionSlug; + } + try { return reply.send({ - documents: store.listDocuments(workspaceSlug, parsedStatus.data), + documents: store.listDocuments({ + status: parsedStatus.data, + ...(collectionSlug === undefined ? {} : { collectionSlug }), + }), }); } catch (error) { return sendDocumentError(reply, error); @@ -309,7 +338,7 @@ export function buildApp( ); app.put<{ Params: DocumentParams }>( - "/api/workspaces/:workspaceSlug/documents/:documentSlug", + "/api/docs/:documentSlug", async (request, reply) => { const params = parseDocumentParams(request.params, reply); @@ -322,7 +351,7 @@ export function buildApp( if (!parsedRequest.success) { return reply.code(400).send({ error: - "The request body must contain a nonblank title of at most 200 characters and Markdown content.", + "The request body must contain a nonblank title of at most 200 characters, Markdown content, and optionally a collection slug (or null).", }); } @@ -362,17 +391,18 @@ export function buildApp( } const document = store.publishDocument( - params.workspaceSlug, params.documentSlug, parsedRequest.data.title, parsedRequest.data.content, - condition, - expectedLatestFeedbackBatchId, - ); - const resource = store.getDocumentResource( - params.workspaceSlug, - params.documentSlug, + { + condition, + expectedLatestFeedbackBatchId, + ...(parsedRequest.data.collectionSlug === undefined + ? {} + : { collectionSlug: parsedRequest.data.collectionSlug }), + }, ); + const resource = store.getDocumentResource(params.documentSlug); return reply .header("etag", resource?.etag ?? "") .code(condition.kind === "create" ? 201 : 200) @@ -384,7 +414,7 @@ export function buildApp( ); app.get<{ Params: DocumentParams }>( - "/api/workspaces/:workspaceSlug/documents/:documentSlug", + "/api/docs/:documentSlug", async (request, reply) => { const params = parseDocumentParams(request.params, reply); @@ -393,14 +423,11 @@ export function buildApp( } try { - const resource = store.getDocumentResource( - params.workspaceSlug, - params.documentSlug, - ); + const resource = store.getDocumentResource(params.documentSlug); if (!resource) { return reply.code(404).send({ - error: `No document has been published with slug "${params.documentSlug}" in workspace "${params.workspaceSlug}".`, + error: `No document has been published with slug "${params.documentSlug}".`, }); } @@ -412,7 +439,7 @@ export function buildApp( ); app.get<{ Params: DocumentParams }>( - "/api/workspaces/:workspaceSlug/documents/:documentSlug/versions", + "/api/docs/:documentSlug/versions", async (request, reply) => { const params = parseDocumentParams(request.params, reply); @@ -422,10 +449,7 @@ export function buildApp( try { return reply.send({ - versions: store.listDocumentVersions( - params.workspaceSlug, - params.documentSlug, - ), + versions: store.listDocumentVersions(params.documentSlug), }); } catch (error) { return sendDocumentError(reply, error); @@ -434,7 +458,7 @@ export function buildApp( ); app.get<{ Params: DocumentVersionParams }>( - "/api/workspaces/:workspaceSlug/documents/:documentSlug/versions/:version", + "/api/docs/:documentSlug/versions/:version", async (request, reply) => { const params = parseDocumentVersionParams(request.params, reply); @@ -444,14 +468,12 @@ export function buildApp( try { const version = store.getDocumentVersion( - params.workspaceSlug, params.documentSlug, params.version, ); if (!version) { throw new DocumentVersionNotFoundError( - params.workspaceSlug, params.documentSlug, params.version, ); @@ -465,7 +487,7 @@ export function buildApp( ); app.post<{ Params: DocumentVersionParams }>( - "/api/workspaces/:workspaceSlug/documents/:documentSlug/versions/:version/restore", + "/api/docs/:documentSlug/versions/:version/restore", async (request, reply) => { const params = parseDocumentVersionParams(request.params, reply); const expectedEtag = parseIfMatch(request.headers["if-match"], reply); @@ -476,15 +498,11 @@ export function buildApp( try { const document = store.restoreDocumentVersion( - params.workspaceSlug, params.documentSlug, params.version, expectedEtag, ); - const resource = store.getDocumentResource( - params.workspaceSlug, - params.documentSlug, - ); + const resource = store.getDocumentResource(params.documentSlug); return reply.header("etag", resource?.etag ?? "").send(document); } catch (error) { return sendDocumentError(reply, error); @@ -493,7 +511,7 @@ export function buildApp( ); app.patch<{ Params: DocumentParams }>( - "/api/workspaces/:workspaceSlug/documents/:documentSlug", + "/api/docs/:documentSlug", async (request, reply) => { const params = parseDocumentParams(request.params, reply); const parsedRequest = DocumentUpdateRequestSchema.safeParse(request.body); @@ -520,20 +538,9 @@ export function buildApp( const document = parsedRequest.data.status === "archived" - ? store.archiveDocument( - params.workspaceSlug, - params.documentSlug, - expectedEtag, - ) - : store.unarchiveDocument( - params.workspaceSlug, - params.documentSlug, - expectedEtag, - ); - const resource = store.getDocumentResource( - params.workspaceSlug, - params.documentSlug, - ); + ? store.archiveDocument(params.documentSlug, expectedEtag) + : store.unarchiveDocument(params.documentSlug, expectedEtag); + const resource = store.getDocumentResource(params.documentSlug); return reply.header("etag", resource?.etag ?? "").send(document); } catch (error) { return sendDocumentError(reply, error); @@ -542,7 +549,7 @@ export function buildApp( ); app.post<{ Params: DocumentParams }>( - "/api/workspaces/:workspaceSlug/documents/:documentSlug/move", + "/api/docs/:documentSlug/move", async (request, reply) => { const params = parseDocumentParams(request.params, reply); const parsedRequest = DocumentMoveRequestSchema.safeParse(request.body); @@ -553,7 +560,8 @@ export function buildApp( if (!parsedRequest.success) { return reply.code(400).send({ - error: "The destination workspace slug is invalid.", + error: + "The destination must be a collection slug, or null for the root.", }); } @@ -568,22 +576,12 @@ export function buildApp( } const movedDocument = store.moveDocument( - params.workspaceSlug, params.documentSlug, - parsedRequest.data.workspaceSlug, + parsedRequest.data.collectionSlug, expectedEtag, ); - const resource = store.getDocumentResource( - movedDocument.workspaceSlug, - movedDocument.slug, - ); - return reply - .header("etag", resource?.etag ?? "") - .header( - "location", - `/api/workspaces/${movedDocument.workspaceSlug}/documents/${movedDocument.slug}`, - ) - .send(movedDocument); + const resource = store.getDocumentResource(params.documentSlug); + return reply.header("etag", resource?.etag ?? "").send(movedDocument); } catch (error) { return sendDocumentError(reply, error); } @@ -591,7 +589,7 @@ export function buildApp( ); app.delete<{ Params: DocumentParams }>( - "/api/workspaces/:workspaceSlug/documents/:documentSlug", + "/api/docs/:documentSlug", async (request, reply) => { const params = parseDocumentParams(request.params, reply); @@ -609,11 +607,7 @@ export function buildApp( return; } - store.deleteArchivedDocument( - params.workspaceSlug, - params.documentSlug, - expectedEtag, - ); + store.deleteArchivedDocument(params.documentSlug, expectedEtag); return reply.code(204).send(); } catch (error) { return sendDocumentError(reply, error); @@ -622,7 +616,7 @@ export function buildApp( ); app.post<{ Params: DocumentParams }>( - "/api/workspaces/:workspaceSlug/documents/:documentSlug/feedback", + "/api/docs/:documentSlug/feedback", async (request, reply) => { const params = parseDocumentParams(request.params, reply); const parsedSubmission = FeedbackSubmissionSchema.safeParse(request.body); @@ -648,18 +642,12 @@ export function buildApp( } const batch = store.addFeedback( - params.workspaceSlug, params.documentSlug, parsedSubmission.data, expectedEtag, ); - feedbackWaiters.notify( - feedbackWaitKey(params.workspaceSlug, params.documentSlug), - ); - const resource = store.getDocumentResource( - params.workspaceSlug, - params.documentSlug, - ); + feedbackWaiters.notify(feedbackWaitKey(params.documentSlug)); + const resource = store.getDocumentResource(params.documentSlug); return reply .header("etag", resource?.etag ?? "") .code(201) @@ -667,7 +655,7 @@ export function buildApp( } catch (error) { if (error instanceof DocumentNotFoundError) { return reply.code(409).send({ - error: `Publish the "${params.documentSlug}" document in workspace "${params.workspaceSlug}" before submitting feedback.`, + error: `Publish the "${params.documentSlug}" document before submitting feedback.`, }); } @@ -680,7 +668,7 @@ export function buildApp( Params: DocumentParams; Querystring: FeedbackWaitQuery; }>( - "/api/workspaces/:workspaceSlug/documents/:documentSlug/feedback/wait", + "/api/docs/:documentSlug/feedback/wait", async (request, reply): Promise => { const params = parseDocumentParams(request.params, reply); const query = parseFeedbackWaitQuery(request.query, reply); @@ -690,10 +678,7 @@ export function buildApp( } void reply.header("cache-control", "no-store"); - const key = feedbackWaitKey( - params.workspaceSlug, - params.documentSlug, - ); + const key = feedbackWaitKey(params.documentSlug); try { const immediate = getFeedbackWaitResponse( @@ -773,7 +758,7 @@ export function buildApp( ); app.get<{ Params: DocumentParams }>( - "/api/workspaces/:workspaceSlug/documents/:documentSlug/feedback", + "/api/docs/:documentSlug/feedback", async (request, reply): Promise => { const params = parseDocumentParams(request.params, reply); @@ -791,16 +776,10 @@ export function buildApp( return; } - const resource = store.getDocumentResource( - params.workspaceSlug, - params.documentSlug, - ); + const resource = store.getDocumentResource(params.documentSlug); if (!resource) { - throw new DocumentNotFoundError( - params.workspaceSlug, - params.documentSlug, - ); + throw new DocumentNotFoundError(params.documentSlug); } if (expectedEtag && expectedEtag !== resource.etag) { @@ -809,7 +788,7 @@ export function buildApp( return reply .header("etag", resource.etag) - .send(store.getFeedback(params.workspaceSlug, params.documentSlug)); + .send(store.getFeedback(params.documentSlug)); } catch (error) { if (error instanceof PersistedDataError) { await reply.code(500).send({ @@ -826,16 +805,16 @@ export function buildApp( return app; } -function parseWorkspaceSlug( +function parseCollectionSlug( value: string, reply: FastifyReply, ): string | null { - const parsedSlug = WorkspaceSlugSchema.safeParse(value); + const parsedSlug = CollectionSlugSchema.safeParse(value); if (!parsedSlug.success) { void reply.code(400).send({ error: - "The workspace slug must use lowercase letters, numbers, and single hyphens.", + "The collection slug must use lowercase letters, numbers, and single hyphens.", }); return null; } @@ -847,13 +826,8 @@ function parseDocumentParams( params: DocumentParams, reply: FastifyReply, ): DocumentParams | null { - const workspaceSlug = parseWorkspaceSlug(params.workspaceSlug, reply); const documentSlug = DocumentSlugSchema.safeParse(params.documentSlug); - if (!workspaceSlug) { - return null; - } - if (!documentSlug.success) { void reply.code(400).send({ error: @@ -862,13 +836,13 @@ function parseDocumentParams( return null; } - return { workspaceSlug, documentSlug: documentSlug.data }; + return { documentSlug: documentSlug.data }; } function parseDocumentVersionParams( params: DocumentVersionParams, reply: FastifyReply, -): { workspaceSlug: string; documentSlug: string; version: number } | null { +): { documentSlug: string; version: number } | null { const documentParams = parseDocumentParams(params, reply); const version = Number(params.version); @@ -928,30 +902,17 @@ function getFeedbackWaitResponse( params: DocumentParams, after: number, ): { response: FeedbackWaitResponse; etag: string } | null { - const resource = store.getDocumentResource( - params.workspaceSlug, - params.documentSlug, - ); + const resource = store.getDocumentResource(params.documentSlug); if (!resource) { - throw new DocumentNotFoundError( - params.workspaceSlug, - params.documentSlug, - ); + throw new DocumentNotFoundError(params.documentSlug); } if (resource.value.archivedAt !== null) { - throw new DocumentArchivedError( - params.workspaceSlug, - params.documentSlug, - ); + throw new DocumentArchivedError(params.documentSlug); } - const batches = store.listFeedbackReceiptsAfter( - params.workspaceSlug, - params.documentSlug, - after, - ); + const batches = store.listFeedbackReceiptsAfter(params.documentSlug, after); if (batches.length === 0) { return null; @@ -966,7 +927,6 @@ function getFeedbackWaitResponse( return { etag: resource.etag, response: FeedbackWaitResponseSchema.parse({ - workspaceSlug: params.workspaceSlug, documentSlug: params.documentSlug, documentVersion: resource.value.version, latestBatchId, @@ -1079,24 +1039,21 @@ function parseIfFeedbackMatch( return batchId; } -function sendWorkspaceMutationError(reply: FastifyReply, error: unknown) { - if (error instanceof WorkspaceNotFoundError) { +function sendCollectionMutationError(reply: FastifyReply, error: unknown) { + if (error instanceof CollectionNotFoundError) { return reply.code(404).send({ error: error.message }); } - if (error instanceof DefaultWorkspaceProtectedError) { - return reply.code(403).send({ error: error.message }); - } - if ( - error instanceof WorkspaceSlugConflictError || - error instanceof WorkspaceNameConflictError || - error instanceof WorkspaceNotEmptyError + error instanceof CollectionSlugConflictError || + error instanceof CollectionNameConflictError || + error instanceof CollectionNotEmptyError || + error instanceof CollectionCycleError ) { return reply.code(409).send({ error: error.message }); } - if (error instanceof WorkspaceNameInvalidError) { + if (error instanceof CollectionNameInvalidError) { return reply.code(400).send({ error: error.message }); } @@ -1120,7 +1077,7 @@ function sendDocumentError(reply: FastifyReply, error: unknown) { } if ( - error instanceof WorkspaceNotFoundError || + error instanceof CollectionNotFoundError || error instanceof DocumentNotFoundError || error instanceof DocumentVersionNotFoundError ) { @@ -1129,8 +1086,7 @@ function sendDocumentError(reply: FastifyReply, error: unknown) { if ( error instanceof DocumentNotArchivedError || - error instanceof DocumentArchivedError || - error instanceof DocumentSlugConflictError + error instanceof DocumentArchivedError ) { return reply.code(409).send({ error: error.message }); } diff --git a/apps/server/src/feedback-waiters.test.ts b/apps/server/src/feedback-waiters.test.ts index c249e1d..a1cea2c 100644 --- a/apps/server/src/feedback-waiters.test.ts +++ b/apps/server/src/feedback-waiters.test.ts @@ -8,13 +8,10 @@ import { describe("FeedbackWaiters", () => { it("notifies only subscribers for the matching document", async () => { const waiters = new FeedbackWaiters(); - const first = waiters.subscribe(feedbackWaitKey("default", "first"), 100); - const second = waiters.subscribe( - feedbackWaitKey("default", "second"), - 5, - ); + const first = waiters.subscribe(feedbackWaitKey("first"), 100); + const second = waiters.subscribe(feedbackWaitKey("second"), 5); - waiters.notify(feedbackWaitKey("default", "first")); + waiters.notify(feedbackWaitKey("first")); await expect(first.result).resolves.toBe("notified"); await expect(second.result).resolves.toBe("timeout"); @@ -22,18 +19,15 @@ describe("FeedbackWaiters", () => { it("releases every subscriber when closed", async () => { const waiters = new FeedbackWaiters(); - const first = waiters.subscribe(feedbackWaitKey("default", "first"), 100); - const second = waiters.subscribe( - feedbackWaitKey("research", "second"), - 100, - ); + const first = waiters.subscribe(feedbackWaitKey("first"), 100); + const second = waiters.subscribe(feedbackWaitKey("second"), 100); waiters.close(); await expect(first.result).resolves.toBe("closed"); await expect(second.result).resolves.toBe("closed"); await expect( - waiters.subscribe(feedbackWaitKey("default", "later"), 100).result, + waiters.subscribe(feedbackWaitKey("later"), 100).result, ).resolves.toBe("closed"); }); }); diff --git a/apps/server/src/feedback-waiters.ts b/apps/server/src/feedback-waiters.ts index f475172..b43e5c4 100644 --- a/apps/server/src/feedback-waiters.ts +++ b/apps/server/src/feedback-waiters.ts @@ -73,9 +73,6 @@ export class FeedbackWaiters { } } -export function feedbackWaitKey( - workspaceSlug: string, - documentSlug: string, -): string { - return `${workspaceSlug}\0${documentSlug}`; +export function feedbackWaitKey(documentSlug: string): string { + return documentSlug; } diff --git a/apps/server/src/storage/pena-store.ts b/apps/server/src/storage/pena-store.ts index 771d5f5..8e401cf 100644 --- a/apps/server/src/storage/pena-store.ts +++ b/apps/server/src/storage/pena-store.ts @@ -1,4 +1,6 @@ import type { + Collection, + CollectionSummary, DocumentSummary, DocumentStatus, DocumentVersion, @@ -8,8 +10,6 @@ import type { FeedbackResponse, FeedbackSubmission, PenaDocument, - Workspace, - WorkspaceSummary, } from "@pena/contracts"; export type DocumentWriteCondition = @@ -21,116 +21,95 @@ export interface DocumentResource { value: T; } +export interface CollectionUpdate { + name?: string; + parentSlug?: string | null; +} + +export interface DocumentPublishOptions { + condition?: DocumentWriteCondition; + expectedLatestFeedbackBatchId?: number; + /** + * Files the document in a collection, or at the root with `null`. Leave it + * `undefined` to keep an existing document where it is. + */ + collectionSlug?: string | null; +} + +export interface DocumentListFilter { + status?: DocumentStatus; + /** + * `undefined` lists every document, `null` lists the ones at the root, and a + * slug lists the ones filed directly in that collection. + */ + collectionSlug?: string | null; +} + export interface PenaStore { - listWorkspaces(): WorkspaceSummary[]; - createWorkspace(name: string): Workspace; - renameWorkspace(slug: string, name: string): Workspace; - deleteWorkspace(slug: string): void; + listCollections(): CollectionSummary[]; + createCollection(name: string, parentSlug?: string | null): Collection; + updateCollection(slug: string, update: CollectionUpdate): Collection; + deleteCollection(slug: string): void; publishDocument( - workspaceSlug: string, slug: string, title: string, content: string, - condition?: DocumentWriteCondition, - expectedLatestFeedbackBatchId?: number, + options?: DocumentPublishOptions, ): PenaDocument; - getDocument(workspaceSlug: string, slug: string): PenaDocument | null; - getDocumentResource( - workspaceSlug: string, - slug: string, - ): DocumentResource | null; - listDocumentVersions( - workspaceSlug: string, - slug: string, - ): DocumentVersionSummary[]; - getDocumentVersion( - workspaceSlug: string, - slug: string, - version: number, - ): DocumentVersion | null; + getDocument(slug: string): PenaDocument | null; + getDocumentResource(slug: string): DocumentResource | null; + listDocumentVersions(slug: string): DocumentVersionSummary[]; + getDocumentVersion(slug: string, version: number): DocumentVersion | null; restoreDocumentVersion( - workspaceSlug: string, slug: string, version: number, expectedEtag?: string, ): PenaDocument; - listDocuments( - workspaceSlug: string, - status?: DocumentStatus, - ): DocumentSummary[]; - listArchivedDocuments(workspaceSlug?: string): DocumentSummary[]; + listDocuments(filter?: DocumentListFilter): DocumentSummary[]; + listArchivedDocuments(collectionSlug?: string): DocumentSummary[]; moveDocument( - workspaceSlug: string, - slug: string, - destinationWorkspaceSlug: string, - expectedEtag?: string, - ): DocumentSummary; - archiveDocument( - workspaceSlug: string, - slug: string, - expectedEtag?: string, - ): DocumentSummary; - unarchiveDocument( - workspaceSlug: string, slug: string, + collectionSlug: string | null, expectedEtag?: string, ): DocumentSummary; - deleteArchivedDocument( - workspaceSlug: string, - slug: string, - expectedEtag?: string, - ): void; + archiveDocument(slug: string, expectedEtag?: string): DocumentSummary; + unarchiveDocument(slug: string, expectedEtag?: string): DocumentSummary; + deleteArchivedDocument(slug: string, expectedEtag?: string): void; addFeedback( - workspaceSlug: string, slug: string, submission: FeedbackSubmission, expectedEtag?: string, ): FeedbackBatch; - listFeedbackReceiptsAfter( - workspaceSlug: string, - slug: string, - after: number, - ): FeedbackReceipt[]; - getFeedback(workspaceSlug: string, slug: string): FeedbackResponse; + listFeedbackReceiptsAfter(slug: string, after: number): FeedbackReceipt[]; + getFeedback(slug: string): FeedbackResponse; close(): void; } export class DocumentNotFoundError extends Error { - constructor(workspaceSlug: string, slug: string) { - super( - `No document has been published with slug "${slug}" in workspace "${workspaceSlug}".`, - ); + constructor(slug: string) { + super(`No document has been published with slug "${slug}".`); this.name = "DocumentNotFoundError"; } } export class DocumentNotArchivedError extends Error { - constructor(workspaceSlug: string, slug: string) { + constructor(slug: string) { super( - `The document "${slug}" in workspace "${workspaceSlug}" must be archived before it can be deleted.`, + `The document "${slug}" must be archived before it can be deleted.`, ); this.name = "DocumentNotArchivedError"; } } export class DocumentArchivedError extends Error { - constructor(workspaceSlug: string, slug: string) { + constructor(slug: string) { super( - `The document "${slug}" in workspace "${workspaceSlug}" is archived. Unarchive it before changing or reviewing it.`, + `The document "${slug}" is archived. Unarchive it before changing or reviewing it.`, ); this.name = "DocumentArchivedError"; } } -export class DocumentSlugConflictError extends Error { - constructor(workspaceSlug: string, slug: string) { - super( - `A document with slug "${slug}" already exists in workspace "${workspaceSlug}".`, - ); - this.name = "DocumentSlugConflictError"; - } -} - export class DocumentPreconditionFailedError extends Error { constructor(public readonly currentVersion: number) { super("The document changed after it was read."); @@ -149,53 +128,57 @@ export class FeedbackPreconditionFailedError extends Error { } export class DocumentVersionNotFoundError extends Error { - constructor(workspaceSlug: string, slug: string, version: number) { - super( - `Version ${version} does not exist for document "${slug}" in workspace "${workspaceSlug}".`, - ); + constructor(slug: string, version: number) { + super(`Version ${version} does not exist for document "${slug}".`); this.name = "DocumentVersionNotFoundError"; } } -export class WorkspaceNotFoundError extends Error { +export class CollectionNotFoundError extends Error { constructor(slug: string) { - super(`No workspace exists with slug "${slug}".`); - this.name = "WorkspaceNotFoundError"; + super(`No collection exists with slug "${slug}".`); + this.name = "CollectionNotFoundError"; } } -export class WorkspaceSlugConflictError extends Error { +export class CollectionSlugConflictError extends Error { constructor(slug: string) { - super(`A workspace with slug "${slug}" already exists.`); - this.name = "WorkspaceSlugConflictError"; + super(`A collection with slug "${slug}" already exists.`); + this.name = "CollectionSlugConflictError"; } } -export class WorkspaceNameInvalidError extends Error { +export class CollectionNameInvalidError extends Error { constructor() { - super("The workspace name must contain at least one letter or number."); - this.name = "WorkspaceNameInvalidError"; + super( + 'The collection name must contain at least one letter or number, and cannot be "root".', + ); + this.name = "CollectionNameInvalidError"; } } -export class WorkspaceNameConflictError extends Error { +export class CollectionNameConflictError extends Error { constructor(name: string) { - super(`A workspace named "${name}" already exists.`); - this.name = "WorkspaceNameConflictError"; + super(`A collection named "${name}" already exists.`); + this.name = "CollectionNameConflictError"; } } -export class DefaultWorkspaceProtectedError extends Error { - constructor(action: "rename" | "delete") { - super(`The default workspace cannot be ${action}d.`); - this.name = "DefaultWorkspaceProtectedError"; +export class CollectionNotEmptyError extends Error { + constructor(slug: string) { + super( + `The collection "${slug}" must contain no documents or collections before it can be deleted.`, + ); + this.name = "CollectionNotEmptyError"; } } -export class WorkspaceNotEmptyError extends Error { +export class CollectionCycleError extends Error { constructor(slug: string) { - super(`The workspace "${slug}" must be empty before it can be deleted.`); - this.name = "WorkspaceNotEmptyError"; + super( + `The collection "${slug}" cannot be moved into itself or one of its descendants.`, + ); + this.name = "CollectionCycleError"; } } @@ -214,3 +197,12 @@ export class UnsupportedSchemaVersionError extends Error { this.name = "UnsupportedSchemaVersionError"; } } + +export class DocumentSlugConflictMigrationError extends Error { + constructor(slugs: string[]) { + super( + `Document slugs must be unique before collections can replace workspaces, but these slugs exist in more than one workspace: ${slugs.join(", ")}.`, + ); + this.name = "DocumentSlugConflictMigrationError"; + } +} diff --git a/apps/server/src/storage/sqlite-pena-store.test.ts b/apps/server/src/storage/sqlite-pena-store.test.ts index 546d447..954e535 100644 --- a/apps/server/src/storage/sqlite-pena-store.test.ts +++ b/apps/server/src/storage/sqlite-pena-store.test.ts @@ -6,18 +6,21 @@ import Database from "better-sqlite3"; import { afterEach, describe, expect, it } from "vitest"; import { + CollectionCycleError, + CollectionNameInvalidError, + CollectionNameConflictError, + CollectionNotEmptyError, + CollectionNotFoundError, + CollectionSlugConflictError, DocumentArchivedError, DocumentNotArchivedError, DocumentNotFoundError, - DocumentSlugConflictError, + DocumentSlugConflictMigrationError, PersistedDataError, UnsupportedSchemaVersionError, - type DocumentWriteCondition, + type DocumentPublishOptions, } from "./pena-store.js"; -import { - DEFAULT_WORKSPACE_SLUG, - SqlitePenaStore, -} from "./sqlite-pena-store.js"; +import { SqlitePenaStore } from "./sqlite-pena-store.js"; const feedbackSubmission = { comments: [ @@ -48,22 +51,27 @@ function createDatabasePath(): string { return join(directory, "pena.sqlite"); } +function createSequencedStore(timestampValues: string[]): SqlitePenaStore { + const timestamps = timestampValues.map((value) => new Date(value)); + + return createStore(":memory:", () => { + const timestamp = timestamps.shift(); + + if (!timestamp) { + throw new Error("Test clock was called unexpectedly."); + } + + return timestamp; + }); +} + function publishStoreDocument( store: SqlitePenaStore, - workspaceSlug: string, slug: string, content: string, - condition?: DocumentWriteCondition, - expectedLatestFeedbackBatchId?: number, + options?: DocumentPublishOptions, ) { - return store.publishDocument( - workspaceSlug, - slug, - formatTestTitle(slug), - content, - condition, - expectedLatestFeedbackBatchId, - ); + return store.publishDocument(slug, formatTestTitle(slug), content, options); } function formatTestTitle(slug: string): string { @@ -73,6 +81,158 @@ function formatTestTitle(slug: string): string { .join(" "); } +interface Schema9Version { + title: string; + content: string; + publishedAt: string; +} + +interface Schema9Document { + id: number; + workspaceId: number; + slug: string; + versions: Schema9Version[]; + feedbackOnLatest?: string[]; +} + +/** + * Builds the last workspace-era schema by hand so migration tests can start + * from a database shaped exactly like one produced before collections. + */ +function createSchema9Database( + databasePath: string, + workspaces: Array<{ id: number; slug: string; name: string }> = [ + { id: 1, slug: "default", name: "Default" }, + ], +): Database.Database { + const database = new Database(databasePath); + database.exec(` + CREATE TABLE workspaces ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL COLLATE NOCASE UNIQUE, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) STRICT; + + CREATE TABLE documents ( + id INTEGER PRIMARY KEY, + workspace_id INTEGER NOT NULL + REFERENCES workspaces(id) ON DELETE RESTRICT, + slug TEXT NOT NULL, + current_version INTEGER NOT NULL CHECK (current_version >= 1), + archived_at TEXT, + state_token TEXT NOT NULL, + UNIQUE (workspace_id, slug) + ) STRICT; + + CREATE TABLE document_versions ( + id INTEGER PRIMARY KEY, + document_id INTEGER NOT NULL + REFERENCES documents(id) ON DELETE CASCADE, + version INTEGER NOT NULL CHECK (version >= 1), + content TEXT NOT NULL, + published_at TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + UNIQUE (document_id, version) + ) STRICT; + + CREATE TABLE feedback_batches ( + id INTEGER PRIMARY KEY, + document_version_id INTEGER NOT NULL + REFERENCES document_versions(id) + ON DELETE CASCADE, + submitted_at TEXT NOT NULL, + comments_json TEXT NOT NULL, + instruction_text TEXT + ) STRICT; + + CREATE INDEX document_versions_document_id_version + ON document_versions(document_id, version); + + CREATE INDEX feedback_batches_document_version_id_id + ON feedback_batches(document_version_id, id); + + CREATE INDEX documents_workspace_id_archived_at + ON documents(workspace_id, archived_at); + + PRAGMA user_version = 9; + `); + const insertWorkspace = database.prepare( + ` + INSERT INTO workspaces (id, slug, name, created_at, updated_at) + VALUES (?, ?, ?, '2026-07-19T09:00:00.000Z', '2026-07-19T09:00:00.000Z') + `, + ); + + for (const workspace of workspaces) { + insertWorkspace.run(workspace.id, workspace.slug, workspace.name); + } + + return database; +} + +function insertSchema9Document( + database: Database.Database, + document: Schema9Document, +): void { + database + .prepare( + ` + INSERT INTO documents + (id, workspace_id, slug, current_version, archived_at, state_token) + VALUES (?, ?, ?, ?, NULL, lower(hex(randomblob(16)))) + `, + ) + .run( + document.id, + document.workspaceId, + document.slug, + document.versions.length, + ); + const insertVersion = database.prepare( + ` + INSERT INTO document_versions + (document_id, version, content, published_at, title) + VALUES (?, ?, ?, ?, ?) + `, + ); + let latestVersionId = 0; + + document.versions.forEach((version, index) => { + const result = insertVersion.run( + document.id, + index + 1, + version.content, + version.publishedAt, + version.title, + ); + latestVersionId = Number(result.lastInsertRowid); + }); + + const insertFeedback = database.prepare( + ` + INSERT INTO feedback_batches + (document_version_id, submitted_at, comments_json) + VALUES (?, '2026-07-19T10:01:00.000Z', ?) + `, + ); + + for (const comment of document.feedbackOnLatest ?? []) { + insertFeedback.run( + latestVersionId, + JSON.stringify([ + { + selectedText: "draft", + comment, + contextBefore: "", + contextAfter: "", + }, + ]), + ); + } +} + afterEach(() => { for (const store of stores) { store.close(); @@ -86,39 +246,32 @@ afterEach(() => { }); describe("SqlitePenaStore", () => { - it("lists document summaries by newest update without their content", () => { - const timestamps = [ - new Date("2026-07-19T10:00:00.000Z"), - new Date("2026-07-19T10:01:00.000Z"), - new Date("2026-07-19T10:02:00.000Z"), - ]; - const store = createStore(":memory:", () => { - const timestamp = timestamps.shift(); - - if (!timestamp) { - throw new Error("Test clock was called unexpectedly."); - } - - return timestamp; - }); - publishStoreDocument(store, DEFAULT_WORKSPACE_SLUG, "first-draft", "First"); - publishStoreDocument( - store, - DEFAULT_WORKSPACE_SLUG, - "second-draft", - "Second", + it("refuses the reserved collection slug that names the root", () => { + const store = createStore(); + + expect(() => store.createCollection("Root")).toThrow( + CollectionNameInvalidError, ); - publishStoreDocument( - store, - DEFAULT_WORKSPACE_SLUG, - "first-draft", - "First, revised", + expect(() => store.createCollection("root ")).toThrow( + CollectionNameInvalidError, ); + expect(store.listCollections()).toEqual([]); + }); + + it("lists document summaries by newest update without their content", () => { + const store = createSequencedStore([ + "2026-07-19T10:00:00.000Z", + "2026-07-19T10:01:00.000Z", + "2026-07-19T10:02:00.000Z", + ]); + publishStoreDocument(store, "first-draft", "First"); + publishStoreDocument(store, "second-draft", "Second"); + publishStoreDocument(store, "first-draft", "First, revised"); - expect(store.listDocuments(DEFAULT_WORKSPACE_SLUG)).toEqual([ + expect(store.listDocuments()).toEqual([ { - workspaceSlug: "default", slug: "first-draft", + collectionSlug: null, version: 2, updatedAt: "2026-07-19T10:02:00.000Z", archivedAt: null, @@ -126,8 +279,8 @@ describe("SqlitePenaStore", () => { excerpt: "First, revised", }, { - workspaceSlug: "default", slug: "second-draft", + collectionSlug: null, version: 1, updatedAt: "2026-07-19T10:01:00.000Z", archivedAt: null, @@ -141,7 +294,6 @@ describe("SqlitePenaStore", () => { const store = createStore(); publishStoreDocument( store, - DEFAULT_WORKSPACE_SLUG, "initial-spec", [ "---", @@ -168,7 +320,7 @@ describe("SqlitePenaStore", () => { ].join("\n"), ); - const [summary] = store.listDocuments(DEFAULT_WORKSPACE_SLUG); + const [summary] = store.listDocuments(); expect(summary?.title).toBe("Initial Spec"); expect(summary?.excerpt).toBe( @@ -180,239 +332,383 @@ describe("SqlitePenaStore", () => { const store = createStore(); publishStoreDocument( store, - DEFAULT_WORKSPACE_SLUG, "initial-spec", `# Long\n\n${"alpha ".repeat(120).trim()}`, ); - const [summary] = store.listDocuments(DEFAULT_WORKSPACE_SLUG); + const [summary] = store.listDocuments(); const excerpt = summary?.excerpt ?? ""; expect(excerpt.length).toBeLessThanOrEqual(320); expect(excerpt.endsWith("alpha")).toBe(true); }); - it("archives and restores a document without losing feedback", () => { - const timestamps = [ - new Date("2026-07-19T10:00:00.000Z"), - new Date("2026-07-19T10:01:00.000Z"), - new Date("2026-07-19T10:02:00.000Z"), - ]; - const store = createStore(":memory:", () => { - const timestamp = timestamps.shift(); - - if (!timestamp) { - throw new Error("Test clock was called unexpectedly."); - } - - return timestamp; + it("creates, renames, reparents, and deletes nested collections", () => { + const store = createSequencedStore([ + "2026-07-19T10:00:00.000Z", + "2026-07-19T10:01:00.000Z", + "2026-07-19T10:02:00.000Z", + "2026-07-19T10:03:00.000Z", + "2026-07-19T10:04:00.000Z", + "2026-07-19T10:05:00.000Z", + ]); + + const research = store.createCollection("Research"); + const notes = store.createCollection("Field Notes", "research"); + publishStoreDocument(store, "survey", "Survey", { + collectionSlug: "field-notes", }); - publishStoreDocument( - store, - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - "Current draft", - ); - store.addFeedback( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - feedbackSubmission, - ); - const archived = store.archiveDocument( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", + expect(research).toEqual({ + slug: "research", + name: "Research", + parentSlug: null, + createdAt: "2026-07-19T10:00:00.000Z", + updatedAt: "2026-07-19T10:00:00.000Z", + }); + expect(notes).toMatchObject({ slug: "field-notes", parentSlug: "research" }); + expect(store.listCollections()).toEqual([ + expect.objectContaining({ + slug: "field-notes", + parentSlug: "research", + documentCount: 1, + childCount: 0, + }), + expect.objectContaining({ + slug: "research", + parentSlug: null, + documentCount: 0, + childCount: 1, + }), + ]); + + const renamed = store.updateCollection("field-notes", { name: "Notes" }); + + expect(renamed).toEqual({ + slug: "field-notes", + name: "Notes", + parentSlug: "research", + createdAt: "2026-07-19T10:01:00.000Z", + updatedAt: "2026-07-19T10:03:00.000Z", + }); + expect(store.updateCollection("field-notes", { name: "Notes" })).toEqual( + renamed, ); - expect(archived.archivedAt).toBe("2026-07-19T10:02:00.000Z"); - expect(store.listDocuments(DEFAULT_WORKSPACE_SLUG)).toEqual([]); - expect(store.listDocuments(DEFAULT_WORKSPACE_SLUG, "archived")).toEqual([ - archived, + const reparented = store.updateCollection("field-notes", { + parentSlug: null, + }); + + expect(reparented).toMatchObject({ + slug: "field-notes", + name: "Notes", + parentSlug: null, + updatedAt: "2026-07-19T10:04:00.000Z", + }); + expect(store.listCollections().map(({ slug, childCount }) => [slug, childCount])) + .toEqual([ + ["field-notes", 0], + ["research", 0], + ]); + + store.deleteCollection("research"); + + expect(store.listCollections().map(({ slug }) => slug)).toEqual([ + "field-notes", ]); - expect( - store.getFeedback(DEFAULT_WORKSPACE_SLUG, "initial-spec").batches, - ).toHaveLength(1); + expect(() => store.deleteCollection("research")).toThrow( + CollectionNotFoundError, + ); + }); - const restored = store.unarchiveDocument( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", + it("refuses to delete a collection holding a document or a child", () => { + const store = createStore(); + store.createCollection("Research"); + store.createCollection("Archive", "research"); + publishStoreDocument(store, "survey", "Survey", { + collectionSlug: "archive", + }); + + expect(() => store.deleteCollection("research")).toThrow( + CollectionNotEmptyError, + ); + expect(() => store.deleteCollection("archive")).toThrow( + CollectionNotEmptyError, ); - expect(restored.archivedAt).toBeNull(); - expect(store.listDocuments(DEFAULT_WORKSPACE_SLUG)).toEqual([restored]); - expect(store.listDocuments(DEFAULT_WORKSPACE_SLUG, "archived")).toEqual([]); + store.moveDocument("survey", null); + store.deleteCollection("archive"); + store.deleteCollection("research"); + + expect(store.listCollections()).toEqual([]); + }); + + it("rejects reparenting a collection into itself or a descendant", () => { + const store = createStore(); + store.createCollection("Top"); + store.createCollection("Middle", "top"); + store.createCollection("Bottom", "middle"); + + expect(() => + store.updateCollection("top", { parentSlug: "top" }), + ).toThrow(CollectionCycleError); + expect(() => + store.updateCollection("top", { parentSlug: "bottom" }), + ).toThrow(CollectionCycleError); + expect(() => + store.updateCollection("middle", { parentSlug: "missing" }), + ).toThrow(CollectionNotFoundError); + expect( - store.getFeedback(DEFAULT_WORKSPACE_SLUG, "initial-spec").batches, - ).toHaveLength(1); + store.updateCollection("bottom", { parentSlug: "top" }), + ).toMatchObject({ slug: "bottom", parentSlug: "top" }); }); - it("moves an active document and its feedback to another workspace", () => { + it("rejects conflicting collection names and slugs and missing parents", () => { const store = createStore(); - store.createWorkspace("Research"); - publishStoreDocument( - store, - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - "Current draft", + store.createCollection("Research"); + + // Any name that slugifies to an existing slug is a slug clash; a name + // clash on create is unreachable because the slug is derived from it. + expect(() => store.createCollection("research")).toThrow( + CollectionSlugConflictError, ); - store.addFeedback( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - feedbackSubmission, + expect(() => store.createCollection(" Research! ")).toThrow( + CollectionSlugConflictError, ); - - const moved = store.moveDocument( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - "research", + expect(() => store.createCollection("Notes", "missing")).toThrow( + CollectionNotFoundError, ); + store.createCollection("Notes"); + + expect(() => + store.updateCollection("notes", { name: "RESEARCH" }), + ).toThrow(CollectionNameConflictError); + }); + + it("archives and restores a document without losing feedback", () => { + const store = createSequencedStore([ + "2026-07-19T10:00:00.000Z", + "2026-07-19T10:01:00.000Z", + "2026-07-19T10:02:00.000Z", + ]); + publishStoreDocument(store, "initial-spec", "Current draft"); + store.addFeedback("initial-spec", feedbackSubmission); + + const archived = store.archiveDocument("initial-spec"); + + expect(archived.archivedAt).toBe("2026-07-19T10:02:00.000Z"); + expect(store.listDocuments()).toEqual([]); + expect(store.listDocuments({ status: "archived" })).toEqual([archived]); + expect(store.getFeedback("initial-spec").batches).toHaveLength(1); + + const restored = store.unarchiveDocument("initial-spec"); + + expect(restored.archivedAt).toBeNull(); + expect(store.listDocuments()).toEqual([restored]); + expect(store.listDocuments({ status: "archived" })).toEqual([]); + expect(store.getFeedback("initial-spec").batches).toHaveLength(1); + }); + + it("moves an active document with its feedback into a collection and back", () => { + const store = createStore(); + store.createCollection("Research"); + publishStoreDocument(store, "initial-spec", "Current draft"); + store.addFeedback("initial-spec", feedbackSubmission); + const before = store.getDocumentResource("initial-spec"); + + const moved = store.moveDocument("initial-spec", "research"); + expect(moved).toMatchObject({ - workspaceSlug: "research", + collectionSlug: "research", slug: "initial-spec", version: 1, archivedAt: null, }); - expect( - store.getDocument(DEFAULT_WORKSPACE_SLUG, "initial-spec"), - ).toBeNull(); - expect(store.getDocument("research", "initial-spec")).toMatchObject({ + expect(store.getDocument("initial-spec")).toMatchObject({ content: "Current draft", - workspaceSlug: "research", + collectionSlug: "research", }); - expect(store.getFeedback("research", "initial-spec").batches).toHaveLength( - 1, + expect(store.getDocumentResource("initial-spec")?.etag).not.toBe( + before?.etag, ); + expect(store.getFeedback("initial-spec").batches).toHaveLength(1); + expect(store.listDocuments({ collectionSlug: "research" })).toEqual([ + moved, + ]); + expect(store.listDocuments({ collectionSlug: null })).toEqual([]); + + expect(store.moveDocument("initial-spec", "research")).toEqual(moved); + + const returned = store.moveDocument("initial-spec", null); + + expect(returned.collectionSlug).toBeNull(); + expect(store.listDocuments({ collectionSlug: null })).toEqual([returned]); + expect(store.getFeedback("initial-spec").batches).toHaveLength(1); }); - it("blocks moving archived documents and destination slug collisions", () => { + it("blocks moving archived documents and moves into missing collections", () => { const store = createStore(); - store.createWorkspace("Research"); - publishStoreDocument( - store, - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - "Default", + publishStoreDocument(store, "initial-spec", "Default"); + + expect(() => store.moveDocument("initial-spec", "missing")).toThrow( + CollectionNotFoundError, ); - publishStoreDocument(store, "research", "initial-spec", "Research"); - expect(() => - store.moveDocument(DEFAULT_WORKSPACE_SLUG, "initial-spec", "research"), - ).toThrow(DocumentSlugConflictError); + store.archiveDocument("initial-spec"); + store.createCollection("Research"); + + expect(() => store.moveDocument("initial-spec", "research")).toThrow( + DocumentArchivedError, + ); + }); + + it("files a document on publish and refiles it without a new version", () => { + const store = createSequencedStore([ + "2026-07-19T10:00:00.000Z", + "2026-07-19T10:01:00.000Z", + "2026-07-19T10:02:00.000Z", + ]); + store.createCollection("Research"); + store.createCollection("Notes"); + + const created = publishStoreDocument(store, "initial-spec", "Draft", { + collectionSlug: "research", + }); + + expect(created).toMatchObject({ + collectionSlug: "research", + version: 1, + updatedAt: "2026-07-19T10:02:00.000Z", + }); + const beforeRefile = store.getDocumentResource("initial-spec"); + + const refiled = publishStoreDocument(store, "initial-spec", "Draft", { + collectionSlug: "notes", + }); + + expect(refiled).toMatchObject({ + collectionSlug: "notes", + version: 1, + updatedAt: "2026-07-19T10:02:00.000Z", + }); + expect(store.listDocumentVersions("initial-spec")).toHaveLength(1); + expect(store.getDocumentResource("initial-spec")?.etag).not.toBe( + beforeRefile?.etag, + ); + + const untouched = publishStoreDocument(store, "initial-spec", "Draft"); + + expect(untouched).toEqual(refiled); + expect(store.getDocument("initial-spec")?.collectionSlug).toBe("notes"); - store.archiveDocument(DEFAULT_WORKSPACE_SLUG, "initial-spec"); expect(() => - store.moveDocument(DEFAULT_WORKSPACE_SLUG, "initial-spec", "research"), - ).toThrow(DocumentArchivedError); + publishStoreDocument(store, "other-spec", "Draft", { + collectionSlug: "missing", + }), + ).toThrow(CollectionNotFoundError); }); - it("lists archived documents across workspaces and supports filtering", () => { - const timestamps = [ - new Date("2026-07-19T10:00:00.000Z"), - new Date("2026-07-19T10:01:00.000Z"), - new Date("2026-07-19T10:02:00.000Z"), - new Date("2026-07-19T10:03:00.000Z"), - new Date("2026-07-19T10:04:00.000Z"), - ]; - const store = createStore(":memory:", () => { - const timestamp = timestamps.shift(); - - if (!timestamp) { - throw new Error("Test clock was called unexpectedly."); - } - - return timestamp; + it("filters document listings by collection without descending", () => { + const store = createStore(); + store.createCollection("Research"); + store.createCollection("Notes", "research"); + publishStoreDocument(store, "root-doc", "Root"); + publishStoreDocument(store, "research-doc", "Research", { + collectionSlug: "research", }); - store.createWorkspace("Research"); - publishStoreDocument( - store, - DEFAULT_WORKSPACE_SLUG, - "shared-draft", - "Default", + publishStoreDocument(store, "notes-doc", "Notes", { + collectionSlug: "notes", + }); + + const slugsOf = (documents: Array<{ slug: string }>) => + documents.map(({ slug }) => slug).sort(); + + expect(slugsOf(store.listDocuments())).toEqual([ + "notes-doc", + "research-doc", + "root-doc", + ]); + expect(slugsOf(store.listDocuments({ collectionSlug: null }))).toEqual([ + "root-doc", + ]); + expect( + slugsOf(store.listDocuments({ collectionSlug: "research" })), + ).toEqual(["research-doc"]); + expect(slugsOf(store.listDocuments({ collectionSlug: "notes" }))).toEqual([ + "notes-doc", + ]); + expect(() => store.listDocuments({ collectionSlug: "missing" })).toThrow( + CollectionNotFoundError, ); - publishStoreDocument(store, "research", "shared-draft", "Research"); - store.archiveDocument(DEFAULT_WORKSPACE_SLUG, "shared-draft"); - store.archiveDocument("research", "shared-draft"); + }); + + it("lists archived documents everywhere and supports filtering", () => { + const store = createSequencedStore([ + "2026-07-19T10:00:00.000Z", + "2026-07-19T10:01:00.000Z", + "2026-07-19T10:02:00.000Z", + "2026-07-19T10:03:00.000Z", + "2026-07-19T10:04:00.000Z", + ]); + store.createCollection("Research"); + publishStoreDocument(store, "root-draft", "Default"); + publishStoreDocument(store, "research-draft", "Research", { + collectionSlug: "research", + }); + store.archiveDocument("root-draft"); + store.archiveDocument("research-draft"); expect(store.listArchivedDocuments()).toEqual([ expect.objectContaining({ - workspaceSlug: "research", - slug: "shared-draft", + collectionSlug: "research", + slug: "research-draft", }), expect.objectContaining({ - workspaceSlug: DEFAULT_WORKSPACE_SLUG, - slug: "shared-draft", + collectionSlug: null, + slug: "root-draft", }), ]); expect(store.listArchivedDocuments("research")).toEqual([ expect.objectContaining({ - workspaceSlug: "research", - slug: "shared-draft", + collectionSlug: "research", + slug: "research-draft", }), ]); }); it("only permanently deletes archived documents and cascades feedback", () => { const store = createStore(); - publishStoreDocument( - store, - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - "Current draft", - ); - store.addFeedback( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - feedbackSubmission, - ); + publishStoreDocument(store, "initial-spec", "Current draft"); + store.addFeedback("initial-spec", feedbackSubmission); - expect(() => - store.deleteArchivedDocument(DEFAULT_WORKSPACE_SLUG, "initial-spec"), - ).toThrow(DocumentNotArchivedError); + expect(() => store.deleteArchivedDocument("initial-spec")).toThrow( + DocumentNotArchivedError, + ); - store.archiveDocument(DEFAULT_WORKSPACE_SLUG, "initial-spec"); - store.deleteArchivedDocument(DEFAULT_WORKSPACE_SLUG, "initial-spec"); + store.archiveDocument("initial-spec"); + store.deleteArchivedDocument("initial-spec"); - expect( - store.getDocument(DEFAULT_WORKSPACE_SLUG, "initial-spec"), - ).toBeNull(); - expect(() => - store.getFeedback(DEFAULT_WORKSPACE_SLUG, "initial-spec"), - ).toThrow(DocumentNotFoundError); + expect(store.getDocument("initial-spec")).toBeNull(); + expect(() => store.getFeedback("initial-spec")).toThrow( + DocumentNotFoundError, + ); }); it("requires an archived document to be explicitly unarchived before publishing", () => { - const timestamps = [ - new Date("2026-07-19T10:00:00.000Z"), - new Date("2026-07-19T10:01:00.000Z"), - ]; - const store = createStore(":memory:", () => { - const timestamp = timestamps.shift(); - - if (!timestamp) { - throw new Error("Test clock was called unexpectedly."); - } - - return timestamp; - }); - publishStoreDocument( - store, - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - "Current draft", - ); - store.archiveDocument(DEFAULT_WORKSPACE_SLUG, "initial-spec"); + const store = createSequencedStore([ + "2026-07-19T10:00:00.000Z", + "2026-07-19T10:01:00.000Z", + ]); + publishStoreDocument(store, "initial-spec", "Current draft"); + store.archiveDocument("initial-spec"); expect(() => - publishStoreDocument( - store, - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - "Current draft", - ), + publishStoreDocument(store, "initial-spec", "Current draft"), ).toThrow(DocumentArchivedError); - expect(store.listDocuments(DEFAULT_WORKSPACE_SLUG)).toEqual([]); - expect(store.listDocuments(DEFAULT_WORKSPACE_SLUG, "archived")).toEqual([ + expect(store.listDocuments()).toEqual([]); + expect(store.listDocuments({ status: "archived" })).toEqual([ expect.objectContaining({ slug: "initial-spec", archivedAt: expect.any(String), @@ -422,45 +718,28 @@ describe("SqlitePenaStore", () => { it("stores ordered feedback batches with numeric IDs", () => { const store = createStore(); - publishStoreDocument( - store, - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - "Current draft", - ); - - const firstBatch = store.addFeedback( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - feedbackSubmission, - ); - const secondBatch = store.addFeedback( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - { - comments: [ - { - selectedText: "draft", - comment: "Use proposal instead.", - contextBefore: "Current ", - contextAfter: "", - }, - ], - }, - ); + publishStoreDocument(store, "initial-spec", "Current draft"); + + const firstBatch = store.addFeedback("initial-spec", feedbackSubmission); + const secondBatch = store.addFeedback("initial-spec", { + comments: [ + { + selectedText: "draft", + comment: "Use proposal instead.", + contextBefore: "Current ", + contextAfter: "", + }, + ], + }); expect(firstBatch.id).toBe(1); expect(secondBatch.id).toBe(2); - expect(store.getFeedback(DEFAULT_WORKSPACE_SLUG, "initial-spec")).toEqual({ + expect(store.getFeedback("initial-spec")).toEqual({ latestBatchId: secondBatch.id, batches: [firstBatch, secondBatch], }); expect( - store.listFeedbackReceiptsAfter( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - firstBatch.id, - ), + store.listFeedbackReceiptsAfter("initial-spec", firstBatch.id), ).toEqual([ { id: secondBatch.id, @@ -471,21 +750,12 @@ describe("SqlitePenaStore", () => { it("stores an instruction as part of its feedback batch", () => { const store = createStore(); - publishStoreDocument( - store, - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - "Current draft", - ); + publishStoreDocument(store, "initial-spec", "Current draft"); - const batch = store.addFeedback( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - { - instruction: "Keep the public API unchanged.", - comments: [], - }, - ); + const batch = store.addFeedback("initial-spec", { + instruction: "Keep the public API unchanged.", + comments: [], + }); expect(batch).toEqual({ id: 1, @@ -493,111 +763,58 @@ describe("SqlitePenaStore", () => { instruction: "Keep the public API unchanged.", comments: [], }); - expect( - store.getFeedback(DEFAULT_WORKSPACE_SLUG, "initial-spec").batches, - ).toEqual([batch]); - expect( - store.listFeedbackReceiptsAfter( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - 0, - ), - ).toEqual([ + expect(store.getFeedback("initial-spec").batches).toEqual([batch]); + expect(store.listFeedbackReceiptsAfter("initial-spec", 0)).toEqual([ { id: batch.id, submittedAt: batch.submittedAt }, ]); }); it("isolates feedback by document ID", () => { const store = createStore(); - publishStoreDocument( - store, - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - "Initial draft", - ); - publishStoreDocument( - store, - DEFAULT_WORKSPACE_SLUG, - "article-draft", - "Article draft", - ); - store.addFeedback( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - feedbackSubmission, - ); - store.addFeedback( - DEFAULT_WORKSPACE_SLUG, - "article-draft", - feedbackSubmission, - ); + publishStoreDocument(store, "initial-spec", "Initial draft"); + publishStoreDocument(store, "article-draft", "Article draft"); + store.addFeedback("initial-spec", feedbackSubmission); + store.addFeedback("article-draft", feedbackSubmission); - expect( - store.getFeedback(DEFAULT_WORKSPACE_SLUG, "initial-spec").batches, - ).toHaveLength(1); - expect( - store.getFeedback(DEFAULT_WORKSPACE_SLUG, "article-draft").batches, - ).toHaveLength(1); + expect(store.getFeedback("initial-spec").batches).toHaveLength(1); + expect(store.getFeedback("article-draft").batches).toHaveLength(1); }); it("preserves feedback and timestamps for identical content", () => { - const timestamps = [ - new Date("2026-07-19T10:00:00.000Z"), - new Date("2026-07-19T10:01:00.000Z"), - new Date("2026-07-19T10:02:00.000Z"), - ]; - const store = createStore(":memory:", () => { - const timestamp = timestamps.shift(); - - if (!timestamp) { - throw new Error("Test clock was called unexpectedly."); - } - - return timestamp; - }); + const store = createSequencedStore([ + "2026-07-19T10:00:00.000Z", + "2026-07-19T10:01:00.000Z", + "2026-07-19T10:02:00.000Z", + ]); const firstDocument = publishStoreDocument( store, - DEFAULT_WORKSPACE_SLUG, "initial-spec", "Current draft", ); - store.addFeedback( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - feedbackSubmission, - ); + store.addFeedback("initial-spec", feedbackSubmission); const repeatedDocument = publishStoreDocument( store, - DEFAULT_WORKSPACE_SLUG, "initial-spec", "Current draft", ); expect(repeatedDocument.updatedAt).toBe(firstDocument.updatedAt); expect(repeatedDocument.version).toBe(1); - expect( - store.getFeedback(DEFAULT_WORKSPACE_SLUG, "initial-spec").batches, - ).toHaveLength(1); + expect(store.getFeedback("initial-spec").batches).toHaveLength(1); }); it("increments the version when changed content replaces a document", () => { const store = createStore(); const firstDocument = publishStoreDocument( store, - DEFAULT_WORKSPACE_SLUG, "initial-spec", "Current draft", ); - store.addFeedback( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - feedbackSubmission, - ); + store.addFeedback("initial-spec", feedbackSubmission); const replacement = publishStoreDocument( store, - DEFAULT_WORKSPACE_SLUG, "initial-spec", "Replacement draft", ); @@ -605,7 +822,7 @@ describe("SqlitePenaStore", () => { expect(firstDocument.version).toBe(1); expect(replacement.content).toBe("Replacement draft"); expect(replacement.version).toBe(2); - expect(store.getFeedback(DEFAULT_WORKSPACE_SLUG, "initial-spec")).toEqual({ + expect(store.getFeedback("initial-spec")).toEqual({ latestBatchId: null, batches: [], }); @@ -614,19 +831,13 @@ describe("SqlitePenaStore", () => { it("creates a new version when only the explicit title changes", () => { const store = createStore(); const first = store.publishDocument( - DEFAULT_WORKSPACE_SLUG, "initial-spec", "Initial Specification", "Unchanged content", ); - store.addFeedback( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - feedbackSubmission, - ); + store.addFeedback("initial-spec", feedbackSubmission); const renamed = store.publishDocument( - DEFAULT_WORKSPACE_SLUG, "initial-spec", "Architecture Specification", "Unchanged content", @@ -640,13 +851,13 @@ describe("SqlitePenaStore", () => { }); expect( store - .listDocumentVersions(DEFAULT_WORKSPACE_SLUG, "initial-spec") + .listDocumentVersions("initial-spec") .map(({ title, version }) => ({ title, version })), ).toEqual([ { title: "Architecture Specification", version: 2 }, { title: "Initial Specification", version: 1 }, ]); - expect(store.getFeedback(DEFAULT_WORKSPACE_SLUG, "initial-spec")).toEqual({ + expect(store.getFeedback("initial-spec")).toEqual({ latestBatchId: null, batches: [], }); @@ -655,55 +866,29 @@ describe("SqlitePenaStore", () => { it("keeps immutable document history and restores an older title and content", () => { const databasePath = createDatabasePath(); const store = createStore(databasePath); - store.publishDocument( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - "First title", - "Version one", - ); - store.addFeedback( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - feedbackSubmission, - ); - store.publishDocument( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - "Second title", - "Version two", - ); - store.publishDocument( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - "Third title", - "Version three", - ); + store.publishDocument("initial-spec", "First title", "Version one"); + store.addFeedback("initial-spec", feedbackSubmission); + store.publishDocument("initial-spec", "Second title", "Version two"); + store.publishDocument("initial-spec", "Third title", "Version three"); expect( - store - .listDocumentVersions(DEFAULT_WORKSPACE_SLUG, "initial-spec") - .map(({ version }) => version), + store.listDocumentVersions("initial-spec").map(({ version }) => version), ).toEqual([3, 2, 1]); - expect( - store.getDocumentVersion(DEFAULT_WORKSPACE_SLUG, "initial-spec", 1), - ).toMatchObject({ + expect(store.getDocumentVersion("initial-spec", 1)).toMatchObject({ title: "First title", content: "Version one", version: 1, + collectionSlug: null, }); - const restored = store.restoreDocumentVersion( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - 1, - ); + const restored = store.restoreDocumentVersion("initial-spec", 1); expect(restored).toMatchObject({ title: "First title", content: "Version one", version: 4, }); - expect(store.getFeedback(DEFAULT_WORKSPACE_SLUG, "initial-spec")).toEqual({ + expect(store.getFeedback("initial-spec")).toEqual({ latestBatchId: null, batches: [], }); @@ -726,84 +911,43 @@ describe("SqlitePenaStore", () => { it("treats restoring identical content as a no-op", () => { const store = createStore(); - publishStoreDocument(store, DEFAULT_WORKSPACE_SLUG, "initial-spec", "Same"); + publishStoreDocument(store, "initial-spec", "Same"); - const restored = store.restoreDocumentVersion( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - 1, - ); + const restored = store.restoreDocumentVersion("initial-spec", 1); expect(restored.version).toBe(1); - expect( - store.listDocumentVersions(DEFAULT_WORKSPACE_SLUG, "initial-spec"), - ).toHaveLength(1); + expect(store.listDocumentVersions("initial-spec")).toHaveLength(1); }); it("does not restore a historical version while the document is archived", () => { const store = createStore(); - publishStoreDocument( - store, - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - "First", - ); - publishStoreDocument( - store, - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - "Second", - ); - store.archiveDocument(DEFAULT_WORKSPACE_SLUG, "initial-spec"); + publishStoreDocument(store, "initial-spec", "First"); + publishStoreDocument(store, "initial-spec", "Second"); + store.archiveDocument("initial-spec"); - expect(() => - store.restoreDocumentVersion(DEFAULT_WORKSPACE_SLUG, "initial-spec", 1), - ).toThrow(DocumentArchivedError); + expect(() => store.restoreDocumentVersion("initial-spec", 1)).toThrow( + DocumentArchivedError, + ); }); it("rejects stale document state tokens after lifecycle changes", () => { const store = createStore(); - publishStoreDocument( - store, - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - "Current", - ); - const resource = store.getDocumentResource( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - ); + publishStoreDocument(store, "initial-spec", "Current"); + const resource = store.getDocumentResource("initial-spec"); expect(resource).not.toBeNull(); - store.archiveDocument( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - resource?.etag, - ); + store.archiveDocument("initial-spec", resource?.etag); expect(() => - store.unarchiveDocument( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - resource?.etag, - ), + store.unarchiveDocument("initial-spec", resource?.etag), ).toThrow("The document changed after it was read."); }); it("rolls back feedback deletion when document replacement fails", () => { const databasePath = createDatabasePath(); const store = createStore(databasePath); - publishStoreDocument( - store, - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - "Current draft", - ); - const batch = store.addFeedback( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - feedbackSubmission, - ); + publishStoreDocument(store, "initial-spec", "Current draft"); + const batch = store.addFeedback("initial-spec", feedbackSubmission); const triggerConnection = new Database(databasePath); triggerConnection.exec(` CREATE TRIGGER reject_document_update @@ -815,20 +959,11 @@ describe("SqlitePenaStore", () => { triggerConnection.close(); expect(() => - publishStoreDocument( - store, - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - "Replacement draft", - ), + publishStoreDocument(store, "initial-spec", "Replacement draft"), ).toThrow("forced document update failure"); - expect( - store.getDocument(DEFAULT_WORKSPACE_SLUG, "initial-spec")?.content, - ).toBe("Current draft"); - expect( - store.getDocument(DEFAULT_WORKSPACE_SLUG, "initial-spec")?.version, - ).toBe(1); - expect(store.getFeedback(DEFAULT_WORKSPACE_SLUG, "initial-spec")).toEqual({ + expect(store.getDocument("initial-spec")?.content).toBe("Current draft"); + expect(store.getDocument("initial-spec")?.version).toBe(1); + expect(store.getFeedback("initial-spec")).toEqual({ latestBatchId: batch.id, batches: [batch], }); @@ -839,26 +974,17 @@ describe("SqlitePenaStore", () => { const firstStore = createStore(databasePath); const document = publishStoreDocument( firstStore, - DEFAULT_WORKSPACE_SLUG, "initial-spec", "Persistent draft", ); - const batch = firstStore.addFeedback( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - feedbackSubmission, - ); + const batch = firstStore.addFeedback("initial-spec", feedbackSubmission); firstStore.close(); stores.delete(firstStore); const reopenedStore = createStore(databasePath); - expect( - reopenedStore.getDocument(DEFAULT_WORKSPACE_SLUG, "initial-spec"), - ).toEqual(document); - expect( - reopenedStore.getFeedback(DEFAULT_WORKSPACE_SLUG, "initial-spec"), - ).toEqual({ + expect(reopenedStore.getDocument("initial-spec")).toEqual(document); + expect(reopenedStore.getFeedback("initial-spec")).toEqual({ latestBatchId: batch.id, batches: [batch], }); @@ -867,21 +993,15 @@ describe("SqlitePenaStore", () => { it("does not rerun migrations when an initialized database is reopened", () => { const databasePath = createDatabasePath(); const firstStore = createStore(databasePath); - publishStoreDocument( - firstStore, - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - "Persistent draft", - ); + publishStoreDocument(firstStore, "initial-spec", "Persistent draft"); firstStore.close(); stores.delete(firstStore); const reopenedStore = createStore(databasePath); - expect( - reopenedStore.getDocument(DEFAULT_WORKSPACE_SLUG, "initial-spec") - ?.content, - ).toBe("Persistent draft"); + expect(reopenedStore.getDocument("initial-spec")?.content).toBe( + "Persistent draft", + ); }); it("migrates existing documents to version 1", () => { @@ -930,25 +1050,17 @@ describe("SqlitePenaStore", () => { const store = createStore(databasePath); - expect(store.getDocument(DEFAULT_WORKSPACE_SLUG, "initial-spec")).toEqual({ - workspaceSlug: "default", + expect(store.getDocument("initial-spec")).toEqual({ slug: "initial-spec", + collectionSlug: null, title: "Initial Spec", content: "Existing draft", version: 1, updatedAt: "2026-07-19T10:00:00.000Z", archivedAt: null, }); - expect(store.listWorkspaces()).toEqual([ - expect.objectContaining({ - slug: "default", - name: "Default", - documentCount: 1, - }), - ]); - expect( - store.getFeedback(DEFAULT_WORKSPACE_SLUG, "initial-spec").batches, - ).toEqual([ + expect(store.listCollections()).toEqual([]); + expect(store.getFeedback("initial-spec").batches).toEqual([ expect.objectContaining({ submittedAt: "2026-07-19T10:01:00.000Z", comments: [expect.objectContaining({ comment: "Keep this." })], @@ -1010,35 +1122,33 @@ describe("SqlitePenaStore", () => { const store = createStore(databasePath); - expect( - store.listDocumentVersions(DEFAULT_WORKSPACE_SLUG, "initial-spec"), - ).toEqual([ + expect(store.listDocumentVersions("initial-spec")).toEqual([ { - workspaceSlug: "default", slug: "initial-spec", + collectionSlug: null, title: "Initial Spec", version: 7, updatedAt: "2026-07-19T10:00:00.000Z", }, ]); - expect( - store.getFeedback(DEFAULT_WORKSPACE_SLUG, "initial-spec").batches, - ).toHaveLength(1); + expect(store.getFeedback("initial-spec").batches).toHaveLength(1); }); it("migrates numeric development state revisions to opaque ETags", () => { const databasePath = createDatabasePath(); - const firstStore = createStore(databasePath); - publishStoreDocument( - firstStore, - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - "Existing version history", - ); - firstStore.close(); - stores.delete(firstStore); - - const database = new Database(databasePath); + const database = createSchema9Database(databasePath); + insertSchema9Document(database, { + id: 1, + workspaceId: 1, + slug: "initial-spec", + versions: [ + { + title: "Initial Spec", + content: "Existing version history", + publishedAt: "2026-07-19T10:00:00.000Z", + }, + ], + }); database.exec(` ALTER TABLE documents DROP COLUMN state_token; ALTER TABLE documents @@ -1049,10 +1159,7 @@ describe("SqlitePenaStore", () => { database.close(); const migratedStore = createStore(databasePath); - const resource = migratedStore.getDocumentResource( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - ); + const resource = migratedStore.getDocumentResource("initial-spec"); expect(resource?.etag).toMatch(/^"pena-[0-9a-f]{32}"$/); expect(resource?.value.content).toBe("Existing version history"); @@ -1060,39 +1167,34 @@ describe("SqlitePenaStore", () => { it("moves each legacy leading H1 into its historical version title", () => { const databasePath = createDatabasePath(); - const firstStore = createStore(databasePath); - firstStore.publishDocument( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - "Legacy Fallback", - "# First title\n\nFirst body.", - ); - firstStore.publishDocument( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - "Legacy Fallback", - "Second title\n===\n\nSecond body.", - ); - firstStore.close(); - stores.delete(firstStore); - - const database = new Database(databasePath); + const database = createSchema9Database(databasePath); + insertSchema9Document(database, { + id: 1, + workspaceId: 1, + slug: "initial-spec", + versions: [ + { + title: "Initial Spec", + content: "# First title\n\nFirst body.", + publishedAt: "2026-07-19T10:00:00.000Z", + }, + { + title: "Initial Spec", + content: "Second title\n===\n\nSecond body.", + publishedAt: "2026-07-19T10:01:00.000Z", + }, + ], + }); const previousState = database .prepare<[], { state_token: string }>( "SELECT state_token FROM documents WHERE slug = 'initial-spec'", ) .get(); - database.exec(` - UPDATE document_versions SET title = 'Initial Spec'; - PRAGMA user_version = 7; - `); + database.pragma("user_version = 7"); database.close(); const migratedStore = createStore(databasePath); - const current = migratedStore.getDocumentResource( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - ); + const current = migratedStore.getDocumentResource("initial-spec"); expect(current?.value).toMatchObject({ title: "Second title", @@ -1100,22 +1202,11 @@ describe("SqlitePenaStore", () => { version: 2, }); expect(current?.etag).not.toBe(`"pena-${previousState?.state_token}"`); - expect( - migratedStore.listDocumentVersions( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - ), - ).toEqual([ + expect(migratedStore.listDocumentVersions("initial-spec")).toEqual([ expect.objectContaining({ title: "Second title", version: 2 }), expect.objectContaining({ title: "First title", version: 1 }), ]); - expect( - migratedStore.getDocumentVersion( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - 1, - ), - ).toMatchObject({ + expect(migratedStore.getDocumentVersion("initial-spec", 1)).toMatchObject({ title: "First title", content: "First body.", }); @@ -1124,7 +1215,7 @@ describe("SqlitePenaStore", () => { it("rejects databases created by a newer schema version", () => { const databasePath = createDatabasePath(); const database = new Database(databasePath); - database.pragma("user_version = 10"); + database.pragma("user_version = 11"); database.close(); expect(() => createStore(databasePath)).toThrow( @@ -1134,22 +1225,20 @@ describe("SqlitePenaStore", () => { it("adds nullable instructions to existing feedback batches", () => { const databasePath = createDatabasePath(); - const firstStore = createStore(databasePath); - publishStoreDocument( - firstStore, - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - "Current draft", - ); - const existingBatch = firstStore.addFeedback( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - feedbackSubmission, - ); - firstStore.close(); - stores.delete(firstStore); - - const database = new Database(databasePath); + const database = createSchema9Database(databasePath); + insertSchema9Document(database, { + id: 1, + workspaceId: 1, + slug: "initial-spec", + versions: [ + { + title: "Initial Spec", + content: "Current draft", + publishedAt: "2026-07-19T10:00:00.000Z", + }, + ], + feedbackOnLatest: ["Change this."], + }); database.exec(` ALTER TABLE feedback_batches DROP COLUMN instruction_text; PRAGMA user_version = 8; @@ -1158,17 +1247,20 @@ describe("SqlitePenaStore", () => { const migratedStore = createStore(databasePath); - expect( - migratedStore.getFeedback(DEFAULT_WORKSPACE_SLUG, "initial-spec"), - ).toEqual({ - latestBatchId: existingBatch.id, - batches: [existingBatch], + expect(migratedStore.getFeedback("initial-spec")).toEqual({ + latestBatchId: 1, + batches: [ + expect.objectContaining({ + id: 1, + comments: [expect.objectContaining({ comment: "Change this." })], + }), + ], }); const inspectionDatabase = new Database(databasePath); expect( inspectionDatabase.pragma("user_version", { simple: true }), - ).toBe(9); + ).toBe(10); expect( ( inspectionDatabase.pragma("table_info(feedback_batches)") as Array<{ @@ -1179,6 +1271,191 @@ describe("SqlitePenaStore", () => { inspectionDatabase.close(); }); + it("turns workspaces into root collections and default documents into root documents", () => { + const databasePath = createDatabasePath(); + const database = createSchema9Database(databasePath, [ + { id: 1, slug: "default", name: "Default" }, + { id: 2, slug: "research", name: "Research" }, + ]); + insertSchema9Document(database, { + id: 1, + workspaceId: 1, + slug: "default-doc", + versions: [ + { + title: "Default Doc", + content: "Default draft", + publishedAt: "2026-07-19T10:00:00.000Z", + }, + ], + feedbackOnLatest: ["Keep default."], + }); + insertSchema9Document(database, { + id: 2, + workspaceId: 2, + slug: "research-doc", + versions: [ + { + title: "Research Doc", + content: "Research draft", + publishedAt: "2026-07-19T10:00:00.000Z", + }, + ], + feedbackOnLatest: ["Keep research."], + }); + const previousTokens = database + .prepare<[], { slug: string; state_token: string }>( + "SELECT slug, state_token FROM documents ORDER BY id", + ) + .all(); + database.close(); + + const store = createStore(databasePath); + + expect(store.getDocument("default-doc")).toMatchObject({ + collectionSlug: null, + content: "Default draft", + version: 1, + }); + expect(store.getDocument("research-doc")).toMatchObject({ + collectionSlug: "research", + content: "Research draft", + version: 1, + }); + expect(store.listCollections()).toEqual([ + { + slug: "research", + name: "Research", + parentSlug: null, + createdAt: "2026-07-19T09:00:00.000Z", + updatedAt: "2026-07-19T09:00:00.000Z", + documentCount: 1, + childCount: 0, + }, + ]); + expect(store.getFeedback("default-doc").batches).toEqual([ + expect.objectContaining({ + comments: [expect.objectContaining({ comment: "Keep default." })], + }), + ]); + expect(store.getFeedback("research-doc").batches).toEqual([ + expect.objectContaining({ + comments: [expect.objectContaining({ comment: "Keep research." })], + }), + ]); + + // The document that left the default workspace is a different resource + // now, so its ETag rotated; the one that stayed put kept its ETag. + const defaultResource = store.getDocumentResource("default-doc"); + const researchResource = store.getDocumentResource("research-doc"); + expect(defaultResource?.etag).not.toBe( + `"pena-${previousTokens[0]?.state_token}"`, + ); + expect(researchResource?.etag).toBe( + `"pena-${previousTokens[1]?.state_token}"`, + ); + + // Writing through every foreign key proves the rebuilt documents table is + // what document_versions and feedback_batches point at. + for (const slug of ["default-doc", "research-doc"]) { + const resource = store.getDocumentResource(slug); + const republished = store.publishDocument( + slug, + formatTestTitle(slug), + "Revised draft", + { condition: { kind: "match", etag: resource?.etag ?? "" } }, + ); + + expect(republished.version).toBe(2); + expect(store.listDocumentVersions(slug)).toHaveLength(2); + + const batch = store.addFeedback( + slug, + feedbackSubmission, + store.getDocumentResource(slug)?.etag, + ); + + expect(store.getFeedback(slug)).toEqual({ + latestBatchId: batch.id, + batches: [batch], + }); + } + + store.archiveDocument("default-doc", store.getDocumentResource("default-doc")?.etag); + store.deleteArchivedDocument("default-doc"); + expect(store.getDocument("default-doc")).toBeNull(); + + const inspectionDatabase = new Database(databasePath); + expect( + inspectionDatabase.pragma("user_version", { simple: true }), + ).toBe(10); + expect( + inspectionDatabase + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'workspaces'", + ) + .get(), + ).toBeUndefined(); + expect(inspectionDatabase.pragma("foreign_key_check")).toEqual([]); + inspectionDatabase.close(); + }); + + it("refuses to migrate when a document slug exists in more than one workspace", () => { + const databasePath = createDatabasePath(); + const database = createSchema9Database(databasePath, [ + { id: 1, slug: "default", name: "Default" }, + { id: 2, slug: "research", name: "Research" }, + ]); + insertSchema9Document(database, { + id: 1, + workspaceId: 1, + slug: "shared-draft", + versions: [ + { + title: "Shared Draft", + content: "Default", + publishedAt: "2026-07-19T10:00:00.000Z", + }, + ], + }); + insertSchema9Document(database, { + id: 2, + workspaceId: 2, + slug: "shared-draft", + versions: [ + { + title: "Shared Draft", + content: "Research", + publishedAt: "2026-07-19T10:00:00.000Z", + }, + ], + }); + database.close(); + + expect(() => createStore(databasePath)).toThrow( + DocumentSlugConflictMigrationError, + ); + expect(() => createStore(databasePath)).toThrow(/shared-draft/); + + const inspectionDatabase = new Database(databasePath); + expect( + inspectionDatabase.pragma("user_version", { simple: true }), + ).toBe(9); + expect( + inspectionDatabase + .prepare("SELECT COUNT(*) AS count FROM workspaces") + .get(), + ).toEqual({ count: 2 }); + expect( + inspectionDatabase + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'collections'", + ) + .get(), + ).toBeUndefined(); + inspectionDatabase.close(); + }); + it("rolls back a failed migration", () => { const databasePath = createDatabasePath(); const database = new Database(databasePath); @@ -1213,17 +1490,8 @@ describe("SqlitePenaStore", () => { it("rejects invalid persisted comment data", () => { const databasePath = createDatabasePath(); const firstStore = createStore(databasePath); - publishStoreDocument( - firstStore, - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - "Persistent draft", - ); - firstStore.addFeedback( - DEFAULT_WORKSPACE_SLUG, - "initial-spec", - feedbackSubmission, - ); + publishStoreDocument(firstStore, "initial-spec", "Persistent draft"); + firstStore.addFeedback("initial-spec", feedbackSubmission); firstStore.close(); stores.delete(firstStore); @@ -1234,23 +1502,19 @@ describe("SqlitePenaStore", () => { database.close(); const reopenedStore = createStore(databasePath); - expect(() => - reopenedStore.getFeedback(DEFAULT_WORKSPACE_SLUG, "initial-spec"), - ).toThrow(PersistedDataError); + expect(() => reopenedStore.getFeedback("initial-spec")).toThrow( + PersistedDataError, + ); }); it("rejects feedback operations for a missing document", () => { const store = createStore(); expect(() => - store.addFeedback( - DEFAULT_WORKSPACE_SLUG, - "missing-document", - feedbackSubmission, - ), - ).toThrow(DocumentNotFoundError); - expect(() => - store.getFeedback(DEFAULT_WORKSPACE_SLUG, "missing-document"), + store.addFeedback("missing-document", feedbackSubmission), ).toThrow(DocumentNotFoundError); + expect(() => store.getFeedback("missing-document")).toThrow( + DocumentNotFoundError, + ); }); }); diff --git a/apps/server/src/storage/sqlite-pena-store.ts b/apps/server/src/storage/sqlite-pena-store.ts index 10129b7..270b558 100644 --- a/apps/server/src/storage/sqlite-pena-store.ts +++ b/apps/server/src/storage/sqlite-pena-store.ts @@ -3,6 +3,9 @@ import { dirname } from "node:path"; import { randomUUID } from "node:crypto"; import { + CollectionNameSchema, + CollectionSchema, + CollectionSummarySchema, DocumentSchema, DocumentSummarySchema, DocumentVersionSchema, @@ -11,11 +14,9 @@ import { FeedbackReceiptSchema, FeedbackResponseSchema, FeedbackSubmissionSchema, - WorkspaceNameSchema, - WorkspaceSchema, - WorkspaceSummarySchema, + type Collection, + type CollectionSummary, type DocumentSummary, - type DocumentStatus, type DocumentVersion, type DocumentVersionSummary, type FeedbackBatch, @@ -23,8 +24,6 @@ import { type FeedbackResponse, type FeedbackSubmission, type PenaDocument, - type Workspace, - type WorkspaceSummary, } from "@pena/contracts"; import Database from "better-sqlite3"; @@ -33,51 +32,55 @@ import { readDocumentExcerpt, } from "./document-preview.js"; import { - DefaultWorkspaceProtectedError, + CollectionCycleError, + CollectionNameConflictError, + CollectionNameInvalidError, + CollectionNotEmptyError, + CollectionNotFoundError, + CollectionSlugConflictError, DocumentArchivedError, DocumentNotArchivedError, DocumentNotFoundError, DocumentPreconditionFailedError, - DocumentSlugConflictError, + DocumentSlugConflictMigrationError, DocumentVersionNotFoundError, FeedbackPreconditionFailedError, PersistedDataError, UnsupportedSchemaVersionError, - WorkspaceNameConflictError, - WorkspaceNameInvalidError, - WorkspaceNotEmptyError, - WorkspaceNotFoundError, - WorkspaceSlugConflictError, + type CollectionUpdate, + type DocumentListFilter, + type DocumentPublishOptions, type DocumentResource, - type DocumentWriteCondition, type PenaStore, } from "./pena-store.js"; -export const DEFAULT_WORKSPACE_SLUG = "default"; - -const CURRENT_SCHEMA_VERSION = 9; +const CURRENT_SCHEMA_VERSION = 10; +const RESERVED_COLLECTION_SLUG = "root"; const DEFAULT_BUSY_TIMEOUT_MS = 5_000; interface SqlitePenaStoreOptions { clock?: () => Date; } -interface WorkspaceRow { +interface CollectionRow { id: number; slug: string; name: string; + parent_id: number | null; + parent_slug: string | null; created_at: string; updated_at: string; } -interface WorkspaceSummaryRow extends WorkspaceRow { +interface CollectionSummaryRow extends CollectionRow { document_count: number; + child_count: number; } interface DocumentRow { id: number; - workspace_id: number; - workspace_slug: string; + collection_id: number | null; + collection_slug: string | null; slug: string; version_id: number; title: string; @@ -90,11 +93,11 @@ interface DocumentRow { type DocumentSummaryRow = Omit< DocumentRow, - "id" | "workspace_id" | "version_id" | "state_token" + "id" | "collection_id" | "version_id" | "state_token" >; interface DocumentVersionRow { - workspace_slug: string; + collection_slug: string | null; slug: string; title: string; content: string; @@ -111,16 +114,50 @@ interface FeedbackBatchRow { type FeedbackReceiptRow = Pick; +/** A resolved publish destination: `undefined` keeps the current one. */ +type CollectionTarget = CollectionRow | null | undefined; + +const DOCUMENT_ROW_SELECT = ` + SELECT + documents.id, + documents.collection_id, + collections.slug AS collection_slug, + documents.slug, + current_version.id AS version_id, + current_version.title, + current_version.content, + current_version.version, + current_version.published_at AS updated_at, + documents.archived_at, + documents.state_token + FROM documents + LEFT JOIN collections ON collections.id = documents.collection_id + JOIN document_versions AS current_version + ON current_version.document_id = documents.id + AND current_version.version = documents.current_version +`; + +const COLLECTION_ROW_SELECT = ` + SELECT + collections.id, + collections.slug, + collections.name, + collections.parent_id, + parent.slug AS parent_slug, + collections.created_at, + collections.updated_at + FROM collections + LEFT JOIN collections AS parent ON parent.id = collections.parent_id +`; + export class SqlitePenaStore implements PenaStore { private readonly database: Database.Database; private readonly clock: () => Date; private readonly publishDocumentTransaction: ( - workspaceSlug: string, slug: string, title: string, content: string, - condition?: DocumentWriteCondition, - expectedLatestFeedbackBatchId?: number, + options: DocumentPublishOptions, ) => PenaDocument; constructor( @@ -146,27 +183,30 @@ export class SqlitePenaStore implements PenaStore { this.publishDocumentTransaction = this.database.transaction( ( - workspaceSlug: string, slug: string, title: string, content: string, - condition?: DocumentWriteCondition, - expectedLatestFeedbackBatchId?: number, + { + condition, + expectedLatestFeedbackBatchId, + collectionSlug, + }: DocumentPublishOptions, ): PenaDocument => { - const workspace = this.requireWorkspaceRow(workspaceSlug); - const currentDocument = this.getDocumentRow(workspace.id, slug); + const target = this.resolveCollectionTarget(collectionSlug); + const currentDocument = this.getDocumentRow(slug); if (!currentDocument) { if (condition?.kind === "match") { throw new DocumentPreconditionFailedError(0); } + const collection = target ?? null; const updatedAt = this.clock().toISOString(); const result = this.database - .prepare<[number, string, string]>( + .prepare<[number | null, string, string]>( ` INSERT INTO documents ( - workspace_id, + collection_id, slug, current_version, state_token @@ -174,7 +214,7 @@ export class SqlitePenaStore implements PenaStore { VALUES (?, ?, 1, ?) `, ) - .run(workspace.id, slug, randomUUID()); + .run(collection?.id ?? null, slug, randomUUID()); const documentId = Number(result.lastInsertRowid); this.database .prepare<[number, string, string, string]>( @@ -192,8 +232,8 @@ export class SqlitePenaStore implements PenaStore { .run(documentId, title, content, updatedAt); return DocumentSchema.parse({ - workspaceSlug, slug, + collectionSlug: collection?.slug ?? null, title, content, version: 1, @@ -209,7 +249,7 @@ export class SqlitePenaStore implements PenaStore { this.assertEtag(currentDocument, condition?.etag); if (currentDocument.archived_at !== null) { - throw new DocumentArchivedError(workspaceSlug, slug); + throw new DocumentArchivedError(slug); } this.assertLatestFeedbackBatchId( @@ -217,44 +257,55 @@ export class SqlitePenaStore implements PenaStore { expectedLatestFeedbackBatchId, ); - if ( - currentDocument.title === title && - currentDocument.content === content - ) { + const contentChanged = + currentDocument.title !== title || + currentDocument.content !== content; + const nextCollectionId = + target === undefined + ? currentDocument.collection_id + : (target?.id ?? null); + const collectionChanged = + nextCollectionId !== currentDocument.collection_id; + + if (!contentChanged && !collectionChanged) { return toDocument(currentDocument); } - const updatedAt = this.clock().toISOString(); - this.database - .prepare<[number, number, string, string, string]>( - ` - INSERT INTO document_versions ( - document_id, - version, - title, - content, - published_at - ) - VALUES (?, ?, ?, ?, ?) - `, - ) - .run( - currentDocument.id, - currentDocument.version + 1, - title, - content, - updatedAt, - ); + const nextVersion = contentChanged + ? currentDocument.version + 1 + : currentDocument.version; + const updatedAt = contentChanged + ? this.clock().toISOString() + : currentDocument.updated_at; + + if (contentChanged) { + this.database + .prepare<[number, number, string, string, string]>( + ` + INSERT INTO document_versions ( + document_id, + version, + title, + content, + published_at + ) + VALUES (?, ?, ?, ?, ?) + `, + ) + .run(currentDocument.id, nextVersion, title, content, updatedAt); + } + const update = this.database - .prepare<[number, string, number, string]>( + .prepare<[number, number | null, string, number, string]>( ` UPDATE documents - SET current_version = ?, state_token = ? + SET current_version = ?, collection_id = ?, state_token = ? WHERE id = ? AND state_token = ? `, ) .run( - currentDocument.version + 1, + nextVersion, + nextCollectionId, randomUUID(), currentDocument.id, currentDocument.state_token, @@ -265,11 +316,14 @@ export class SqlitePenaStore implements PenaStore { } return DocumentSchema.parse({ - workspaceSlug, slug, + collectionSlug: + target === undefined + ? currentDocument.collection_slug + : (target?.slug ?? null), title, content, - version: currentDocument.version + 1, + version: nextVersion, updatedAt, archivedAt: null, }); @@ -277,163 +331,180 @@ export class SqlitePenaStore implements PenaStore { ); } - listWorkspaces(): WorkspaceSummary[] { + listCollections(): CollectionSummary[] { const rows = this.database - .prepare<[], WorkspaceSummaryRow>( + .prepare<[], CollectionSummaryRow>( ` SELECT - workspaces.id, - workspaces.slug, - workspaces.name, - workspaces.created_at, - workspaces.updated_at, - COUNT(documents.id) AS document_count - FROM workspaces - LEFT JOIN documents ON documents.workspace_id = workspaces.id - GROUP BY workspaces.id - ORDER BY - CASE WHEN workspaces.slug = 'default' THEN 0 ELSE 1 END, - workspaces.name COLLATE NOCASE, - workspaces.id + collections.id, + collections.slug, + collections.name, + collections.parent_id, + parent.slug AS parent_slug, + collections.created_at, + collections.updated_at, + ( + SELECT COUNT(*) + FROM documents + WHERE documents.collection_id = collections.id + ) AS document_count, + ( + SELECT COUNT(*) + FROM collections AS child + WHERE child.parent_id = collections.id + ) AS child_count + FROM collections + LEFT JOIN collections AS parent ON parent.id = collections.parent_id + ORDER BY collections.name COLLATE NOCASE, collections.id `, ) .all(); - return rows.map(toWorkspaceSummary); + return rows.map(toCollectionSummary); } - createWorkspace(name: string): Workspace { - const parsedName = WorkspaceNameSchema.parse(name); - const slug = slugifyWorkspaceName(parsedName); + createCollection(name: string, parentSlug: string | null = null): Collection { + const parsedName = CollectionNameSchema.parse(name); + const slug = slugifyCollectionName(parsedName); - if (!slug) { - throw new WorkspaceNameInvalidError(); + // "root" is how the document list filter names documents outside every + // collection, so no collection may claim that slug. + if (!slug || slug === RESERVED_COLLECTION_SLUG) { + throw new CollectionNameInvalidError(); } - if (this.getWorkspaceRow(slug)) { - throw new WorkspaceSlugConflictError(slug); + const parent = parentSlug === null ? null : this.requireCollectionRow(parentSlug); + + if (this.getCollectionRow(slug)) { + throw new CollectionSlugConflictError(slug); } - if (this.getWorkspaceRowByName(parsedName)) { - throw new WorkspaceNameConflictError(parsedName); + if (this.getCollectionRowByName(parsedName)) { + throw new CollectionNameConflictError(parsedName); } const timestamp = this.clock().toISOString(); this.database - .prepare<[string, string, string, string]>( + .prepare<[string, string, number | null, string, string]>( ` - INSERT INTO workspaces (slug, name, created_at, updated_at) - VALUES (?, ?, ?, ?) + INSERT INTO collections (slug, name, parent_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) `, ) - .run(slug, parsedName, timestamp, timestamp); + .run(slug, parsedName, parent?.id ?? null, timestamp, timestamp); - return WorkspaceSchema.parse({ + return CollectionSchema.parse({ slug, name: parsedName, + parentSlug: parent?.slug ?? null, createdAt: timestamp, updatedAt: timestamp, }); } - renameWorkspace(slug: string, name: string): Workspace { - const workspace = this.requireWorkspaceRow(slug); - - if (slug === DEFAULT_WORKSPACE_SLUG) { - throw new DefaultWorkspaceProtectedError("rename"); - } + updateCollection(slug: string, update: CollectionUpdate): Collection { + return this.database.transaction(() => { + const collection = this.requireCollectionRow(slug); + let nextName = collection.name; + let nextParent: CollectionRow | null = collection.parent_id === null + ? null + : this.requireCollectionRowById(collection.parent_id); + + if (update.name !== undefined) { + nextName = CollectionNameSchema.parse(update.name); + const conflicting = this.getCollectionRowByName(nextName); + + if (conflicting && conflicting.id !== collection.id) { + throw new CollectionNameConflictError(nextName); + } + } - const parsedName = WorkspaceNameSchema.parse(name); - const conflictingWorkspace = this.getWorkspaceRowByName(parsedName); + if (update.parentSlug !== undefined) { + nextParent = + update.parentSlug === null + ? null + : this.requireCollectionRow(update.parentSlug); + this.assertNoCycle(collection, nextParent); + } - if (conflictingWorkspace && conflictingWorkspace.id !== workspace.id) { - throw new WorkspaceNameConflictError(parsedName); - } + const nextParentId = nextParent?.id ?? null; - if (workspace.name === parsedName) { - return toWorkspace(workspace); - } + if ( + nextName === collection.name && + nextParentId === collection.parent_id + ) { + return toCollection(collection); + } - const updatedAt = this.clock().toISOString(); - this.database - .prepare<[string, string, number]>( - "UPDATE workspaces SET name = ?, updated_at = ? WHERE id = ?", - ) - .run(parsedName, updatedAt, workspace.id); + const updatedAt = this.clock().toISOString(); + this.database + .prepare<[string, number | null, string, number]>( + ` + UPDATE collections + SET name = ?, parent_id = ?, updated_at = ? + WHERE id = ? + `, + ) + .run(nextName, nextParentId, updatedAt, collection.id); - return WorkspaceSchema.parse({ - slug, - name: parsedName, - createdAt: workspace.created_at, - updatedAt, - }); + return CollectionSchema.parse({ + slug, + name: nextName, + parentSlug: nextParent?.slug ?? null, + createdAt: collection.created_at, + updatedAt, + }); + })(); } - deleteWorkspace(slug: string): void { - const workspace = this.requireWorkspaceRow(slug); - - if (slug === DEFAULT_WORKSPACE_SLUG) { - throw new DefaultWorkspaceProtectedError("delete"); - } - - const document = this.database - .prepare<[number], { id: number }>( - "SELECT id FROM documents WHERE workspace_id = ? LIMIT 1", + deleteCollection(slug: string): void { + const collection = this.requireCollectionRow(slug); + const occupant = this.database + .prepare<[number, number], { id: number }>( + ` + SELECT id FROM documents WHERE collection_id = ? + UNION ALL + SELECT id FROM collections WHERE parent_id = ? + LIMIT 1 + `, ) - .get(workspace.id); + .get(collection.id, collection.id); - if (document) { - throw new WorkspaceNotEmptyError(slug); + if (occupant) { + throw new CollectionNotEmptyError(slug); } this.database - .prepare<[number]>("DELETE FROM workspaces WHERE id = ?") - .run(workspace.id); + .prepare<[number]>("DELETE FROM collections WHERE id = ?") + .run(collection.id); } publishDocument( - workspaceSlug: string, slug: string, title: string, content: string, - condition?: DocumentWriteCondition, - expectedLatestFeedbackBatchId?: number, + options: DocumentPublishOptions = {}, ): PenaDocument { - return this.publishDocumentTransaction( - workspaceSlug, - slug, - title, - content, - condition, - expectedLatestFeedbackBatchId, - ); + return this.publishDocumentTransaction(slug, title, content, options); } - getDocument(workspaceSlug: string, slug: string): PenaDocument | null { - const workspace = this.requireWorkspaceRow(workspaceSlug); - const row = this.getDocumentRow(workspace.id, slug); + getDocument(slug: string): PenaDocument | null { + const row = this.getDocumentRow(slug); return row ? toDocument(row) : null; } - getDocumentResource( - workspaceSlug: string, - slug: string, - ): DocumentResource | null { - const workspace = this.requireWorkspaceRow(workspaceSlug); - const row = this.getDocumentRow(workspace.id, slug); + getDocumentResource(slug: string): DocumentResource | null { + const row = this.getDocumentRow(slug); return row ? { value: toDocument(row), etag: documentEtag(row) } : null; } - listDocumentVersions( - workspaceSlug: string, - slug: string, - ): DocumentVersionSummary[] { - const document = this.requireDocumentRow(workspaceSlug, slug); + listDocumentVersions(slug: string): DocumentVersionSummary[] { + const document = this.requireDocumentRow(slug); const rows = this.database .prepare<[number], DocumentVersionRow>( ` SELECT - workspaces.slug AS workspace_slug, + collections.slug AS collection_slug, documents.slug, document_versions.title, document_versions.content, @@ -441,7 +512,7 @@ export class SqlitePenaStore implements PenaStore { document_versions.published_at FROM document_versions JOIN documents ON documents.id = document_versions.document_id - JOIN workspaces ON workspaces.id = documents.workspace_id + LEFT JOIN collections ON collections.id = documents.collection_id WHERE document_versions.document_id = ? ORDER BY document_versions.version DESC `, @@ -451,34 +522,29 @@ export class SqlitePenaStore implements PenaStore { return rows.map(toDocumentVersionSummary); } - getDocumentVersion( - workspaceSlug: string, - slug: string, - version: number, - ): DocumentVersion | null { - const document = this.requireDocumentRow(workspaceSlug, slug); + getDocumentVersion(slug: string, version: number): DocumentVersion | null { + const document = this.requireDocumentRow(slug); const row = this.getDocumentVersionRow(document.id, version); return row ? toDocumentVersion(row) : null; } restoreDocumentVersion( - workspaceSlug: string, slug: string, version: number, expectedEtag?: string, ): PenaDocument { return this.database.transaction(() => { - const current = this.requireDocumentRow(workspaceSlug, slug); + const current = this.requireDocumentRow(slug); this.assertEtag(current, expectedEtag); if (current.archived_at !== null) { - throw new DocumentArchivedError(workspaceSlug, slug); + throw new DocumentArchivedError(slug); } const historical = this.getDocumentVersionRow(current.id, version); if (!historical) { - throw new DocumentVersionNotFoundError(workspaceSlug, slug, version); + throw new DocumentVersionNotFoundError(slug, version); } if ( @@ -525,8 +591,8 @@ export class SqlitePenaStore implements PenaStore { } return DocumentSchema.parse({ - workspaceSlug, slug, + collectionSlug: current.collection_slug, title: historical.title, content: historical.content, version: nextVersion, @@ -536,24 +602,33 @@ export class SqlitePenaStore implements PenaStore { })(); } - listDocuments( - workspaceSlug: string, - status: DocumentStatus = "active", - ): DocumentSummary[] { - const workspace = this.requireWorkspaceRow(workspaceSlug); - const statusFilter = + listDocuments({ + status = "active", + collectionSlug, + }: DocumentListFilter = {}): DocumentSummary[] { + const conditions = [ status === "archived" ? "documents.archived_at IS NOT NULL" - : "documents.archived_at IS NULL"; + : "documents.archived_at IS NULL", + ]; + const parameters: number[] = []; + + if (collectionSlug === null) { + conditions.push("documents.collection_id IS NULL"); + } else if (collectionSlug !== undefined) { + conditions.push("documents.collection_id = ?"); + parameters.push(this.requireCollectionRow(collectionSlug).id); + } + const orderColumn = status === "archived" ? "documents.archived_at" : "current_version.published_at"; const rows = this.database - .prepare<[number], DocumentSummaryRow>( + .prepare( ` SELECT - workspaces.slug AS workspace_slug, + collections.slug AS collection_slug, documents.slug, current_version.title, current_version.content, @@ -561,106 +636,57 @@ export class SqlitePenaStore implements PenaStore { current_version.published_at AS updated_at, documents.archived_at FROM documents - JOIN workspaces ON workspaces.id = documents.workspace_id + LEFT JOIN collections ON collections.id = documents.collection_id JOIN document_versions AS current_version ON current_version.document_id = documents.id AND current_version.version = documents.current_version - WHERE documents.workspace_id = ? AND ${statusFilter} + WHERE ${conditions.join(" AND ")} ORDER BY ${orderColumn} DESC, documents.id DESC `, ) - .all(workspace.id); + .all(...parameters); return rows.map(toDocumentSummary); } - listArchivedDocuments(workspaceSlug?: string): DocumentSummary[] { - const workspace = workspaceSlug - ? this.requireWorkspaceRow(workspaceSlug) - : null; - const rows = workspace - ? this.database - .prepare<[number], DocumentSummaryRow>( - ` - SELECT - workspaces.slug AS workspace_slug, - documents.slug, - current_version.title, - current_version.content, - current_version.version, - current_version.published_at AS updated_at, - documents.archived_at - FROM documents - JOIN workspaces ON workspaces.id = documents.workspace_id - JOIN document_versions AS current_version - ON current_version.document_id = documents.id - AND current_version.version = documents.current_version - WHERE documents.workspace_id = ? - AND documents.archived_at IS NOT NULL - ORDER BY documents.archived_at DESC, documents.id DESC - `, - ) - .all(workspace.id) - : this.database - .prepare<[], DocumentSummaryRow>( - ` - SELECT - workspaces.slug AS workspace_slug, - documents.slug, - current_version.title, - current_version.content, - current_version.version, - current_version.published_at AS updated_at, - documents.archived_at - FROM documents - JOIN workspaces ON workspaces.id = documents.workspace_id - JOIN document_versions AS current_version - ON current_version.document_id = documents.id - AND current_version.version = documents.current_version - WHERE documents.archived_at IS NOT NULL - ORDER BY documents.archived_at DESC, documents.id DESC - `, - ) - .all(); - - return rows.map(toDocumentSummary); + listArchivedDocuments(collectionSlug?: string): DocumentSummary[] { + return this.listDocuments({ + status: "archived", + ...(collectionSlug === undefined ? {} : { collectionSlug }), + }); } moveDocument( - workspaceSlug: string, slug: string, - destinationWorkspaceSlug: string, + collectionSlug: string | null, expectedEtag?: string, ): DocumentSummary { - const document = this.requireDocumentRow(workspaceSlug, slug); + const document = this.requireDocumentRow(slug); this.assertEtag(document, expectedEtag); if (document.archived_at !== null) { - throw new DocumentArchivedError(workspaceSlug, slug); + throw new DocumentArchivedError(slug); } - const destinationWorkspace = this.requireWorkspaceRow( - destinationWorkspaceSlug, - ); + const destination = + collectionSlug === null + ? null + : this.requireCollectionRow(collectionSlug); - if (workspaceSlug === destinationWorkspaceSlug) { + if ((destination?.id ?? null) === document.collection_id) { return toDocumentSummary(document); } - if (this.getDocumentRow(destinationWorkspace.id, slug)) { - throw new DocumentSlugConflictError(destinationWorkspaceSlug, slug); - } - const update = this.database - .prepare<[number, string, number, string]>( + .prepare<[number | null, string, number, string]>( ` UPDATE documents - SET workspace_id = ?, state_token = ? + SET collection_id = ?, state_token = ? WHERE id = ? AND state_token = ? `, ) .run( - destinationWorkspace.id, + destination?.id ?? null, randomUUID(), document.id, document.state_token, @@ -672,16 +698,12 @@ export class SqlitePenaStore implements PenaStore { return toDocumentSummary({ ...document, - workspace_slug: destinationWorkspaceSlug, + collection_slug: destination?.slug ?? null, }); } - archiveDocument( - workspaceSlug: string, - slug: string, - expectedEtag?: string, - ): DocumentSummary { - const document = this.requireDocumentRow(workspaceSlug, slug); + archiveDocument(slug: string, expectedEtag?: string): DocumentSummary { + const document = this.requireDocumentRow(slug); this.assertEtag(document, expectedEtag); if (document.archived_at !== null) { @@ -706,12 +728,8 @@ export class SqlitePenaStore implements PenaStore { return toDocumentSummary({ ...document, archived_at: archivedAt }); } - unarchiveDocument( - workspaceSlug: string, - slug: string, - expectedEtag?: string, - ): DocumentSummary { - const document = this.requireDocumentRow(workspaceSlug, slug); + unarchiveDocument(slug: string, expectedEtag?: string): DocumentSummary { + const document = this.requireDocumentRow(slug); this.assertEtag(document, expectedEtag); if (document.archived_at === null) { @@ -735,16 +753,12 @@ export class SqlitePenaStore implements PenaStore { return toDocumentSummary({ ...document, archived_at: null }); } - deleteArchivedDocument( - workspaceSlug: string, - slug: string, - expectedEtag?: string, - ): void { - const document = this.requireDocumentRow(workspaceSlug, slug); + deleteArchivedDocument(slug: string, expectedEtag?: string): void { + const document = this.requireDocumentRow(slug); this.assertEtag(document, expectedEtag); if (document.archived_at === null) { - throw new DocumentNotArchivedError(workspaceSlug, slug); + throw new DocumentNotArchivedError(slug); } const deletion = this.database @@ -759,16 +773,15 @@ export class SqlitePenaStore implements PenaStore { } addFeedback( - workspaceSlug: string, slug: string, submission: FeedbackSubmission, expectedEtag?: string, ): FeedbackBatch { - const document = this.requireDocumentRow(workspaceSlug, slug); + const document = this.requireDocumentRow(slug); this.assertEtag(document, expectedEtag); if (document.archived_at !== null) { - throw new DocumentArchivedError(workspaceSlug, slug); + throw new DocumentArchivedError(slug); } const validatedSubmission = FeedbackSubmissionSchema.parse(submission); @@ -810,8 +823,8 @@ export class SqlitePenaStore implements PenaStore { }); } - getFeedback(workspaceSlug: string, slug: string): FeedbackResponse { - const document = this.requireDocumentRow(workspaceSlug, slug); + getFeedback(slug: string): FeedbackResponse { + const document = this.requireDocumentRow(slug); const rows = this.database .prepare<[number], FeedbackBatchRow>( ` @@ -830,12 +843,8 @@ export class SqlitePenaStore implements PenaStore { }); } - listFeedbackReceiptsAfter( - workspaceSlug: string, - slug: string, - after: number, - ): FeedbackReceipt[] { - const document = this.requireDocumentRow(workspaceSlug, slug); + listFeedbackReceiptsAfter(slug: string, after: number): FeedbackReceipt[] { + const document = this.requireDocumentRow(slug); const rows = this.database .prepare<[number, number], FeedbackReceiptRow>( ` @@ -856,82 +865,98 @@ export class SqlitePenaStore implements PenaStore { } } - private getWorkspaceRow(slug: string): WorkspaceRow | null { + private resolveCollectionTarget( + collectionSlug: string | null | undefined, + ): CollectionTarget { + if (collectionSlug === undefined) { + return undefined; + } + + return collectionSlug === null + ? null + : this.requireCollectionRow(collectionSlug); + } + + private getCollectionRow(slug: string): CollectionRow | null { return ( this.database - .prepare<[string], WorkspaceRow>( - ` - SELECT id, slug, name, created_at, updated_at - FROM workspaces - WHERE slug = ? - `, + .prepare<[string], CollectionRow>( + `${COLLECTION_ROW_SELECT} WHERE collections.slug = ?`, ) .get(slug) ?? null ); } - private getWorkspaceRowByName(name: string): WorkspaceRow | null { + private getCollectionRowByName(name: string): CollectionRow | null { return ( this.database - .prepare<[string], WorkspaceRow>( - ` - SELECT id, slug, name, created_at, updated_at - FROM workspaces - WHERE name = ? COLLATE NOCASE - `, + .prepare<[string], CollectionRow>( + `${COLLECTION_ROW_SELECT} WHERE collections.name = ? COLLATE NOCASE`, ) .get(name) ?? null ); } - private requireWorkspaceRow(slug: string): WorkspaceRow { - const workspace = this.getWorkspaceRow(slug); + private requireCollectionRow(slug: string): CollectionRow { + const collection = this.getCollectionRow(slug); + + if (!collection) { + throw new CollectionNotFoundError(slug); + } + + return collection; + } + + private requireCollectionRowById(id: number): CollectionRow { + const collection = this.database + .prepare<[number], CollectionRow>( + `${COLLECTION_ROW_SELECT} WHERE collections.id = ?`, + ) + .get(id); - if (!workspace) { - throw new WorkspaceNotFoundError(slug); + if (!collection) { + throw new PersistedDataError( + `Collection ${id} is referenced but does not exist.`, + ); } - return workspace; + return collection; } - private getDocumentRow(workspaceId: number, slug: string): DocumentRow | null { + /** Walks up from the proposed parent to make sure it is not the collection itself or one of its descendants. */ + private assertNoCycle( + collection: CollectionRow, + proposedParent: CollectionRow | null, + ): void { + let cursor = proposedParent; + + while (cursor) { + if (cursor.id === collection.id) { + throw new CollectionCycleError(collection.slug); + } + + cursor = + cursor.parent_id === null + ? null + : this.requireCollectionRowById(cursor.parent_id); + } + } + + private getDocumentRow(slug: string): DocumentRow | null { return ( this.database - .prepare<[number, string], DocumentRow>( - ` - SELECT - documents.id, - documents.workspace_id, - workspaces.slug AS workspace_slug, - documents.slug, - current_version.id AS version_id, - current_version.title, - current_version.content, - current_version.version, - current_version.published_at AS updated_at, - documents.archived_at, - documents.state_token - FROM documents - JOIN workspaces ON workspaces.id = documents.workspace_id - JOIN document_versions AS current_version - ON current_version.document_id = documents.id - AND current_version.version = documents.current_version - WHERE documents.workspace_id = ? AND documents.slug = ? - `, + .prepare<[string], DocumentRow>( + `${DOCUMENT_ROW_SELECT} WHERE documents.slug = ?`, ) - .get(workspaceId, slug) ?? null + .get(slug) ?? null ); } - private requireDocumentRow( - workspaceSlug: string, - slug: string, - ): DocumentRow { - const workspace = this.requireWorkspaceRow(workspaceSlug); - const document = this.getDocumentRow(workspace.id, slug); + private requireDocumentRow(slug: string): DocumentRow { + const document = this.getDocumentRow(slug); if (!document) { - throw new DocumentNotFoundError(workspaceSlug, slug); + throw new DocumentNotFoundError(slug); } return document; @@ -946,7 +971,7 @@ export class SqlitePenaStore implements PenaStore { .prepare<[number, number], DocumentVersionRow>( ` SELECT - workspaces.slug AS workspace_slug, + collections.slug AS collection_slug, documents.slug, document_versions.title, document_versions.content, @@ -954,7 +979,7 @@ export class SqlitePenaStore implements PenaStore { document_versions.published_at FROM document_versions JOIN documents ON documents.id = document_versions.document_id - JOIN workspaces ON workspaces.id = documents.workspace_id + LEFT JOIN collections ON collections.id = documents.collection_id WHERE document_versions.document_id = ? AND document_versions.version = ? `, @@ -1100,6 +1125,11 @@ function migrateDatabase(database: Database.Database): void { if (schemaVersion < 9) { migrateToFeedbackInstructions(database); + schemaVersion = 9; + } + + if (schemaVersion < 10) { + migrateToCollections(database); } } @@ -1443,10 +1473,105 @@ function formatDocumentSlug(slug: string): string { .join(" "); } -function slugifyWorkspaceName(name: string): string { + +function migrateToCollections(database: Database.Database): void { + const duplicateSlugs = database + .prepare<[], { slug: string }>( + ` + SELECT slug + FROM documents + GROUP BY slug + HAVING COUNT(*) > 1 + ORDER BY slug + `, + ) + .all() + .map((row) => row.slug); + + if (duplicateSlugs.length > 0) { + throw new DocumentSlugConflictMigrationError(duplicateSlugs); + } + + database.pragma("foreign_keys = OFF"); + + try { + database.transaction(() => { + // Every workspace except "default" becomes a root collection with the + // same identity. Documents from "default" move to the root, which is + // where a document without a collection lives. + database.exec(` + CREATE TABLE collections ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL COLLATE NOCASE UNIQUE, + parent_id INTEGER REFERENCES collections(id) ON DELETE RESTRICT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) STRICT; + + INSERT INTO collections (id, slug, name, parent_id, created_at, updated_at) + SELECT id, slug, name, NULL, created_at, updated_at + FROM workspaces + WHERE slug <> 'default'; + + CREATE INDEX collections_parent_id ON collections(parent_id); + + CREATE TABLE documents_with_collections ( + id INTEGER PRIMARY KEY, + collection_id INTEGER + REFERENCES collections(id) ON DELETE RESTRICT, + slug TEXT NOT NULL UNIQUE, + current_version INTEGER NOT NULL CHECK (current_version >= 1), + archived_at TEXT, + state_token TEXT NOT NULL + ) STRICT; + + INSERT INTO documents_with_collections ( + id, + collection_id, + slug, + current_version, + archived_at, + state_token + ) + SELECT + documents.id, + collections.id, + documents.slug, + documents.current_version, + documents.archived_at, + CASE + WHEN collections.id IS NULL THEN lower(hex(randomblob(16))) + ELSE documents.state_token + END + FROM documents + LEFT JOIN collections ON collections.id = documents.workspace_id; + + DROP TABLE documents; + ALTER TABLE documents_with_collections RENAME TO documents; + DROP TABLE workspaces; + + CREATE INDEX documents_collection_id_archived_at + ON documents(collection_id, archived_at); + + PRAGMA user_version = 10; + `); + })(); + } finally { + database.pragma("foreign_keys = ON"); + } + + const foreignKeyViolations = database.pragma("foreign_key_check") as unknown[]; + + if (foreignKeyViolations.length > 0) { + throw new Error("The collections migration produced invalid foreign keys."); + } +} + +function slugifyCollectionName(name: string): string { return name .normalize("NFKD") - .replace(/[\u0300-\u036f]/g, "") + .replace(/[̀-ͯ]/g, "") .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") @@ -1454,26 +1579,28 @@ function slugifyWorkspaceName(name: string): string { .replace(/-+$/g, ""); } -function toWorkspace(row: WorkspaceRow): Workspace { - return WorkspaceSchema.parse({ +function toCollection(row: CollectionRow): Collection { + return CollectionSchema.parse({ slug: row.slug, name: row.name, + parentSlug: row.parent_slug, createdAt: row.created_at, updatedAt: row.updated_at, }); } -function toWorkspaceSummary(row: WorkspaceSummaryRow): WorkspaceSummary { - return WorkspaceSummarySchema.parse({ - ...toWorkspace(row), +function toCollectionSummary(row: CollectionSummaryRow): CollectionSummary { + return CollectionSummarySchema.parse({ + ...toCollection(row), documentCount: row.document_count, + childCount: row.child_count, }); } function toDocument(row: DocumentRow): PenaDocument { return DocumentSchema.parse({ - workspaceSlug: row.workspace_slug, slug: row.slug, + collectionSlug: row.collection_slug, title: row.title, content: row.content, version: row.version, @@ -1484,8 +1611,8 @@ function toDocument(row: DocumentRow): PenaDocument { function toDocumentVersion(row: DocumentVersionRow): DocumentVersion { return DocumentVersionSchema.parse({ - workspaceSlug: row.workspace_slug, slug: row.slug, + collectionSlug: row.collection_slug, title: row.title, content: row.content, version: row.version, @@ -1497,8 +1624,8 @@ function toDocumentVersionSummary( row: DocumentVersionRow, ): DocumentVersionSummary { return DocumentVersionSummarySchema.parse({ - workspaceSlug: row.workspace_slug, slug: row.slug, + collectionSlug: row.collection_slug, title: row.title, version: row.version, updatedAt: row.published_at, @@ -1511,8 +1638,8 @@ function documentEtag(row: Pick): string { function toDocumentSummary(row: DocumentSummaryRow): DocumentSummary { return DocumentSummarySchema.parse({ - workspaceSlug: row.workspace_slug, slug: row.slug, + collectionSlug: row.collection_slug, title: row.title, version: row.version, updatedAt: row.updated_at, diff --git a/apps/server/src/watch-feedback-script.test.ts b/apps/server/src/watch-feedback-script.test.ts index 56696cd..f6c5518 100644 --- a/apps/server/src/watch-feedback-script.test.ts +++ b/apps/server/src/watch-feedback-script.test.ts @@ -49,7 +49,6 @@ describe("Pena feedback watcher", () => { .writeHead(200, { "content-type": "application/json" }) .end( JSON.stringify({ - workspaceSlug: "default", documentSlug: "initial-spec", documentVersion: 3, latestBatchId: 8, @@ -66,7 +65,6 @@ describe("Pena feedback watcher", () => { expect(JSON.parse(line)).toEqual({ type: "pena_feedback_submitted", - workspaceSlug: "default", documentSlug: "initial-spec", documentVersion: 3, latestBatchId: 8, @@ -114,8 +112,6 @@ async function startServer( function startWatcher(baseUrl: string): ChildProcessWithoutNullStreams { const child = spawn(process.execPath, [ watcherPath, - "--workspace", - "default", "--document", "initial-spec", "--base-url", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index ed04508..af58543 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,34 +1,27 @@ import { UtilityBar } from "./components/UtilityBar"; import { ArchivePage } from "./features/archive/ArchivePage"; +import { CollectionsPage } from "./features/collections/CollectionsPage"; import { DocumentReviewPage } from "./features/document-review/DocumentReviewPage"; import { readAppRoute } from "./features/document-review/routing"; import { HomePage } from "./features/home/HomePage"; -import { WorkspacesPage } from "./features/workspaces/WorkspacesPage"; export function App() { const route = readAppRoute(window.location.pathname, window.location.search); if (route.kind === "home") { - return ; + return ; } - if (route.kind === "workspaces") { - return ; + if (route.kind === "collections") { + return ; } if (route.kind === "archive") { - return ; + return ; } - if (route.kind === "documents") { - return route.documentSlug === null ? ( - - ) : ( - - ); + if (route.kind === "document") { + return ; } return ( diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 068a69b..cd0e224 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -1,4 +1,7 @@ import type { + Collection, + CollectionListResponse, + CollectionUpdateRequest, DocumentListResponse, DocumentSummary, DocumentStatus, @@ -8,8 +11,6 @@ import type { FeedbackResponse, FeedbackSubmission, PenaDocument, - Workspace, - WorkspaceListResponse, } from "@pena/contracts"; interface ApiErrorBody { @@ -21,6 +22,9 @@ export interface DocumentResource { etag: string; } +/** Lists documents at the root (`null`), in one collection, or everywhere. */ +export type DocumentListScope = string | null | undefined; + async function parseResponse(response: Response): Promise { if (!response.ok) { const body = (await response.json().catch(() => ({}))) as ApiErrorBody; @@ -30,60 +34,61 @@ async function parseResponse(response: Response): Promise { return response.json() as Promise; } -function documentsUrl(workspaceSlug: string): string { - return `/api/workspaces/${encodeURIComponent(workspaceSlug)}/documents`; +async function assertOk(response: Response): Promise { + if (!response.ok) { + const body = (await response.json().catch(() => ({}))) as ApiErrorBody; + throw new Error(body.error ?? `Pena returned HTTP ${response.status}.`); + } +} + +function documentUrl(documentSlug: string): string { + return `/api/docs/${encodeURIComponent(documentSlug)}`; } -function documentUrl(workspaceSlug: string, documentSlug: string): string { - return `${documentsUrl(workspaceSlug)}/${encodeURIComponent(documentSlug)}`; +function collectionUrl(collectionSlug: string): string { + return `/api/collections/${encodeURIComponent(collectionSlug)}`; } -export async function fetchWorkspaces(): Promise { - const response = await fetch("/api/workspaces"); - return parseResponse(response); +export async function fetchCollections(): Promise { + const response = await fetch("/api/collections"); + return parseResponse(response); } -export async function createWorkspace(name: string): Promise { - const response = await fetch("/api/workspaces", { +export async function createCollection( + name: string, + parentSlug: string | null = null, +): Promise { + const response = await fetch("/api/collections", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ name }), + body: JSON.stringify({ name, parentSlug }), }); - return parseResponse(response); + return parseResponse(response); } -export async function renameWorkspace( - workspaceSlug: string, - name: string, -): Promise { - const response = await fetch( - `/api/workspaces/${encodeURIComponent(workspaceSlug)}`, - { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name }), - }, - ); - return parseResponse(response); +export async function updateCollection( + collectionSlug: string, + update: CollectionUpdateRequest, +): Promise { + const response = await fetch(collectionUrl(collectionSlug), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(update), + }); + return parseResponse(response); } -export async function deleteWorkspace(workspaceSlug: string): Promise { - const response = await fetch( - `/api/workspaces/${encodeURIComponent(workspaceSlug)}`, - { method: "DELETE" }, - ); - - if (!response.ok) { - const body = (await response.json().catch(() => ({}))) as ApiErrorBody; - throw new Error(body.error ?? `Pena returned HTTP ${response.status}.`); - } +export async function deleteCollection(collectionSlug: string): Promise { + const response = await fetch(collectionUrl(collectionSlug), { + method: "DELETE", + }); + await assertOk(response); } export async function fetchDocument( - workspaceSlug: string, documentSlug: string, ): Promise { - const response = await fetch(documentUrl(workspaceSlug, documentSlug)); + const response = await fetch(documentUrl(documentSlug)); if (response.status === 404) { return null; @@ -100,34 +105,29 @@ export async function fetchDocument( } export async function fetchDocumentVersions( - workspaceSlug: string, documentSlug: string, ): Promise { - const response = await fetch( - `${documentUrl(workspaceSlug, documentSlug)}/versions`, - ); + const response = await fetch(`${documentUrl(documentSlug)}/versions`); return parseResponse(response); } export async function fetchDocumentVersion( - workspaceSlug: string, documentSlug: string, version: number, ): Promise { const response = await fetch( - `${documentUrl(workspaceSlug, documentSlug)}/versions/${version}`, + `${documentUrl(documentSlug)}/versions/${version}`, ); return parseResponse(response); } export async function restoreDocumentVersion( - workspaceSlug: string, documentSlug: string, version: number, etag: string, ): Promise { const response = await fetch( - `${documentUrl(workspaceSlug, documentSlug)}/versions/${version}/restore`, + `${documentUrl(documentSlug)}/versions/${version}/restore`, { method: "POST", headers: { "if-match": etag }, @@ -144,31 +144,42 @@ export async function restoreDocumentVersion( } export async function fetchDocuments( - workspaceSlug: string, + scope: DocumentListScope = undefined, status: DocumentStatus = "active", ): Promise { - const query = status === "archived" ? "?status=archived" : ""; - const response = await fetch(`${documentsUrl(workspaceSlug)}${query}`); + const query = new URLSearchParams(); + + if (status === "archived") { + query.set("status", "archived"); + } + + if (scope === null) { + query.set("collection", "root"); + } else if (scope !== undefined) { + query.set("collection", scope); + } + + const search = query.toString(); + const response = await fetch(`/api/docs${search ? `?${search}` : ""}`); return parseResponse(response); } export async function fetchArchive( - workspaceSlug: string | null = null, + collectionSlug: string | null = null, ): Promise { - const query = workspaceSlug - ? `?workspace=${encodeURIComponent(workspaceSlug)}` + const query = collectionSlug + ? `?collection=${encodeURIComponent(collectionSlug)}` : ""; const response = await fetch(`/api/archive${query}`); return parseResponse(response); } async function updateDocumentStatus( - workspaceSlug: string, documentSlug: string, status: DocumentStatus, etag: string, ): Promise { - const response = await fetch(documentUrl(workspaceSlug, documentSlug), { + const response = await fetch(documentUrl(documentSlug), { method: "PATCH", headers: { "content-type": "application/json", @@ -180,85 +191,69 @@ async function updateDocumentStatus( } export async function archiveDocument( - workspaceSlug: string, documentSlug: string, etag: string, ): Promise { - return updateDocumentStatus(workspaceSlug, documentSlug, "archived", etag); + return updateDocumentStatus(documentSlug, "archived", etag); } export async function unarchiveDocument( - workspaceSlug: string, documentSlug: string, etag: string, ): Promise { - return updateDocumentStatus(workspaceSlug, documentSlug, "active", etag); + return updateDocumentStatus(documentSlug, "active", etag); } export async function moveDocument( - workspaceSlug: string, documentSlug: string, - destinationWorkspaceSlug: string, + collectionSlug: string | null, etag: string, ): Promise { - const response = await fetch( - `${documentUrl(workspaceSlug, documentSlug)}/move`, - { - method: "POST", - headers: { - "content-type": "application/json", - "if-match": etag, - }, - body: JSON.stringify({ workspaceSlug: destinationWorkspaceSlug }), + const response = await fetch(`${documentUrl(documentSlug)}/move`, { + method: "POST", + headers: { + "content-type": "application/json", + "if-match": etag, }, - ); + body: JSON.stringify({ collectionSlug }), + }); return parseResponse(response); } export async function deleteDocument( - workspaceSlug: string, documentSlug: string, etag: string, ): Promise { - const response = await fetch(documentUrl(workspaceSlug, documentSlug), { + const response = await fetch(documentUrl(documentSlug), { method: "DELETE", headers: { "if-match": etag }, }); - - if (!response.ok) { - const body = (await response.json().catch(() => ({}))) as ApiErrorBody; - throw new Error(body.error ?? `Pena returned HTTP ${response.status}.`); - } + await assertOk(response); } export async function submitFeedback( - workspaceSlug: string, documentSlug: string, submission: FeedbackSubmission, etag: string, ): Promise { - const response = await fetch( - `${documentUrl(workspaceSlug, documentSlug)}/feedback`, - { - method: "POST", - headers: { - "content-type": "application/json", - "if-match": etag, - }, - body: JSON.stringify(submission), + const response = await fetch(`${documentUrl(documentSlug)}/feedback`, { + method: "POST", + headers: { + "content-type": "application/json", + "if-match": etag, }, - ); + body: JSON.stringify(submission), + }); return parseResponse(response); } export async function fetchFeedback( - workspaceSlug: string, documentSlug: string, etag?: string, ): Promise { const response = await fetch( - `${documentUrl(workspaceSlug, documentSlug)}/feedback`, + `${documentUrl(documentSlug)}/feedback`, etag ? { headers: { "if-match": etag } } : undefined, ); return parseResponse(response); diff --git a/apps/web/src/collections.ts b/apps/web/src/collections.ts new file mode 100644 index 0000000..2ce63d7 --- /dev/null +++ b/apps/web/src/collections.ts @@ -0,0 +1,128 @@ +import type { Collection } from "@pena/contracts"; + +/** + * Helpers for the collection tree. The server returns collections as a flat + * list with a `parentSlug` on each; pages derive folders, paths, and subtrees + * from that list instead of asking the server for each level. + */ + +export function findCollection( + collections: T[], + slug: string | null, +): T | null { + if (slug === null) { + return null; + } + + return collections.find((collection) => collection.slug === slug) ?? null; +} + +/** Direct children of a collection, or the root collections for `null`. */ +export function childCollections( + collections: T[], + parentSlug: string | null, +): T[] { + return collections.filter( + (collection) => collection.parentSlug === parentSlug, + ); +} + +/** Ancestors from the root down to and including the collection itself. */ +export function collectionPath( + collections: T[], + slug: string | null, +): T[] { + const path: T[] = []; + const seen = new Set(); + let cursor = findCollection(collections, slug); + + while (cursor && !seen.has(cursor.slug)) { + seen.add(cursor.slug); + path.unshift(cursor); + cursor = findCollection(collections, cursor.parentSlug); + } + + return path; +} + +/** The collection and everything nested inside it, depth first. */ +export function collectionSubtree( + collections: T[], + slug: string | null, +): T[] { + const subtree: T[] = []; + const root = findCollection(collections, slug); + + if (root) { + subtree.push(root); + } + + const queue = childCollections(collections, slug); + const seen = new Set(root ? [root.slug] : []); + + while (queue.length > 0) { + const next = queue.shift(); + + if (!next || seen.has(next.slug)) { + continue; + } + + seen.add(next.slug); + subtree.push(next); + queue.unshift(...childCollections(collections, next.slug)); + } + + return subtree; +} + +/** Human-readable path such as "Mamikos / Payments" for breadcrumbs. */ +export function formatCollectionPath( + collections: Collection[], + slug: string | null, +): string { + const path = collectionPath(collections, slug); + return path.length === 0 + ? "" + : path.map((collection) => collection.name).join(" / "); +} + +export interface CollectionTreeNode { + collection: T; + depth: number; + children: CollectionTreeNode[]; +} + +/** Nested tree ordered by name at every level. */ +export function buildCollectionTree( + collections: T[], + parentSlug: string | null = null, + depth = 0, + seen = new Set(), +): CollectionTreeNode[] { + return childCollections(collections, parentSlug) + .filter((collection) => !seen.has(collection.slug)) + .sort((left, right) => + left.name.localeCompare(right.name, undefined, { sensitivity: "base" }), + ) + .map((collection) => { + const nextSeen = new Set(seen); + nextSeen.add(collection.slug); + return { + collection, + depth, + children: buildCollectionTree( + collections, + collection.slug, + depth + 1, + nextSeen, + ), + }; + }); +} + +/** Depth-first flattening of the tree, for select menus and lists. */ +export function flattenCollectionTree( + nodes: CollectionTreeNode[], +): CollectionTreeNode[] { + return nodes.flatMap((node) => [node, ...flattenCollectionTree(node.children)]); +} diff --git a/apps/web/src/components/UtilityBar.tsx b/apps/web/src/components/UtilityBar.tsx index 004e888..bae6421 100644 --- a/apps/web/src/components/UtilityBar.tsx +++ b/apps/web/src/components/UtilityBar.tsx @@ -1,27 +1,31 @@ -export type UtilityBarPage = "dashboard" | "workspaces" | "archive" | null; +import { archiveHref } from "../features/document-review/routing"; + +export type UtilityBarPage = "dashboard" | "collections" | "archive" | null; interface UtilityBarProps { current: UtilityBarPage; - /** Scopes the Archive link to a workspace when one is in view. */ - workspaceSlug?: string | null; + /** Scopes the Archive link to a collection when one is in view. */ + collectionSlug?: string | null; } -export function UtilityBar({ current, workspaceSlug = null }: UtilityBarProps) { +export function UtilityBar({ + current, + collectionSlug = null, +}: UtilityBarProps) { return (
+ +
+ + setQuery(event.target.value)} + /> + +
+ + {notice ? ( +

+ {notice.message} +

+ ) : null} + + {isLoading ? ( +
+ + + +
+ ) : matches.length === 0 ? ( +

+ {query.trim() + ? `No collections match “${query.trim()}”.` + : "No collections yet. Documents live at the root until you file them."} +

+ ) : ( +
+ {matches.map(({ collection, depth }) => { + const isEditing = editingSlug === collection.slug; + const isMoving = movingSlug === collection.slug; + const isDeleting = deleteCandidate === collection.slug; + const isMenuOpen = openMenuSlug === collection.slug; + const isOccupied = + collection.documentCount > 0 || collection.childCount > 0; + const pathLabel = formatCollectionPath( + collections, + collection.parentSlug, + ); + + return ( +
+
+ + +
+ + + {isMenuOpen ? ( +
+ + + + + + {isOccupied ? ( +

+ Move out every document and nested collection + first. +

+ ) : null} +
+ ) : null} +
+
+ +
+

{collection.name}

+
+ + {collection.slug} + + {pathLabel ? ( +

In {pathLabel}

+ ) : null} +

+ Updated {formatRelativeTime(collection.updatedAt)} +

+ +
+
+ {collection.documentCount} + + {collection.documentCount === 1 + ? "document" + : "documents"} + + {collection.childCount > 0 ? ( + + · {collection.childCount}{" "} + {collection.childCount === 1 + ? "collection" + : "collections"} + + ) : null} +
+ + + Open + +
+ + {isEditing ? ( +
+ void handleRename(event, collection.slug) + } + > + + setEditingName(event.target.value)} + maxLength={80} + autoFocus + /> +
+ + +
+
+ ) : null} + + {isMoving ? ( +
+ void handleMove(event, collection.slug) + } + > + + +
+ + +
+
+ ) : null} + + {isDeleting ? ( +
+
+ Delete {collection.name}? +

+ Type {collection.slug} to confirm. +

+
+ + setDeleteConfirmation(event.target.value) + } + autoFocus + autoComplete="off" + /> +
+ + +
+
+ ) : null} +
+ ); + })} +
+ )} + + +
+ + pena + + collaborate and review docs with Claude Code +
+ + ); +} + +interface ParentOptionProps { + node: CollectionTreeNode; +} + +function ParentOption({ node }: ParentOptionProps) { + return ( + + ); +} + +/** + * Colours the monogram from the slug so a collection keeps the same hue + * between visits and neighbouring cards stay distinguishable at a glance. + */ +function monogramTint(slug: string): { background: string; color: string } { + let hue = 0; + + for (let index = 0; index < slug.length; index += 1) { + hue = (hue * 31 + slug.charCodeAt(index)) % 360; + } + + return { + background: `hsl(${hue} 60% 60% / 14%)`, + color: `hsl(${hue} 72% 76%)`, + }; +} + +function SearchIcon() { + return ( + + ); +} + +function InfoIcon() { + return ( + + ); +} + +function MoreIcon() { + return ( + + ); +} diff --git a/apps/web/src/features/document-review/DocumentReviewPage.test.tsx b/apps/web/src/features/document-review/DocumentReviewPage.test.tsx index 0ee08ec..3ad1e0a 100644 --- a/apps/web/src/features/document-review/DocumentReviewPage.test.tsx +++ b/apps/web/src/features/document-review/DocumentReviewPage.test.tsx @@ -19,9 +19,11 @@ const DECISION_DOCUMENT = [ ":::", ].join("\n"); +const DOCUMENT_URL = "/api/docs/review"; + const documentResponse = { - workspaceSlug: "default", slug: "review", + collectionSlug: null as string | null, title: "Review", content: DECISION_DOCUMENT, version: 1, @@ -29,6 +31,26 @@ const documentResponse = { archivedAt: null, }; +const researchCollection = { + slug: "research", + name: "Research", + parentSlug: null, + createdAt: "2026-07-18T10:00:00.000Z", + updatedAt: "2026-07-18T10:00:00.000Z", + documentCount: 0, + childCount: 0, +}; + +const writingCollection = { + slug: "writing", + name: "Writing", + parentSlug: null, + createdAt: "2026-07-18T10:00:00.000Z", + updatedAt: "2026-07-18T10:00:00.000Z", + documentCount: 0, + childCount: 0, +}; + beforeEach(() => { vi.stubGlobal( "ResizeObserver", @@ -62,8 +84,8 @@ describe("interactive decision review", () => { const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); - if (url === "/api/workspaces/default/documents") { - return documentListResponse(); + if (url === "/api/collections") { + return collectionListResponse(); } if (init?.method === "POST") { @@ -83,7 +105,7 @@ describe("interactive decision review", () => { vi.stubGlobal("fetch", fetchMock); const user = userEvent.setup(); - render(); + render(); const apply = await screen.findByRole("button", { name: "Apply" }); const skip = screen.getByRole("button", { name: "Skip" }); @@ -152,18 +174,17 @@ describe("interactive decision review", () => { expect((apply as HTMLButtonElement).disabled).toBe(true); expect((skip as HTMLButtonElement).disabled).toBe(true); expect(skip.getAttribute("aria-pressed")).toBe("true"); - expect(fetchMock).toHaveBeenCalledWith( - "/api/workspaces/default/documents/review/feedback", - { headers: { "if-match": '"pena-test-1"' } }, - ); + expect(fetchMock).toHaveBeenCalledWith(`${DOCUMENT_URL}/feedback`, { + headers: { "if-match": '"pena-test-1"' }, + }); }); it("restores submitted decisions as disabled choices", async () => { vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL) => - String(input) === "/api/workspaces/default/documents" - ? documentListResponse() + String(input) === "/api/collections" + ? collectionListResponse() : String(input).endsWith("/feedback") ? jsonResponse({ latestBatchId: 1, @@ -186,7 +207,7 @@ describe("interactive decision review", () => { ), ); - render(); + render(); const apply = await screen.findByRole("button", { name: "Apply" }); const skip = screen.getByRole("button", { name: "Skip" }); @@ -218,8 +239,8 @@ describe("interactive decision review", () => { return jsonResponse({ latestBatchId: null, batches: [] }); } - if (url === "/api/workspaces/default/documents") { - return documentListResponse(); + if (url === "/api/collections") { + return collectionListResponse(); } return jsonResponse(documentResponse); @@ -228,7 +249,7 @@ describe("interactive decision review", () => { vi.stubGlobal("fetch", fetchMock); const user = userEvent.setup(); - render(); + render(); expect(screen.queryByRole("button", { name: "Instruction" })).toBeNull(); await user.click(await screen.findByRole("button", { name: "Apply" })); @@ -258,8 +279,8 @@ describe("interactive decision review", () => { vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL) => { - if (String(input) === "/api/workspaces/default/documents") { - return documentListResponse(); + if (String(input) === "/api/collections") { + return collectionListResponse(); } if (String(input).endsWith("/feedback")) { @@ -270,7 +291,7 @@ describe("interactive decision review", () => { }), ); - render(); + render(); expect( await screen.findByRole("heading", { @@ -283,8 +304,8 @@ describe("interactive decision review", () => { it("keeps Markdown-only loading behavior unchanged", async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL) => - String(input) === "/api/workspaces/default/documents" - ? documentListResponse() + String(input) === "/api/collections" + ? collectionListResponse() : jsonResponse({ ...documentResponse, content: "## Markdown only\n\nSelect this passage.", @@ -292,7 +313,7 @@ describe("interactive decision review", () => { ); vi.stubGlobal("fetch", fetchMock); - render(); + render(); expect( await screen.findByRole("heading", { name: "Markdown only" }), @@ -303,8 +324,10 @@ describe("interactive decision review", () => { name: "Submit feedback", }) as HTMLButtonElement).disabled, ).toBe(true); - // The document and the move destinations — the rail no longer needs a list. + // The document and the collection list — nothing else is fetched. await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + expect(fetchMock).toHaveBeenCalledWith(DOCUMENT_URL); + expect(fetchMock).toHaveBeenCalledWith("/api/collections"); }); }); @@ -328,8 +351,8 @@ describe("version history", () => { async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); - if (url === "/api/workspaces") { - return jsonResponse({ workspaces: [] }); + if (url === "/api/collections") { + return collectionListResponse(); } if (url.endsWith("/feedback")) { @@ -342,8 +365,8 @@ describe("version history", () => { if (url.endsWith("/versions/1")) { return jsonResponse({ - workspaceSlug: "default", slug: "review", + collectionSlug: null, title: "First draft", content: "Original line.", version: 1, @@ -355,15 +378,15 @@ describe("version history", () => { return jsonResponse({ versions: [ { - workspaceSlug: "default", slug: "review", + collectionSlug: null, title: "Second draft", version: 2, updatedAt: "2026-07-18T11:00:00.000Z", }, { - workspaceSlug: "default", slug: "review", + collectionSlug: null, title: "First draft", version: 1, updatedAt: "2026-07-18T10:00:00.000Z", @@ -378,7 +401,7 @@ describe("version history", () => { vi.stubGlobal("fetch", fetchMock); const user = userEvent.setup(); - render(); + render(); await user.click( await screen.findByRole("button", { name: "Version 2" }), @@ -428,12 +451,14 @@ describe("version history", () => { await screen.findByText("Version 3 is now current."), ).toBeTruthy(); expect(fetchMock).toHaveBeenCalledWith( - "/api/workspaces/default/documents/review/versions/1/restore", + `${DOCUMENT_URL}/versions/1/restore`, { method: "POST", headers: { "if-match": '"pena-test-1"' }, }, ); + expect(fetchMock).toHaveBeenCalledWith(`${DOCUMENT_URL}/versions`); + expect(fetchMock).toHaveBeenCalledWith(`${DOCUMENT_URL}/versions/1`); }); }); @@ -449,13 +474,15 @@ describe("saved document index", () => { vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL) => - String(input).endsWith("/feedback") + String(input) === "/api/collections" + ? collectionListResponse() + : String(input).endsWith("/feedback") ? jsonResponse({ latestBatchId: null, batches: [] }) : jsonResponse(documentResponse), ), ); - render(); + render(); const target = await screen.findByRole("heading", { name: "Add request caching", @@ -479,18 +506,18 @@ describe("saved document index", () => { return jsonResponse({ latestBatchId: null, batches: [] }); } - if (url.endsWith("/documents/review")) { + if (url === DOCUMENT_URL) { documentFetchCount += 1; return documentFetchCount === 1 ? jsonResponse(documentResponse) : refreshResponse; } - return documentListResponse(); + return collectionListResponse(); }); vi.stubGlobal("fetch", fetchMock); - render(); + render(); await screen.findByRole("heading", { name: "Review" }); window.dispatchEvent(new Event("focus")); @@ -522,14 +549,16 @@ describe("saved document index", () => { vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL) => - String(input).endsWith("/feedback") + String(input) === "/api/collections" + ? collectionListResponse() + : String(input).endsWith("/feedback") ? jsonResponse({ latestBatchId: null, batches: [] }) : jsonResponse(documentResponse), ), ); const user = userEvent.setup(); - render(); + render(); await user.click( await screen.findByRole("button", { name: "Download" }), @@ -547,50 +576,147 @@ describe("saved document index", () => { expect(revokeObjectURL).toHaveBeenCalledWith("blob:pena-markdown"); }); - it("moves an active document to another workspace", async () => { + it("moves a root document into a collection", async () => { + let currentDocument = documentResponse; const fetchMock = vi.fn( async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); - if (url === "/api/workspaces") { - return jsonResponse({ - workspaces: [ - { - slug: "default", - name: "Default", - documentCount: 1, - createdAt: "2026-07-18T10:00:00.000Z", - updatedAt: "2026-07-18T10:00:00.000Z", - }, - { - slug: "research", - name: "Research", - documentCount: 0, - createdAt: "2026-07-18T10:00:00.000Z", - updatedAt: "2026-07-18T10:00:00.000Z", - }, - ], - }); + if (url === "/api/collections") { + return collectionListResponse([researchCollection]); + } + + if (url.endsWith("/feedback")) { + return jsonResponse({ latestBatchId: null, batches: [] }); + } + + if (url === `${DOCUMENT_URL}/move` && init?.method === "POST") { + currentDocument = { ...documentResponse, collectionSlug: "research" }; + return jsonResponse( + { ...currentDocument, excerpt: "" }, + 200, + '"pena-test-2"', + ); + } + + return jsonResponse(currentDocument); + }, + ); + vi.stubGlobal("fetch", fetchMock); + const user = userEvent.setup(); + + render(); + + await user.click(await screen.findByRole("button", { name: "Move" })); + const destination = screen.getByRole("combobox", { + name: "Destination collection", + }) as HTMLSelectElement; + // The document already sits at the root, so only collections are offered. + expect( + [...destination.options].map((option) => [option.value, option.text]), + ).toEqual([["research", "Research"]]); + expect(destination.value).toBe("research"); + await user.click(screen.getByRole("button", { name: "Move document" })); + + expect(await screen.findByText("Moved to Research.")).toBeTruthy(); + expect(fetchMock).toHaveBeenCalledWith(`${DOCUMENT_URL}/move`, { + method: "POST", + // The move is conditional on the exact document state that was loaded. + headers: { + "content-type": "application/json", + "if-match": '"pena-test-1"', + }, + body: JSON.stringify({ collectionSlug: "research" }), + }); + // The URL stays the same, so the page reloads the document in place. + expect( + screen.getByRole("link", { name: "Research" }).getAttribute("href"), + ).toBe("/collections/research"); + expect( + screen.queryByRole("combobox", { name: "Destination collection" }), + ).toBeNull(); + }); + + it("moves a filed document back to the root", async () => { + let currentDocument = { + ...documentResponse, + collectionSlug: "research" as string | null, + }; + const fetchMock = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + + if (url === "/api/collections") { + return collectionListResponse([ + { ...researchCollection, documentCount: 1 }, + writingCollection, + ]); } - if (url === "/api/workspaces/default/documents") { - return documentListResponse(); + if (url.endsWith("/feedback")) { + return jsonResponse({ latestBatchId: null, batches: [] }); + } + + if (url === `${DOCUMENT_URL}/move` && init?.method === "POST") { + currentDocument = { ...documentResponse, collectionSlug: null }; + return jsonResponse( + { ...currentDocument, excerpt: "" }, + 200, + '"pena-test-2"', + ); + } + + return jsonResponse(currentDocument); + }, + ); + vi.stubGlobal("fetch", fetchMock); + const user = userEvent.setup(); + + render(); + + await user.click(await screen.findByRole("button", { name: "Move" })); + const destination = screen.getByRole("combobox", { + name: "Destination collection", + }) as HTMLSelectElement; + // Root comes first; the current collection is not a destination. + expect( + [...destination.options].map((option) => [option.value, option.text]), + ).toEqual([ + ["", "Root"], + ["writing", "Writing"], + ]); + expect(destination.value).toBe(""); + await user.click(screen.getByRole("button", { name: "Move document" })); + + expect(await screen.findByText("Moved to the root.")).toBeTruthy(); + expect(fetchMock).toHaveBeenCalledWith(`${DOCUMENT_URL}/move`, { + method: "POST", + headers: { + "content-type": "application/json", + "if-match": '"pena-test-1"', + }, + body: JSON.stringify({ collectionSlug: null }), + }); + expect(screen.queryByRole("link", { name: "Research" })).toBeNull(); + }); + + it("reports a failed move without leaving the page", async () => { + const fetchMock = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + + if (url === "/api/collections") { + return collectionListResponse([researchCollection]); } if (url.endsWith("/feedback")) { return jsonResponse({ latestBatchId: null, batches: [] }); } - if ( - url === "/api/workspaces/default/documents/review/move" && - init?.method === "POST" - ) { + if (url === `${DOCUMENT_URL}/move` && init?.method === "POST") { return jsonResponse( - { - error: - 'A document with slug "review" already exists in workspace "research".', - }, - 409, + { error: 'No collection exists with slug "research".' }, + 404, ); } @@ -600,47 +726,146 @@ describe("saved document index", () => { vi.stubGlobal("fetch", fetchMock); const user = userEvent.setup(); - render(); + render(); - await screen.findByRole("button", { name: "Move" }); - await user.click(screen.getByRole("button", { name: "Move" })); - expect( - (screen.getByRole("combobox", { - name: "Destination workspace", - }) as HTMLSelectElement).value, - ).toBe("research"); + await user.click(await screen.findByRole("button", { name: "Move" })); await user.click(screen.getByRole("button", { name: "Move document" })); expect( - await screen.findByText( - 'A document with slug "review" already exists in workspace "research".', - ), + await screen.findByText('No collection exists with slug "research".'), ).toBeTruthy(); - expect(fetchMock).toHaveBeenCalledWith( - "/api/workspaces/default/documents/review/move", - { - method: "POST", - // The move is conditional on the exact document state that was loaded. - headers: { - "content-type": "application/json", - "if-match": '"pena-test-1"', - }, - body: JSON.stringify({ workspaceSlug: "research" }), + expect(screen.getAllByRole("heading", { name: "Review" })).toHaveLength(1); + }); + + it("hides the move action when there is nowhere to move to", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => + String(input) === "/api/collections" + ? collectionListResponse() + : String(input).endsWith("/feedback") + ? jsonResponse({ latestBatchId: null, batches: [] }) + : jsonResponse(documentResponse), + ), + ); + + render(); + + await screen.findByRole("button", { name: "Archive" }); + expect(screen.queryByRole("button", { name: "Move" })).toBeNull(); + }); + + it("archives the document and returns to its collection", async () => { + const assign = vi.fn(); + Object.defineProperty(window, "location", { + configurable: true, + value: { ...window.location, assign, pathname: "/docs/review" }, + }); + const filedDocument = { ...documentResponse, collectionSlug: "research" }; + const fetchMock = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + + if (url === "/api/collections") { + return collectionListResponse([researchCollection]); + } + + if (url.endsWith("/feedback")) { + return jsonResponse({ latestBatchId: null, batches: [] }); + } + + if (url === DOCUMENT_URL && init?.method === "PATCH") { + return jsonResponse( + { + ...filedDocument, + archivedAt: "2026-07-19T10:00:00.000Z", + excerpt: "", + }, + 200, + '"pena-test-2"', + ); + } + + return jsonResponse(filedDocument); + }, + ); + vi.stubGlobal("fetch", fetchMock); + const user = userEvent.setup(); + + render(); + + await user.click(await screen.findByRole("button", { name: "Archive" })); + + await waitFor(() => + expect(assign).toHaveBeenCalledWith("/collections/research"), + ); + expect(fetchMock).toHaveBeenCalledWith(DOCUMENT_URL, { + method: "PATCH", + headers: { + "content-type": "application/json", + "if-match": '"pena-test-1"', }, + body: JSON.stringify({ status: "archived" }), + }); + }); + + it("archives a root document and returns to the dashboard", async () => { + const assign = vi.fn(); + Object.defineProperty(window, "location", { + configurable: true, + value: { ...window.location, assign, pathname: "/docs/review" }, + }); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => + String(input) === "/api/collections" + ? collectionListResponse() + : String(input).endsWith("/feedback") + ? jsonResponse({ latestBatchId: null, batches: [] }) + : init?.method === "PATCH" + ? jsonResponse( + { + ...documentResponse, + archivedAt: "2026-07-19T10:00:00.000Z", + excerpt: "", + }, + 200, + '"pena-test-2"', + ) + : jsonResponse(documentResponse), + ), ); + const user = userEvent.setup(); + + render(); + + await user.click(await screen.findByRole("button", { name: "Archive" })); + + await waitFor(() => expect(assign).toHaveBeenCalledWith("/")); }); - it("outlines the document and links back to the workspace", async () => { + it("outlines the document and links back through its collections", async () => { + const nestedCollection = { + ...writingCollection, + slug: "payments", + name: "Payments", + parentSlug: "research", + }; vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL) => - String(input).endsWith("/feedback") + String(input) === "/api/collections" + ? collectionListResponse([ + { ...researchCollection, childCount: 1 }, + nestedCollection, + ]) + : String(input).endsWith("/feedback") ? jsonResponse({ latestBatchId: null, batches: [] }) - : jsonResponse(documentResponse), + : jsonResponse({ ...documentResponse, collectionSlug: "payments" }), ), ); - render(); + render(); const outline = await screen.findByRole("complementary", { name: "Document outline", @@ -669,24 +894,54 @@ describe("saved document index", () => { "--outline-indent", )).toBe("12px"); + // The breadcrumb walks from the root through every ancestor collection. + const breadcrumb = screen.getByRole("navigation", { name: "Breadcrumb" }); + const crumbs = [...breadcrumb.querySelectorAll("a")].map((link) => [ + link.textContent, + link.getAttribute("href"), + ]); + expect(crumbs).toEqual([ + ["All documents", "/"], + ["Research", "/collections/research"], + ["Payments", "/collections/payments"], + ]); + // The archive link in the utility bar is scoped to the same collection. expect( - screen - .getByRole("link", { name: "default" }) - .getAttribute("href"), - ).toBe("/workspaces/default"); + screen.getByRole("link", { name: "Archive" }).getAttribute("href"), + ).toBe("/archive?collection=payments"); }); -}); -function documentListResponse(): Response { - return jsonResponse({ - documents: [ - { - slug: "review", - version: 1, - updatedAt: "2026-07-18T10:00:00.000Z", - }, - ], + it("links a root document straight back to the dashboard", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => + String(input) === "/api/collections" + ? collectionListResponse() + : String(input).endsWith("/feedback") + ? jsonResponse({ latestBatchId: null, batches: [] }) + : jsonResponse(documentResponse), + ), + ); + + render(); + + await screen.findByRole("heading", { name: "Review" }); + const breadcrumb = screen.getByRole("navigation", { name: "Breadcrumb" }); + const crumbs = [...breadcrumb.querySelectorAll("a")].map((link) => [ + link.textContent, + link.getAttribute("href"), + ]); + expect(crumbs).toEqual([["All documents", "/"]]); + expect( + screen.getByRole("link", { name: "Archive" }).getAttribute("href"), + ).toBe("/archive"); }); +}); + +function collectionListResponse( + collections: unknown[] = [], +): Response { + return jsonResponse({ collections }); } function jsonResponse( diff --git a/apps/web/src/features/document-review/DocumentReviewPage.tsx b/apps/web/src/features/document-review/DocumentReviewPage.tsx index df4fbf6..d413b89 100644 --- a/apps/web/src/features/document-review/DocumentReviewPage.tsx +++ b/apps/web/src/features/document-review/DocumentReviewPage.tsx @@ -1,7 +1,7 @@ import { parseDecisionDocument, + type CollectionSummary, type PenaDocument, - type WorkspaceSummary, } from "@pena/contracts"; import { useCallback, @@ -12,12 +12,18 @@ import { import { archiveDocument, + fetchCollections, fetchDocument, fetchFeedback, - fetchWorkspaces, moveDocument, submitFeedback, } from "../../api"; +import { + buildCollectionTree, + collectionPath, + flattenCollectionTree, + formatCollectionPath, +} from "../../collections"; import { formatClockTime, formatRelativeTime } from "../../format"; import { isSubmitAllShortcut } from "../../shortcuts"; import { DocumentViewer } from "./components/DocumentViewer"; @@ -32,6 +38,7 @@ import { } from "./decision-feedback"; import { downloadMarkdown } from "./markdown-download"; import type { OutlineSection } from "./outline"; +import { collectionHref } from "./routing"; import type { DraftComment, DraftDecision, @@ -41,15 +48,14 @@ import type { interface DocumentReviewPageProps { documentSlug: string; - workspaceSlug: string; } const COMPACT_FEEDBACK_BREAKPOINT = 760; -export function DocumentReviewPage({ - documentSlug, - workspaceSlug, -}: DocumentReviewPageProps) { +/** Select value that stands for the root, since a slug can never be empty. */ +const ROOT_DESTINATION = ""; + +export function DocumentReviewPage({ documentSlug }: DocumentReviewPageProps) { const [currentDocument, setCurrentDocument] = useState( null, ); @@ -65,8 +71,8 @@ export function DocumentReviewPage({ const [isArchiving, setIsArchiving] = useState(false); const [isMoving, setIsMoving] = useState(false); const [isMoveOpen, setIsMoveOpen] = useState(false); - const [moveDestination, setMoveDestination] = useState(""); - const [workspaces, setWorkspaces] = useState([]); + const [moveDestination, setMoveDestination] = useState(ROOT_DESTINATION); + const [collections, setCollections] = useState([]); const [draftFeedback, setDraftFeedback] = useState([]); const [feedbackInstruction, setFeedbackInstruction] = useState(""); const [isInstructionComposerOpen, setIsInstructionComposerOpen] = @@ -88,7 +94,7 @@ export function DocumentReviewPage({ setNotice(null); try { - const resource = await fetchDocument(workspaceSlug, documentSlug); + const resource = await fetchDocument(documentSlug); if (!resource) { setCurrentDocument(null); @@ -102,11 +108,7 @@ export function DocumentReviewPage({ const nextSubmittedDecisions = parsedDocument.decisions.length > 0 ? readSubmittedDecisions( - await fetchFeedback( - workspaceSlug, - documentSlug, - resource.etag, - ), + await fetchFeedback(documentSlug, resource.etag), parsedDocument.decisions, ) : {}; @@ -126,7 +128,7 @@ export function DocumentReviewPage({ } finally { setIsLoading(false); } - }, [documentSlug, workspaceSlug]); + }, [documentSlug]); // The rail lists this document's own headings, so the outline is reported // back by the viewer that renders them rather than fetched. @@ -143,26 +145,24 @@ export function DocumentReviewPage({ }, []); useEffect(() => { - void fetchWorkspaces() - .then((response) => setWorkspaces(response.workspaces ?? [])) - .catch(() => setWorkspaces([])); + void fetchCollections() + .then((response) => setCollections(response.collections ?? [])) + .catch(() => setCollections([])); }, []); useEffect(() => { if (documentSlug) { void loadDocument(); - } else { - window.document.title = `${workspaceSlug} · Pena`; } - }, [documentSlug, loadDocument, workspaceSlug]); + }, [documentSlug, loadDocument]); useEffect(() => { if (currentDocument) { - window.document.title = `${currentDocument.title} · ${workspaceSlug} · Pena`; + window.document.title = `${currentDocument.title} · Pena`; } else if (documentSlug) { - window.document.title = `${documentSlug} · ${workspaceSlug} · Pena`; + window.document.title = `${documentSlug} · Pena`; } - }, [currentDocument, documentSlug, workspaceSlug]); + }, [currentDocument, documentSlug]); // Claude republishes while the window sits in the background. Refetching on // focus replaces the manual refresh button, but never discards a draft. @@ -252,7 +252,6 @@ export function DocumentReviewPage({ } await submitFeedback( - workspaceSlug, documentSlug, { ...(submittedInstruction.trim().length === 0 @@ -331,8 +330,10 @@ export function DocumentReviewPage({ setNotice(null); try { - await archiveDocument(workspaceSlug, documentSlug, documentEtag); - window.location.assign(`/workspaces/${workspaceSlug}`); + await archiveDocument(documentSlug, documentEtag); + window.location.assign( + collectionHref(currentDocument?.collectionSlug ?? null), + ); } catch (error) { setNotice({ kind: "error", @@ -354,31 +355,24 @@ export function DocumentReviewPage({ return; } - const firstDestination = workspaces.find( - (workspace) => workspace.slug !== workspaceSlug, - ); + const firstDestination = moveDestinations[0]; if (!firstDestination) { return; } - setMoveDestination(firstDestination.slug); + setMoveDestination(firstDestination.value); setIsMoveOpen(true); setNotice(null); } function cancelMove(): void { setIsMoveOpen(false); - setMoveDestination(""); + setMoveDestination(ROOT_DESTINATION); } async function handleMove(): Promise { - if ( - !documentSlug || - !currentDocument || - !documentEtag || - !moveDestination - ) { + if (!documentSlug || !currentDocument || !documentEtag) { return; } @@ -394,34 +388,59 @@ export function DocumentReviewPage({ setNotice(null); try { + const destination = + moveDestination === ROOT_DESTINATION ? null : moveDestination; const movedDocument = await moveDocument( - workspaceSlug, documentSlug, - moveDestination, + destination, documentEtag, ); - window.location.assign( - `/workspaces/${movedDocument.workspaceSlug}/documents/${movedDocument.slug}`, - ); + // The URL does not change, so the page reloads its own state. + setIsMoveOpen(false); + setMoveDestination(ROOT_DESTINATION); + await loadDocument(); + setNotice({ + kind: "success", + message: + movedDocument.collectionSlug === null + ? "Moved to the root." + : `Moved to ${ + formatCollectionPath(collections, movedDocument.collectionSlug) || + movedDocument.collectionSlug + }.`, + }); } catch (error) { setNotice({ kind: "error", message: error instanceof Error ? error.message : "Could not move the document.", }); + } finally { setIsMoving(false); } } - const moveDestinations = workspaces.filter( - (workspace) => workspace.slug !== workspaceSlug, - ); + const currentCollectionSlug = currentDocument?.collectionSlug ?? null; + // Every collection except the current one, plus the root when the document + // is not already there. Nested names are indented like a folder tree. + const moveDestinations = [ + ...(currentCollectionSlug === null + ? [] + : [{ value: ROOT_DESTINATION, label: "Root" }]), + ...flattenCollectionTree(buildCollectionTree(collections)) + .filter(({ collection }) => collection.slug !== currentCollectionSlug) + .map(({ collection, depth }) => ({ + value: collection.slug, + label: `${" ".repeat(depth)}${collection.name}`, + })), + ]; + const breadcrumbPath = collectionPath(collections, currentCollectionSlug); return (