From 7ddd68e6a6dd08f313a418387b1075eaf7b6a6bd Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Thu, 3 Sep 2026 10:37:15 +0200 Subject: [PATCH 01/16] feat(branches): site branches with preview links, three-way merge, and version restore Every content row, table, and the site shell now carry a branch: main is the live site, and a branch is a private fork edited through the same editor, addressed by the X-Instatic-Branch header on every admin request and by branch-qualified collab doc ids. Branches are created, switched, renamed, and deleted from a toolbar chip and a context strip; a branch can be shared through a revocable preview link, updated from main, and merged into main through a three-way review that surfaces conflicts per field. Published versions of a page can be listed and restored into the draft. Publishing, scheduling, public routes, forms, plugins, the dashboard, and MCP headless reads stay pinned to main. The collab relay refuses documents for deleted branches, keeps its invalidation bookkeeping per branch, and reseeds a branch from its rows if a delete fails after it was tombstoned. Verification: bunx tsc -b, bun test, bun run build, bun run lint, and Playwright tests/e2e/branches.e2e.ts + tests/e2e/version-history.e2e.ts. --- docs/README.md | 1 + docs/architecture.md | 3 +- docs/e2e/feature-matrix.md | 15 + docs/e2e/feature-validation.tsv | 6 + docs/editor.md | 2 + docs/features/audit-log.md | 2 + docs/features/branches.md | 188 ++++++++ docs/features/content-storage.md | 10 +- docs/features/loops.md | 2 +- docs/features/publisher.md | 2 + docs/features/site-shell.md | 11 +- docs/features/site-transfer.md | 1 + docs/features/spotlight.md | 3 + docs/reference/architecture-tests.md | 1 + docs/reference/capabilities.md | 3 +- docs/reference/database-dialects.md | 18 + docs/reference/persistence-keys.md | 4 +- docs/server.md | 33 +- server/ai/content/treeService.test.ts | 9 +- server/ai/content/treeService.ts | 13 +- server/ai/drivers/types.ts | 2 + server/ai/handlers/chat.ts | 7 + server/ai/mcp/contentAuthorization.test.ts | 7 +- server/ai/mcp/contentAuthorization.ts | 3 +- server/ai/mcp/publishTool.test.ts | 5 +- server/ai/mcp/server.test.ts | 3 +- server/ai/mcp/server.ts | 4 + server/ai/mcp/tools/contextTool.ts | 5 +- server/ai/mcp/tools/documentTools.ts | 3 +- server/ai/mcp/tools/styleTools.test.ts | 4 +- server/ai/mcp/tools/styleTools.ts | 5 +- server/ai/runtime/types.ts | 5 + server/ai/tools/content/readTools.test.ts | 6 +- server/ai/tools/content/readTools.ts | 12 +- server/ai/tools/site/readTools.test.ts | 6 +- server/ai/tools/site/readTools.ts | 4 +- server/auth/capabilities.ts | 1 + server/branches/contentHash.ts | 102 ++++ server/branches/deleteBranch.ts | 52 ++ server/branches/entities.ts | 72 +++ server/branches/fork.ts | 149 ++++++ server/branches/merge.ts | 443 ++++++++++++++++++ server/branches/previewLinks.ts | 98 ++++ server/branches/scope.ts | 64 +++ server/collab/relay.ts | 275 ++++++----- server/collab/relayBranches.ts | 113 +++++ server/collab/relayInvalidations.ts | 73 +++ server/collab/relayPersistence.ts | 131 ++++-- server/collab/relayResetQueue.ts | 152 ++++++ server/collab/socket.ts | 11 + server/db/migrations-pg.ts | 101 ++++ server/db/migrations-sqlite.ts | 109 +++++ server/forms/handler.ts | 7 +- server/handlers/cms/branches.ts | 309 ++++++++++++ server/handlers/cms/components.ts | 9 +- server/handlers/cms/dashboard/activity.ts | 2 +- server/handlers/cms/dashboard/posts.ts | 6 +- .../handlers/cms/dashboard/publishLineup.ts | 9 +- server/handlers/cms/dashboard/shared.ts | 6 +- server/handlers/cms/data/index.ts | 10 +- server/handlers/cms/data/meta.ts | 4 +- server/handlers/cms/data/preview.ts | 10 +- server/handlers/cms/data/rows.ts | 172 +++++-- server/handlers/cms/data/search.ts | 4 +- server/handlers/cms/data/tables.ts | 53 ++- server/handlers/cms/export.ts | 15 +- server/handlers/cms/import.ts | 53 ++- server/handlers/cms/importArchive.ts | 4 +- server/handlers/cms/importPreview.ts | 6 +- server/handlers/cms/index.ts | 34 +- server/handlers/cms/layouts.ts | 9 +- server/handlers/cms/pages.ts | 9 +- server/handlers/cms/plugins/pack.ts | 23 +- server/handlers/cms/publish.ts | 10 + server/handlers/cms/runtime.ts | 9 +- server/handlers/cms/setup.ts | 6 +- server/handlers/cms/shared.ts | 11 +- server/handlers/cms/site.ts | 11 +- server/handlers/cms/siteDocument.ts | 38 +- server/index.ts | 1 + server/plugins/host/contentFieldMapping.ts | 3 +- server/plugins/host/handlers/content.ts | 53 ++- .../host/handlers/contentProjection.ts | 5 +- server/publish/branchPreview.ts | 233 +++++++++ server/publish/branchPreviewAssets.ts | 54 +++ server/publish/contentEvents.ts | 28 +- server/publish/loopPrefetch.ts | 12 +- server/publish/publicRouter.ts | 4 +- server/publish/publicRoutes.ts | 85 ++++ server/publish/publishScheduler.ts | 5 +- server/publish/publishSite.ts | 3 +- server/publish/republish.ts | 3 +- server/publish/runtime/previewRuntime.ts | 4 +- server/repositories/audit.ts | 9 + server/repositories/branchBases.ts | 69 +++ server/repositories/branchPreviews.ts | 87 ++++ server/repositories/branches.ts | 105 +++++ server/repositories/collabDocuments.ts | 19 + .../data/__tests__/tables.test.ts | 27 +- server/repositories/data/index.ts | 8 +- server/repositories/data/publish.ts | 7 +- .../data/rows/__tests__/apply.test.ts | 23 +- .../data/rows/__tests__/filter.test.ts | 19 +- .../data/rows/__tests__/mutations.test.ts | 26 +- .../data/rows/__tests__/read.test.ts | 25 +- server/repositories/data/rows/apply.ts | 34 +- server/repositories/data/rows/bulk.ts | 13 +- server/repositories/data/rows/filter.ts | 8 +- server/repositories/data/rows/import.ts | 27 +- server/repositories/data/rows/mapper.ts | 52 +- server/repositories/data/rows/mutations.ts | 122 +++-- server/repositories/data/rows/read.ts | 65 ++- server/repositories/data/rows/schedule.ts | 26 +- server/repositories/data/rows/search.ts | 12 +- server/repositories/data/tables.ts | 128 +++-- server/repositories/data/versions.ts | 85 ++++ server/repositories/publish.ts | 36 +- server/repositories/rowWriteEvents.ts | 14 +- server/repositories/setup.ts | 7 +- server/repositories/site.ts | 39 +- server/router.ts | 93 +--- server/serverRuntime.ts | 36 ++ src/__tests__/agent/agentTools.test.ts | 3 + src/__tests__/ai/mcpContextTool.test.ts | 2 + src/__tests__/ai/mcpStyleTool.test.ts | 10 +- .../branch-scope-repositories.test.ts | 137 ++++++ .../architecture/bundle-size-budgets.test.ts | 7 +- .../architecture/cmsTransferExport.test.ts | 62 +-- .../architecture/cmsTransferImport.test.ts | 51 +- .../architecture/cmsTransferPreview.test.ts | 35 +- .../dispatcher-html-pipeline.test.ts | 23 +- .../import-export-roundtrip.test.ts | 95 ++-- src/__tests__/collab/applyPatches.test.ts | 30 +- src/__tests__/collab/awareness.test.tsx | 18 +- src/__tests__/collab/collabNotices.test.ts | 8 +- .../collab/inlineEditRemoteMerge.test.tsx | 2 +- src/__tests__/collab/provider.test.ts | 43 +- src/__tests__/core/branches/ids.test.ts | 49 ++ .../core/branches/threeWayMerge.test.ts | 71 +++ src/__tests__/data/contentAdmin.test.tsx | 4 +- src/__tests__/fixtures/storeIsolation.ts | 2 + src/__tests__/server/branchMerge.test.ts | 257 ++++++++++ .../server/branchPreviewLinks.test.ts | 128 +++++ src/__tests__/server/branchScope.test.ts | 172 +++++++ src/__tests__/server/branchesHandler.test.ts | 226 +++++++++ .../server/cmsDataAuthorization.test.ts | 2 + src/__tests__/server/cmsPublish.test.ts | 42 +- src/__tests__/server/cmsSiteHandlers.test.ts | 4 +- .../server/cmsSitePersistence.test.ts | 23 +- src/__tests__/server/collabRelay.test.ts | 169 +++---- .../server/collabRelayBranches.test.ts | 272 +++++++++++ .../server/collabRelayIntegration.test.ts | 36 +- .../server/collabUpdateGuard.test.ts | 28 +- src/__tests__/server/dataCms.test.ts | 22 +- src/__tests__/server/dataMetaRoute.test.ts | 3 +- .../server/importEndpointGuidance.test.ts | 4 +- .../server/postTypeBuiltInFields.test.ts | 41 +- src/__tests__/server/publicForms.test.ts | 22 +- .../server/publishRebakeTemplate.test.ts | 3 +- .../publishRuntimeErrorResponse.test.ts | 3 +- src/__tests__/server/publishScheduler.test.ts | 3 +- .../server/publishStaticArtefact.test.ts | 4 +- src/__tests__/server/rowVersions.test.ts | 70 +++ src/admin/AuthenticatedAdmin.tsx | 11 +- src/admin/ai/useMcpWorkspaceBridge.ts | 5 +- src/admin/main.tsx | 4 + .../pages/content/ContentPage.module.css | 9 + src/admin/pages/content/ContentPage.tsx | 1 + .../ContentSettingsPanel.tsx | 12 +- .../ContentToolbar/ContentToolbar.tsx | 47 +- src/admin/pages/data/DataPage.tsx | 22 +- .../DataGrid/DataGridBulkActionBar.tsx | 6 + .../DataGrid/DataRowContextMenu.tsx | 7 +- .../components/ExportDialog/ExportDialog.tsx | 5 + src/admin/pages/site/agent/agentSlice.ts | 6 +- src/admin/pages/site/collab/awarenessState.ts | 6 +- src/admin/pages/site/collab/collabProvider.ts | 32 +- src/admin/pages/site/hooks/usePersistence.ts | 33 +- .../site/panels/AgentPanel/AgentPanel.tsx | 2 +- .../panels/AgentPanel/ConversationHistory.tsx | 2 +- .../site/store/slices/site/collabBinding.ts | 44 +- .../site/store/slices/site/collabBranch.ts | 47 ++ .../site/store/slices/site/collabNotices.ts | 4 +- .../pages/site/toolbar/PublishButton.tsx | 66 ++- src/admin/pages/site/toolbar/Toolbar.tsx | 9 +- src/admin/pages/users/utils/capabilities.ts | 1 + .../shared/BranchSwitcher/BranchChip.tsx | 408 ++++++++++++++++ .../BranchSwitcher/BranchContextStrip.tsx | 272 +++++++++++ .../BranchSwitcher/BranchSwitcher.module.css | 216 +++++++++ .../BranchSwitcher/DeleteBranchDialog.tsx | 73 +++ .../ManageBranchesDialog.module.css | 121 +++++ .../BranchSwitcher/ManageBranchesDialog.tsx | 312 ++++++++++++ .../MergeBranchDialog.module.css | 101 ++++ .../BranchSwitcher/MergeBranchDialog.tsx | 250 ++++++++++ .../shared/BranchSwitcher/branchAccent.ts | 14 + src/admin/shared/BranchSwitcher/branchTime.ts | 11 + src/admin/shared/BranchSwitcher/index.ts | 3 + .../shared/CapabilityPicker/capabilityMeta.ts | 4 + .../VersionHistoryDialog.module.css | 86 ++++ .../VersionHistoryDialog.tsx | 162 +++++++ .../shared/VersionHistoryDialog/index.ts | 1 + src/admin/spotlight/SpotlightResults.tsx | 1 + src/admin/spotlight/SpotlightRow.tsx | 4 + src/admin/spotlight/builtinCommands.ts | 2 + src/admin/spotlight/commands/branches.ts | 72 +++ src/admin/spotlight/commands/editor.ts | 3 + src/admin/spotlight/groupAccent.ts | 1 + src/admin/spotlight/matcher.ts | 1 + .../spotlight/providers/branchesProvider.ts | 40 ++ src/admin/spotlight/scopes/rootScope.ts | 2 + src/admin/spotlight/types.ts | 1 + src/admin/state/activeBranch.ts | 70 +++ src/admin/state/branchStore.ts | 308 ++++++++++++ src/core/branches/ids.ts | 70 +++ src/core/branches/index.ts | 44 ++ src/core/branches/schemas.ts | 106 +++++ src/core/branches/threeWayMerge.ts | 60 +++ src/core/capabilities.ts | 3 + src/core/collab/applyPatches.ts | 24 +- src/core/collab/docIds.ts | 54 ++- src/core/collab/index.ts | 4 +- src/core/collab/protocol.ts | 6 +- src/core/data/bundleSchema.ts | 6 + src/core/data/schemas.ts | 16 + src/core/http/apiClient.ts | 75 ++- src/core/http/index.ts | 9 + src/core/http/requestHeaders.ts | 45 ++ src/core/loops/sources/dataRows.ts | 35 +- src/core/loops/types.ts | 7 + src/core/persistence/cms.ts | 4 +- src/core/persistence/cmsBranches.ts | 109 +++++ src/core/persistence/cmsData.ts | 39 +- src/core/persistence/cmsFonts.ts | 4 +- src/core/persistence/cmsTransfer.ts | 17 +- src/core/persistence/index.ts | 13 + src/core/publisher/render.ts | 11 +- src/core/utils/canonicalJson.ts | 17 + .../AgentPanel => core/utils}/relativeTime.ts | 0 tests/e2e/branches.e2e.ts | 213 +++++++++ tests/e2e/version-history.e2e.ts | 36 ++ .../dist/icons/archive-restore-solid.d.ts | 3 + .../dist/icons/archive-restore-solid.js | 4 + .../dist/icons/circle-dot-solid.d.ts | 3 + .../dist/icons/circle-dot-solid.js | 4 + .../dist/icons/git-branch-solid.d.ts | 3 + .../dist/icons/git-branch-solid.js | 4 + .../dist/icons/git-merge-solid.d.ts | 3 + .../dist/icons/git-merge-solid.js | 4 + .../dist/icons/share-solid.d.ts | 3 + .../pixel-art-icons/dist/icons/share-solid.js | 4 + .../icons/archive-restore-solid.tsx | 18 + .../icons/circle-dot-solid.tsx | 18 + .../icons/git-branch-solid.tsx | 18 + .../pixel-art-icons/icons/git-merge-solid.tsx | 18 + vendor/pixel-art-icons/icons/share-solid.tsx | 18 + 255 files changed, 9941 insertions(+), 1224 deletions(-) create mode 100644 docs/features/branches.md create mode 100644 server/branches/contentHash.ts create mode 100644 server/branches/deleteBranch.ts create mode 100644 server/branches/entities.ts create mode 100644 server/branches/fork.ts create mode 100644 server/branches/merge.ts create mode 100644 server/branches/previewLinks.ts create mode 100644 server/branches/scope.ts create mode 100644 server/collab/relayBranches.ts create mode 100644 server/collab/relayInvalidations.ts create mode 100644 server/collab/relayResetQueue.ts create mode 100644 server/handlers/cms/branches.ts create mode 100644 server/publish/branchPreview.ts create mode 100644 server/publish/branchPreviewAssets.ts create mode 100644 server/publish/publicRoutes.ts create mode 100644 server/repositories/branchBases.ts create mode 100644 server/repositories/branchPreviews.ts create mode 100644 server/repositories/branches.ts create mode 100644 server/serverRuntime.ts create mode 100644 src/__tests__/architecture/branch-scope-repositories.test.ts create mode 100644 src/__tests__/core/branches/ids.test.ts create mode 100644 src/__tests__/core/branches/threeWayMerge.test.ts create mode 100644 src/__tests__/server/branchMerge.test.ts create mode 100644 src/__tests__/server/branchPreviewLinks.test.ts create mode 100644 src/__tests__/server/branchScope.test.ts create mode 100644 src/__tests__/server/branchesHandler.test.ts create mode 100644 src/__tests__/server/collabRelayBranches.test.ts create mode 100644 src/__tests__/server/rowVersions.test.ts create mode 100644 src/admin/pages/site/store/slices/site/collabBranch.ts create mode 100644 src/admin/shared/BranchSwitcher/BranchChip.tsx create mode 100644 src/admin/shared/BranchSwitcher/BranchContextStrip.tsx create mode 100644 src/admin/shared/BranchSwitcher/BranchSwitcher.module.css create mode 100644 src/admin/shared/BranchSwitcher/DeleteBranchDialog.tsx create mode 100644 src/admin/shared/BranchSwitcher/ManageBranchesDialog.module.css create mode 100644 src/admin/shared/BranchSwitcher/ManageBranchesDialog.tsx create mode 100644 src/admin/shared/BranchSwitcher/MergeBranchDialog.module.css create mode 100644 src/admin/shared/BranchSwitcher/MergeBranchDialog.tsx create mode 100644 src/admin/shared/BranchSwitcher/branchAccent.ts create mode 100644 src/admin/shared/BranchSwitcher/branchTime.ts create mode 100644 src/admin/shared/BranchSwitcher/index.ts create mode 100644 src/admin/shared/VersionHistoryDialog/VersionHistoryDialog.module.css create mode 100644 src/admin/shared/VersionHistoryDialog/VersionHistoryDialog.tsx create mode 100644 src/admin/shared/VersionHistoryDialog/index.ts create mode 100644 src/admin/spotlight/commands/branches.ts create mode 100644 src/admin/spotlight/providers/branchesProvider.ts create mode 100644 src/admin/state/activeBranch.ts create mode 100644 src/admin/state/branchStore.ts create mode 100644 src/core/branches/ids.ts create mode 100644 src/core/branches/index.ts create mode 100644 src/core/branches/schemas.ts create mode 100644 src/core/branches/threeWayMerge.ts create mode 100644 src/core/http/requestHeaders.ts create mode 100644 src/core/persistence/cmsBranches.ts create mode 100644 src/core/utils/canonicalJson.ts rename src/{admin/pages/site/panels/AgentPanel => core/utils}/relativeTime.ts (100%) create mode 100644 tests/e2e/branches.e2e.ts create mode 100644 tests/e2e/version-history.e2e.ts create mode 100644 vendor/pixel-art-icons/dist/icons/archive-restore-solid.d.ts create mode 100644 vendor/pixel-art-icons/dist/icons/archive-restore-solid.js create mode 100644 vendor/pixel-art-icons/dist/icons/circle-dot-solid.d.ts create mode 100644 vendor/pixel-art-icons/dist/icons/circle-dot-solid.js create mode 100644 vendor/pixel-art-icons/dist/icons/git-branch-solid.d.ts create mode 100644 vendor/pixel-art-icons/dist/icons/git-branch-solid.js create mode 100644 vendor/pixel-art-icons/dist/icons/git-merge-solid.d.ts create mode 100644 vendor/pixel-art-icons/dist/icons/git-merge-solid.js create mode 100644 vendor/pixel-art-icons/dist/icons/share-solid.d.ts create mode 100644 vendor/pixel-art-icons/dist/icons/share-solid.js create mode 100644 vendor/pixel-art-icons/icons/archive-restore-solid.tsx create mode 100644 vendor/pixel-art-icons/icons/circle-dot-solid.tsx create mode 100644 vendor/pixel-art-icons/icons/git-branch-solid.tsx create mode 100644 vendor/pixel-art-icons/icons/git-merge-solid.tsx create mode 100644 vendor/pixel-art-icons/icons/share-solid.tsx diff --git a/docs/README.md b/docs/README.md index 2b1606817..9ae42889e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -144,6 +144,7 @@ Three categories, three voices: | [features/data-workspace.md](features/data-workspace.md) | Data workspace UI: DataInspector, field management, DataGrid | | [features/auth-and-access.md](features/auth-and-access.md) | Sessions, MFA, step-up, lockout, CSRF, capabilities | | [features/site-shell.md](features/site-shell.md) | The persisted site config (breakpoints, classes, files, deps) | +| [features/branches.md](features/branches.md) | Site branches: fork, edit in isolation, preview links, three-way merge, version restore | | [features/modules.md](features/modules.md) | Module engine, defining first-party blocks | | [features/dashboard.md](features/dashboard.md) | Dashboard workspace, widgets, grid, customize mode | | [features/spotlight.md](features/spotlight.md) | Cmd+K command palette | diff --git a/docs/architecture.md b/docs/architecture.md index 036acc8a4..367ecea02 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -152,7 +152,7 @@ server/router.ts ← match path → 301 redirect / 200 HTML / 404 ``` -Handlers validate request bodies with TypeBox before doing work, talk to repositories for persistence, and return `{ error: string }` envelopes on failure. Validation helpers live in `server/http.ts`. Per-handler logging uses the prefix `console.error('[]', err)`. +Handlers validate request bodies with TypeBox before doing work, talk to repositories for persistence, and return `{ error: string }` envelopes on failure. CMS requests carry the site branch they address in the `X-Instatic-Branch` header; the dispatcher resolves it once into a `BranchScope` and public routes always read main (or a branch draft behind a preview cookie). Validation helpers live in `server/http.ts`. Per-handler logging uses the prefix `console.error('[]', err)`. --- @@ -183,6 +183,7 @@ The shape and cell types are defined by the `data_tables` schema. There is no se ### Storage conventions - JSON columns end in `_json`. The SQLite adapter auto-parses any `*_json` string on read and auto-stringifies any plain object on write. Gated by `db-json-column-naming.test.ts`. +- `site`, `data_tables`, and `data_rows` carry `branch_id` (default `main`) and a generated `logical_id`. Every row keeps its logical id on every site branch; the physical primary key is `physicalId(branchId, logicalId)` from `src/core/branches/ids.ts` — the logical id itself on main. Repositories on these tables take an explicit `BranchScope`. See [features/branches.md](features/branches.md). - Migrations are split per dialect with identical IDs. PG uses `jsonb`, `timestamptz`, `bigint`, `distinct on`; SQLite uses `text`, `text`, `integer`, window-function rewrites. Parity gated by `migration-parity.test.ts`. - Repositories use only ANSI-standard SQL. The five Postgres-isms — `now()` in DML, `::int`, `::jsonb`, `any($N::...)`, `distinct on` — are banned in any `DbClient`-importing file. Gated by `db-postgres-isms.test.ts`. diff --git a/docs/e2e/feature-matrix.md b/docs/e2e/feature-matrix.md index 93952c654..236d54227 100644 --- a/docs/e2e/feature-matrix.md +++ b/docs/e2e/feature-matrix.md @@ -134,6 +134,21 @@ CAP-005 note: plugin read/install/configure/lifecycle affordance splits, install Page management note: `page-management.e2e.ts` creates disposable pages from the Site Explorer, verifies new pages appear and open in the canvas, renames a page through the context menu, deletes a page through the confirmation dialog, and switches away from and back to an unsaved edited page before saving/reloading to prove draft state is retained. +## Site Branches And Versions + +| ID | Priority | Auto | Area | User Goal | Setup | Path | Expected Outcome | Watch For | +|---|---:|:---:|---|---|---|---|---|---| +| BRANCH-001 | P1 | ✅ | Branches | Create a branch from the toolbar, edit on it, and return to main | Owner logged in | Toolbar branch chip → Create branch… → strip | The chip shows the branch, the strip appears above the toolbar, Publish is disabled with the reason inline, the branch survives a reload, and "Switch to main" clears the strip | branch lost on reload, publish enabled on a branch, strip missing | +| BRANCH-002 | P1 | ✅ | Branches | Switch branches by search and from the command palette | Owner logged in, a branch exists | Chip search + Enter; ⌘K "Switch to main" | Enter switches to the first match; the palette command returns to main | stale palette results, switch without remount | +| BRANCH-003 | P1 | ✅ | Branches | Rename and delete a branch from the manage dialog | Fresh login (step-up) | Chip → Manage branches… | Search narrows the list and clearing it restores every branch; inline rename updates the row; delete confirms, steps up, and removes the branch | delete without step-up, rename lost | +| BRANCH-004 | P1 | ✅ | Branches | Share a preview link and open it as a visitor | Fresh login (step-up for cleanup) | Strip → Share preview; visitor context opens the URL | The visitor sees the branch draft with the "Previewing branch" banner and can exit; revoking kills the link | banner missing, link still works after revoke, main content shown | +| BRANCH-005 | P1 | ✅ | Branches | Merge a branch into main from the review dialog | Fresh login (step-up) | Strip → Merge into main… | The plan lists the branch-only page as New, merging steps up, the branch is deleted, and the page exists on main | empty plan while the relay still holds the edit, merge without step-up | +| VERSION-001 | P1 | ✅ | Versions | Restore a published version of the active page | Fresh login (step-up for publish) | Publish menu → Version history… | The page's first version is listed as Latest; Restore asks for confirmation, then the draft is replaced and a toast confirms | empty list after publish, restore publishing instead of drafting | + +BRANCH-001 … BRANCH-005 note: `tests/e2e/branches.e2e.ts` drives the real toolbar chip, palette, in-place creator, context strip, manage dialog, preview-link visitor flow (a second browser context without an admin session), and the merge review dialog; every step captures evidence under `.tmp/evidence/branches-*.png`. Tests that step up run on a fresh login because step-up rotates the shared owner session. + +VERSION-001 note: `tests/e2e/version-history.e2e.ts` creates and opens a page of its own (so the history it asserts on is independent of earlier specs), publishes, opens the version list from the publish split menu, and restores version 1 through the inline confirmation. + ## Visual Builder | ID | Priority | Auto | Area | User Goal | Setup | Path | Expected Outcome | Watch For | diff --git a/docs/e2e/feature-validation.tsv b/docs/e2e/feature-validation.tsv index 3043a1528..5d1674692 100644 --- a/docs/e2e/feature-validation.tsv +++ b/docs/e2e/feature-validation.tsv @@ -150,3 +150,9 @@ CONTENT-010 Entry SEO title and description reach the published As a cont ADMIN-009 Settings Escape dismissal after focus loss As an admin user, I want Escape to close the Settings modal even after I click non-focusable chrome so the dialog never traps me. Escape closes the Settings modal after a click on a heading or dead space moves focus to body. Focus on body; focus inside nested controls; backdrop click still works. Escape handling is document-level, not a React onKeyDown scoped to the dialog subtree. src/admin/modals/Settings/SettingsModal.tsx; tests/e2e/settings-escape.e2e.ts Regression spec for the fix shipped in PR #272; unit coverage drives fireEvent at chosen elements, only a browser reproduces real focus state. Happy: open Settings from the Site editor toolbar, click the first heading, press Escape, dialog hides. Passing in the repaired suite run 2026-08-30 0 None Rescued spec recovered from a prior verification session and added as tests/e2e/settings-escape.e2e.ts. Repair details in PR #461. 2026-08-30 ADMIN-010 AI workspace section navigation entry As an owner, I want the AI workspace reachable from the admin section navigation so I do not have to type the URL. A capability-gated AI link renders in the section navigation with href /admin/ai and routes on click. Users without AI capabilities see no entry; landing routes still include /admin/ai. Navigation uses the in-house admin router; the entry is gated on ai.providers.manage or ai.audit.read. src/admin/shared/AdminSectionNavigation/AdminSectionNavigation.tsx; tests/e2e/ai-nav.e2e.ts Regression spec for the fix shipped in PR #346. Happy: from Dashboard, the AI link is visible with the right href and clicking it lands on /admin/ai. Passing in the repaired suite run 2026-08-30 0 None Rescued spec recovered from a prior verification session and added as tests/e2e/ai-nav.e2e.ts. Repair details in PR #461. 2026-08-30 AI-010 Workspace MCP bridge stream stays readable As a connector user, I want the editor bridge stream to survive reverse proxies so connected agents keep working. The bridge responds 200 with content-type text/event-stream, cache-control no-cache, no-transform, and x-accel-buffering no, and the browser client holds the stream open without parse or stream errors. Buffering proxies reframing the body; client tearing the stream down as unreadable. The body stays newline-delimited JSON while the media type advertises an event stream so intermediaries flush incrementally. server/ai/mcp/editorBridge.ts; src/admin/ai/useMcpWorkspaceBridge.ts; tests/e2e/mcp-editor-bridge.e2e.ts Regression spec for the fix shipped in PR #282; unit coverage stubs fetch, only a browser exercises the real stream reader. Happy: open the Site editor, capture the bridge response, assert the streaming headers, and verify no mcp-workspace-bridge console errors for three seconds. Passing in the repaired suite run 2026-08-30 0 None Rescued spec recovered from a prior verification session and added as tests/e2e/mcp-editor-bridge.e2e.ts. Repair details in PR #461. 2026-08-30 +BRANCH-001 Create and switch site branches from the toolbar As an editor, I want to fork the site into a branch and edit it in isolation so live content stays untouched until I merge. The toolbar chip opens a palette with an in-place creator; creating switches the tab, shows the tinted context strip above the toolbar, disables Publish with an inline reason, and the branch persists per tab across reloads. Duplicate slug is refused inline; a deleted branch drops the tab back to main; Publish/Schedule stay disabled everywhere on a branch. Branch ids match /^[a-z0-9][a-z0-9.-]{0,63}$/; creation requires site.branches.manage; the X-Instatic-Branch header scopes every CMS request. src/admin/shared/BranchSwitcher/; src/admin/state/branchStore.ts; server/handlers/cms/branches.ts; tests/e2e/branches.e2e.ts Branch content is a full copy of main; media, plugins, and users are shared. Happy: create Spring Redesign, see the strip and disabled Publish, reload, switch back to main. Passing 2026-09-02 0 None Evidence: .tmp/evidence/branches-1..4-*.png 2026-09-02 +BRANCH-002 Search-to-switch and palette branch commands As an editor, I want to reach any branch by typing its name so switching is instant. Typing in the chip palette filters branches and Enter switches to the first match; the ⌘K palette offers Switch to main and Switch to . No match offers Create … to managers only. Palette rows come from the branch registry; commands are gated on site.read. src/admin/spotlight/commands/branches.ts; src/admin/spotlight/providers/branchesProvider.ts The chip refreshes the registry when opened. Happy: type spring, Enter, then ⌘K Switch to main. Passing 2026-09-02 0 None 2026-09-02 +BRANCH-003 Manage branches dialog As a manager, I want to rename or delete branches in one place. The dialog lists branches with inline rename; delete confirms, steps up, and removes the branch and its content. Main cannot be renamed or deleted; deleting the active branch returns the tab to main. Requires site.branches.manage; delete requires step-up. src/admin/shared/BranchSwitcher/ManageBranchesDialog.tsx; server/branches/deleteBranch.ts Runs on a fresh login because step-up rotates the session. Happy: rename to Spring 2027, delete with step-up. Passing 2026-09-02 0 None Evidence: .tmp/evidence/branches-5-manage.png 2026-09-02 +BRANCH-004 Branch preview links As an editor, I want to share a link that shows a branch draft to someone without an admin account. Share preview issues a tokenised link; opening it sets a cookie and renders the branch draft with a banner and an exit link; revoking kills the link. Sharing again rotates the token; a dead token clears the cookie; a route missing on the branch falls through to the 404 page. Tokens are stored hashed; the cookie is HttpOnly SameSite=Lax; responses are no-store and noindex. server/branches/previewLinks.ts; server/publish/branchPreview.ts; server/publish/publicRoutes.ts A second browser context plays the visitor. Happy: share, open as visitor, exit, revoke, open again. Passing 2026-09-02 0 None Evidence: .tmp/evidence/branches-6-preview-shared.png, branches-7-visitor-preview.png 2026-09-02 +BRANCH-005 Merge a branch into main As a manager, I want to review what a branch changes and land it on main. The review dialog plans a three-way merge (New/Changed/Removed, conflicts with a two-way choice); applying steps up, writes main's draft, and optionally deletes the branch. Unresolved conflicts keep the button disabled and answer 409 server-side; a change that landed after the plan is re-planned on apply. Row status never merges; bases move to the merged result; requires site.branches.manage + step-up. server/branches/merge.ts; src/core/branches/threeWayMerge.ts; src/admin/shared/BranchSwitcher/MergeBranchDialog.tsx The relay is flushed before planning so live edits count. Happy: create Branch Page on the branch, merge with delete, find the page on main. Passing 2026-09-02 0 None Evidence: .tmp/evidence/branches-8-merge-review.png 2026-09-02 +VERSION-001 Page version history and restore As an editor, I want to see the published versions of a page and bring one back into the draft. The publish menu opens Version history; versions list newest first with the live one marked; Restore confirms inline and replaces the draft on the active branch. Unpublished pages show an empty state; restoring never publishes. Requires row edit access; restore is audited as version.restore. src/admin/shared/VersionHistoryDialog/; server/handlers/cms/data/rows.ts; tests/e2e/version-history.e2e.ts Publish steps up, so the spec runs on a fresh login. Happy: publish, open history, restore version 1. Passing 2026-09-02 0 None 2026-09-02 diff --git a/docs/editor.md b/docs/editor.md index 2de45ee41..ad44ca2ec 100644 --- a/docs/editor.md +++ b/docs/editor.md @@ -184,6 +184,8 @@ Every admin page picks one of three root layouts from `src/admin/layouts/`. Impo The `adminUi` store (`src/admin/state/adminUi.ts`) is the small cross-shell state store: settings-modal open flag, site-import modal open flag, site name/favicon for the toolbar brand position, and `activeLivePath` — the public path the "Open live page" toolbar button opens. The toolbar renders a compact skeleton while the site identity is loading, then renders the configured site favicon when present; otherwise it shows the site name with the same compact bold typography as the admin navigation. The site name is exposed through the shared tooltip after identity loads. It lives outside `@site/` so `AdminPageLayout` can subscribe without pulling in the 165 KB editor graph. The editor's `settingsSlice` mirrors its state into `adminUi` via a registered bridge so both are always in sync. +Next to the brand the toolbar mounts `BranchChip`, and above its header `BranchContextStrip` (`src/admin/shared/BranchSwitcher/`) — the site-branch switcher and the branch's own actions, driven by `useBranchStore` (`src/admin/state/branchStore.ts`). Both render on every admin route; the strip only while a branch other than main is active. See [`features/branches.md`](features/branches.md). + Canvas chrome state for Content, Data, and Media lives in `src/admin/state/workspaceLayout.ts`, with persistence in `src/admin/state/workspaceLayoutStorage.ts` and `src/admin/state/useWorkspaceLayoutPersistence.ts`. That store owns non-site sidebar widths, right-panel collapsed state, and the Data sidebar toggle. Site editor layout remains site-only: `src/admin/pages/site/hooks/useEditorLayoutPersistence.ts` subscribes to the editor store and delegates the storage mapping to `src/admin/pages/site/layout/siteEditorLayoutPersistence.ts`. `activeLivePath` is written by the active workspace and cleared on unmount. The Site editor delegates to `useActiveLivePath` (`src/admin/pages/site/hooks/useActiveLivePath.ts`) inside `AdminCanvasEditorBody` — it resolves templates to a routable path rather than their own (non-routable) slug: an everywhere template maps to the previewed page's path; a postTypes template maps to the previewed published row's permalink. Both resolutions follow the same selection as the `TemplateModeControl` preview dropdown so the button always opens what the canvas is showing. The Content workspace writes `activeLivePath` inline inside its own layout; non-editor layouts never write it, so it stays `null` there naturally. diff --git a/docs/features/audit-log.md b/docs/features/audit-log.md index 1f6644586..2f34072eb 100644 --- a/docs/features/audit-log.md +++ b/docs/features/audit-log.md @@ -44,6 +44,8 @@ Every event has a typed `action` string. The closed union is the source of truth | Publishing | `publish` | | Plugins | `plugin.install`, `plugin.update`, `plugin.enable`, `plugin.disable`, `plugin.delete`, `plugin.pack.install`, `plugin.settings.update` | | AI | `ai.credential.created`, `ai.credential.updated`, `ai.credential.deleted`, `ai.credential.tested`, `ai.default.updated`, `ai.default.cleared`, `ai.chat.started`, `ai.chat.completed`, `ai.chat.failed`, `ai.mcp_connector.created`, `ai.mcp_connector.revoked` | +| Branches | `branch.create`, `branch.rename`, `branch.delete`, `branch.merge`, `branch.update`, `branch.preview.share`, `branch.preview.revoke` | +| Versions | `version.restore` — a published version copied back into a row's draft (metadata: `versionId`, `versionNumber`, `branchId`) | If you add a new action that fits an existing group, append to the union. New groups (e.g. media-related audit) extend the same union. diff --git a/docs/features/branches.md b/docs/features/branches.md new file mode 100644 index 000000000..e7650b791 --- /dev/null +++ b/docs/features/branches.md @@ -0,0 +1,188 @@ +# Site branches + +Edit the whole site — pages, components, layouts, data rows, tables, and the +shell — on a private copy, share it as a preview link, and merge it back into +main with a three-way review. Publishing only ever happens on main. + +--- + +## TL;DR + +- A **branch** is a full copy of the site's content under a branch id. `main` is the live site and always exists. +- Every content row keeps its **logical id** on every branch. The physical primary key is `physicalId(branchId, logicalId)` — the logical id itself on main, `:` elsewhere (`src/core/branches/ids.ts`). Nothing in stored JSON changes shape between branches. +- The **request scope** decides which branch a CMS request reads and writes: the `X-Instatic-Branch` header, resolved once in `server/handlers/cms/index.ts` into a `BranchScope` (`server/branches/scope.ts`) and threaded into every repository call. Publishing, scheduling, public routes, forms, plugins, the dashboard, and headless MCP reads pin `MAIN_SCOPE`. +- The admin tab's active branch lives in `useBranchStore` (`src/admin/state/branchStore.ts`), persisted per tab in `sessionStorage` (`instatic-active-branch`, seeded from `?branch=`). The store registers the header with `@core/http`, and the Site / Content / Data workspaces remount on a switch. +- **Publish and schedule are disabled on a branch** with the reason inline (`useBranchPublishGate`); the server answers `409` if a request slips through. +- **Preview links** (`/_instatic/preview/`) set an HttpOnly cookie; while it names a live link, every public GET renders the branch's draft with a banner (`server/publish/branchPreview.ts`). +- **Merge** (branch → main) and **Update** (main → branch) are the same three-way merge over `site_branch_bases` (`server/branches/merge.ts`): field-level where both sides moved different fields, a reviewer decision where they moved the same one. An update never writes main. +- **Version history** lists a row's published versions and restores one into the draft on the active branch (`data_row_versions`, `GET/POST …/data/rows/:id/versions`). +- Capability: `site.branches.manage` (Owner, Admin). Audit: `branch.*`, `version.restore`. + +--- + +## Where the code lives + +``` +src/core/branches/ +├── ids.ts MAIN_BRANCH_ID, id pattern, slugify, physicalId / logicalIdOf +├── schemas.ts SiteBranch, BranchPreview, MergePlan, request/response envelopes +├── threeWayMerge.ts mergeJson(base, ours, theirs) — the pure JSON three-way merge +└── index.ts barrel (gated: no deep imports) + +server/branches/ +├── scope.ts BranchScope, MAIN_SCOPE, resolveBranchScope(req, db), BRANCH_HEADER +├── contentHash.ts rowContent / tableContent / siteContent projections + hashes + schemas +├── fork.ts forkBranch — copy shell, tables, rows; record bases (one transaction) +├── deleteBranch.ts deleteBranch — rows, tables, shell, collab docs, registry row +├── merge.ts planBranchMerge / applyBranchMerge (merge + update directions) +└── previewLinks.ts tokens, cookie, resolvePreviewCookie, entry/exit paths + +server/repositories/ +├── branches.ts site_branches registry +├── branchBases.ts site_branch_bases — base hash + content per entity +└── branchPreviews.ts site_branch_previews — hashed tokens, one active link per branch + +server/handlers/cms/branches.ts /admin/api/cms/branches[/…] endpoints +server/publish/branchPreview.ts render a branch draft for a public URL +server/publish/branchPreviewAssets.ts in-memory runtime bundles for previews +server/publish/publicRoutes.ts dispatcher tail: preview link, public route, 404 + +src/admin/state/branchStore.ts active branch, registry, switcher UI state, publish gate +src/admin/shared/BranchSwitcher/ chip + palette, context strip, manage / delete / merge dialogs +src/admin/shared/VersionHistoryDialog/ published versions + restore +src/admin/spotlight/commands/branches.ts Switch / Create / Manage / Switch to main +src/admin/spotlight/providers/branchesProvider.ts "Switch to " rows +``` + +--- + +## The model + +### Ids + +| Term | Value | +|------|-------| +| Branch id | `/^[a-z0-9][a-z0-9.-]{0,63}$/` — never contains `:`; `main` is reserved | +| Logical id | The id content code sees everywhere (page ids in trees, `rowId` in collab, row ids in the API) | +| Physical id | `physicalId(branchId, logicalId)`: the logical id on main, `` `${branchId}:${logicalId}` `` elsewhere | +| Site shell logical id | `default` (`SITE_SHELL_LOGICAL_ID`) — physical `default` on main, `:default` elsewhere | + +`branch_id` and `logical_id` are columns on `site`, `data_tables`, and `data_rows`. `logical_id` is a **generated column** (SQLite virtual, Postgres stored) derived from `id` and `branch_id`, so no insert can get it wrong. The physical-id scheme exists in exactly one place (`src/core/branches/ids.ts`) and the gate `branch-scope-repositories.test.ts` keeps it there. + +Every repository statement that binds a physical id ALSO pins `branch_id` to the scope (the hydrated row select appends the predicate itself). On main the physical id equals the logical id, so without that predicate a main-scoped call could reach a branch row through its `:` key — e.g. publish a branch row as main's. `branchScope.test.ts` pins this down. + +Foreign keys stay physical, so main's rows, versions, redirects, and media references are untouched by branching. Branch rows point at branch tables; deleting a branch deletes its rows and tables outright (nothing on main references them). + +### Scope + +`BranchScope { branchId }` is an explicit parameter on every repository function that touches a branched table (gated). The CMS dispatcher resolves it once: + +```ts +const scope = await resolveBranchScope(req, db) // MAIN_SCOPE, or 400 / 404 { code: 'branch_not_found' } +``` + +Paths that only make sense for the live site pass `MAIN_SCOPE`: publishing, the scheduler, public routes, forms, plugin content hooks (`content.entry.*` emitters return early off main), the dashboard, and MCP headless reads. The AI tool context carries `branch` from the chat request so editor-side AI reads follow the tab. The export download is a form POST that cannot carry the header, so it names the branch in its body (`ExportRequest.branchId`, resolved by `resolveBranchScopeById`). + +### Collab + +Doc ids carry the branch: `page::`, `component::`, `layout::`, and one `site:` shell doc per branch (`src/core/collab/docIds.ts`). The relay keeps a roster per branch and refuses docs for unknown branches (`BranchGoneError`, admission in `server/collab/relayBranches.ts`). Deleting a branch calls `relay.forgetBranch(branchId)` FIRST: the id is tombstoned (refused even while its registry row still exists), its queued resets are dropped, and its resident docs are evicted; the rows are then deleted on the collab-aware write lane. A socket that rebinds meanwhile receives a `FRAME_RESET` with reason `gone`, which the client answers by leaving the branch (`fallBackToMain`) instead of rebinding. `rememberBranch` lifts the tombstone when the id is forked again, or when the delete transaction failed after tombstoning — and, still under the tombstone, drops every stored document of the branch so the next open reseeds from its rows: a reset dropped while it was tombstoned (an HTTP save, a data-workspace edit) may have left a blob behind the rows. If that purge fails (the database outage that failed the delete) the branch stays refused and the next open retries it. Sockets still bound to a forgotten branch keep their ref counts held, exactly as across a reset, so their late closes never evict a doc reopened after the revival. The tab that asked for the deletion (or for a merge that deletes) expects that `gone`: it leaves the branch quietly and reports once, from its own request. The editor binding mints ids through `collabBranchId()` (`src/admin/pages/site/store/slices/site/collabBranch.ts`), set by `usePersistence` before the site loads; a branch switch clears the store's site first so nothing renders or edits under the wrong branch while the new one loads. + +### Fork + +`POST /admin/api/cms/branches { name, id?, fromBranchId? }` runs `forkBranch`: it flushes the relay first (so open editors' latest edits are in the rows) and then, on the collab-aware write lane, runs one transaction copying the shell (seq reset), every non-deleted table, and every non-deleted row (`scheduled` becomes `draft` — only main publishes). The **bases** — `{ kind, logicalId, contentHash, content }` of the projections in `contentHash.ts` — are recorded from MAIN's content at fork time whatever the branch was forked from, because merges and updates always compare against main: a branch forked off another branch sees the parent's additions as its own pending changes. Media, plugins, users, versions, and redirects are shared with main and never copied. Collab blobs are not copied — the relay seeds a branch doc from its row JSON on first open. + +--- + +## The UI + +Everything lives in the shared toolbar (`src/admin/pages/site/toolbar/Toolbar.tsx`), so it is present on every admin route: + +- **Chip** (`BranchChip`) next to the site brand — always icon-only, tinted with the branch accent off main (the context strip right above it carries the name, so the chip does not repeat it). Opens a palette: search first (Enter switches to the first match, or starts creating when nothing matches), then *Current* and *Recent* (main first, then by `updatedAt`), then *Create branch…* (an in-place form: name → slug preview, start from main or the current branch) and *Manage branches…*. +- **Context strip** (`BranchContextStrip`) above the toolbar while on a branch, painted `--bg-surface-2` with the identity tint (`pillAccent(branch.id)`) on the icon and name only. Actions: *Share preview* / *New preview link*, *Merge into main…*, and a menu with *Update from main…*, *Rename…*, *Revoke preview link*, *Switch to main*, *Delete branch*. +- **Manage dialog** (`ManageBranchesDialog`) — search by name or id, open, rename inline, delete, create. +- **Merge dialog** (`MergeBranchDialog`) — the plan grouped by Site / Tables / entries per table, `New` / `Changed` / `Removed` badges, a two-way choice per conflict, and (merge only) *Delete branch after merging*, default on. +- **Delete** always confirms (`DeleteBranchDialog`) and steps up. +- **Publish controls** on a branch: the site Publish button, the Content and Data publish groups, the data grid's row menu and bulk bar, and the Content settings status select all disable with `BRANCH_PUBLISH_REASON` inline. The Spotlight `editor.publish` command hides on a branch. +- **Spotlight**: group `branches` — *Switch branch…*, *Create branch…*, *Switch to main*, *Manage branches…*; typing a branch name lists *Switch to *. +- **Branch gone**: a `404 { code: 'branch_not_found' }` on any request drops the tab back to main with a toast (`registerApiErrorListener` in `@core/http`). + +--- + +## Preview links + +| Endpoint | Gate | Effect | +|----------|------|--------| +| `POST /admin/api/cms/branches/:id/preview` | `site.branches.manage` | Issues a new token (retiring the previous one) and returns `{ url, preview }`. Only the SHA-256 is stored. | +| `GET …/preview` | `site.read` | `{ preview }` — the active link's metadata, or `null`. | +| `DELETE …/preview` | `site.branches.manage` | Revokes. | +| `GET /_instatic/preview/` | public | Validates, sets `instatic_branch_preview` (HttpOnly, SameSite=Lax, Path=/, 30 days) and redirects to `/`. A dead token clears the cookie instead. | +| `GET /_instatic/preview/exit` | public | Clears the cookie. | + +While a request carries a live cookie, `tryServePublicRoute` hands the URL to `renderBranchPreview`, which mirrors the editor's runtime preview rather than the publish path: the page (or entry template, for `//` rows on the branch) is composed from the branch's draft, loops read the branch's DRAFT rows (post types have no published versions off main — `fetchPublishedDataRowItems({ drafts: true })` skips only `unpublished` rows), request-dependent nodes render inline with the request in hand (`publishPage({ dynamicNodes: 'inline' })`) instead of becoming holes hydrated from main, CSS is inlined, runtime scripts are bundled on demand and served from memory under `/_instatic/assets/preview//…`, plugin frontend assets are injected, and no publish hook fires. Responses are `no-store` + `noindex` with a fixed banner ("Previewing branch … — not live" + exit link). A path the branch does not have falls through to the 404 page, never to main's published page. + +--- + +## Merge and update + +Both directions run `planBranchMerge(db, branchId, direction)` over three snapshots per entity — the base (from `site_branch_bases`), `into`, and `from`: + +| Situation | Outcome | +|-----------|---------| +| Only `from` moved | Applied (`create` / `update` / `delete`) | +| Only `into` moved | Nothing to do | +| Both moved, different fields | `mergeJson` merges field by field (`src/core/branches/threeWayMerge.ts`) | +| Both moved, same field | Conflict at that path — the reviewer picks `into` or `from` for the whole entity | +| Deleted on one side, changed on the other | Conflict (`(deleted)`) | + +Row content is `{ tableId, cells, slug }` — **never `status`**: a merge changes drafts, not what is live. The site content is `{ name, shell }` without id and timestamps (the merged shell is re-validated with `validateSite` before it is saved); table content is the schema fields. + +`applyBranchMerge` flushes the relay, re-plans against live data (an unresolved conflict aborts before any write), then in one transaction writes each result to `into`. A **merge** also mirrors the result onto the branch so both sides agree afterwards, and the base becomes the result. An **update** never writes main: the branch takes the result and the base becomes main's content as of the update, so the branch's own changes stay pending. Entities identical on both sides whose base is stale move their base forward too, so a later edit on one side is not reported as a conflict. Row writes use the repositories with `collabInternal`; after commit the row/shell write notifications fire (open editors reset and reload) and, when main received rows, the `content.entry.*` plugin hooks fire for them. Table creates run before rows and restore a soft-deleted table under the same id; table deletes run last and abort the merge (`MergeApplyError`) when the table still has rows. + +Endpoints: `GET|POST /admin/api/cms/branches/:id/merge` and `…/update` (`site.branches.manage`; `POST` steps up). `POST` body: `{ resolutions?: Record, deleteBranch?: boolean }` → `{ plan, branchDeleted }`; unresolved conflicts answer `409 { code: 'merge_conflicts', keys }`. + +--- + +## Version history + +`GET /admin/api/cms/data/rows/:id/versions` lists `data_row_versions` for the row (newest first, with the publisher's name). `POST …/versions/:versionId/restore` runs that version's `cells` through the `content.entry.cells` filter, derives the slug the way a draft save does (`409` when another row now owns it), writes the row's **draft on the request's branch**, emits `content.entry.updated` on main, and records `version.restore`. Nothing is published by restoring. The dialog (`VersionHistoryDialog`) is reachable from the Site editor's publish menu (active page) and the Content toolbar's publish menu (selected entry). + +--- + +## Cookbook + +### Call a repository from a handler + +```ts +export async function handleFooRoutes(req: Request, db: DbClient, scope: BranchScope) { + const rows = await listDataRows(db, scope, 'posts') // the tab's branch + const live = await listDataRows(db, MAIN_SCOPE, 'posts') // explicitly the live site +} +``` + +### Keep an operation main-only + +Return `409` with the standard message the way `branchOnlyResponse(scope)` in `server/handlers/cms/data/rows.ts` does before doing work, and gate the control in the UI with `useBranchPublishGate()` so it never reaches the server. + +### Add a new branched table + +Add `branch_id text not null default 'main'` and the generated `logical_id` to the table in both migration files, make every repository function on it take `BranchScope`, bind `physicalId(...)` in its SQL, add the table to `snapshotScope` in `server/branches/merge.ts` and to `forkBranch` / `deleteBranch`, and extend the gate test's table list. + +--- + +## Forbidden patterns + +- Computing `` `${branch}:${id}` `` anywhere but `src/core/branches/ids.ts`. +- A repository on `site`, `data_tables`, or `data_rows` without a `BranchScope` parameter, or raw SQL on those tables outside `server/repositories`, `server/branches`, and `server/db` that ignores `branch_id`. +- Publishing, scheduling, or baking artefacts for a scope other than main. +- Storing a preview token in plain text, or granting preview access from anything but the cookie's token lookup. +- Merging `status` or timestamps — only content moves between branches. + +--- + +## Related + +- [`site-shell.md`](site-shell.md) — collab document model +- [`content-storage.md`](content-storage.md) — the branched tables +- [`publisher.md`](publisher.md) — the public render path a preview mirrors +- [`../reference/capabilities.md`](../reference/capabilities.md) — `site.branches.manage` +- [`audit-log.md`](audit-log.md) — `branch.*`, `version.restore` diff --git a/docs/features/content-storage.md b/docs/features/content-storage.md index 2a22ef1db..9133c2709 100644 --- a/docs/features/content-storage.md +++ b/docs/features/content-storage.md @@ -26,7 +26,9 @@ The schema for a collection. One row per collection. | Column | Type | Notes | |-------------------|-----------|------------------------------------------------------------------| -| `id` | text PK | | +| `id` | text PK | Physical key: the logical id on main, `:` on a branch | +| `branch_id` | text | `main` by default; see [`branches.md`](branches.md) | +| `logical_id` | text | Generated from `id` + `branch_id` — the id content code uses | | `name` | text | Human-readable | | `slug` | text | URL-safe (kebab-case) | | `kind` | text | `'postType' \| 'data' \| 'page' \| 'component' \| 'layout'` | @@ -44,8 +46,10 @@ One row per content row. | Column | Type | Notes | |-------------------------|------------|-----------------------------------------------------------------| -| `id` | text PK | | -| `table_id` | text FK | → `data_tables.id` | +| `id` | text PK | Physical key: the logical id on main, `:` on a branch | +| `branch_id` | text | `main` by default; repositories read and write one branch per `BranchScope` | +| `logical_id` | text | Generated from `id` + `branch_id`; the id every API and tree carries | +| `table_id` | text FK | → `data_tables.id` (physical — a branch row points at the branch's table) | | `cells_json` | jsonb | `Record` | | `slug` | text | Denormalized from `cells_json.slug` for fast route lookup | | `status` | text | `'draft' \| 'published' \| 'unpublished' \| 'scheduled'` | diff --git a/docs/features/loops.md b/docs/features/loops.md index c1947bc39..b18364eda 100644 --- a/docs/features/loops.md +++ b/docs/features/loops.md @@ -337,7 +337,7 @@ In the editor, `useLoopPreviewItems` (`src/admin/pages/site/canvas/useLoopPrevie | Source | Canvas path | |---|---| -| `data.rows` | GETs `/data/tables/:id/loop-preview` — same projection as the publisher, and takes `cellField` / `cellOperator` / `cellValue` plus a `cell:` `orderBy` so the canvas shows the rows the published page will emit. Falls back to synthetic items from the table's field definitions when no published rows exist yet. | +| `data.rows` | GETs `/data/tables/:id/loop-preview` — same projection as the publisher, and takes `cellField` / `cellOperator` / `cellValue` plus a `cell:` `orderBy` so the canvas shows the rows the published page will emit. On a site branch the preview reads the branch's draft rows (the branch has nothing published), mirroring `renderBranchPreview`. Falls back to synthetic items from the table's field definitions when no rows exist yet. | | `site.pages` | Reads pages from the in-memory site document via `selectSitePagesLoopItems`. Applies `filterPagesForLoop` + `pageToLoopItem` imported from `@core/loops` — identical to the publisher path. | | `site.media` | Fetches via `listCmsMediaAssets()`, filters by MIME prefix, sorts + slices client-side. | | Plugin sources | Calls `source.preview(ctx)` synchronously. | diff --git a/docs/features/publisher.md b/docs/features/publisher.md index f7efd7b6a..9b144b46e 100644 --- a/docs/features/publisher.md +++ b/docs/features/publisher.md @@ -382,6 +382,8 @@ Because `serializeCsp` sorts, the same plugins + adapters always emit a **byte-i | File | Role | |-------------------------------------------------|---------------------------------------------------------------------| | `server/publish/publicRouter.ts` | Gateway: Layer A disk fast-path → Layer B LRU → live `resolvePublicRoute` + `renderPublicResolution`. | +| `server/publish/publicRoutes.ts` | Dispatcher tail: `tryServeBranchPreviewLink` (preview cookie in/out), `tryServePublicRoute` (a live preview cookie → `renderBranchPreview`, otherwise `renderPublicResolution`), setup redirect, 404 page. | +| `server/publish/branchPreview.ts` | Render a public URL from a branch's DRAFT for preview-link visitors: same composition as the editor's runtime preview (inline CSS, loops on the branch, on-demand runtime bundles kept in `branchPreviewAssets.ts`, plugin frontend injections, no publish hooks), `no-store` + `noindex`, with a banner. | | `server/publish/staticArtefact.ts` | Two-slot pointer-file swap (`swapSlot`), per-file atomic writes (`writeArtefact`, `updateArtefactInPlace`), and reads (`readArtefact`). Layer A. | | `server/publish/renderCache.ts` | In-memory LRU keyed by `(urlPath, canonicalQuery)`, entries versioned. `getOrRender` (single-flight). Reads the version from `publishState`; version captured at render start — a publish landing mid-render discards the result rather than caching stale HTML. Layer B. | | `server/publish/publishState.ts` | Publish-time process state: `publishVersion` (`bumpPublishVersion`/`getPublishVersion`), `withPublishLock` (ISS-038 publish serializer), and `createVersionedSingleFlight` — the generalized version-keyed single-flight memo the hole endpoint reuses. Repositories import the version + lock from here (not from the cache). | diff --git a/docs/features/site-shell.md b/docs/features/site-shell.md index 512b056ca..511bf3876 100644 --- a/docs/features/site-shell.md +++ b/docs/features/site-shell.md @@ -505,8 +505,9 @@ one text node, simultaneously), presence is visible, and there is **no save UI at all**: the server persists continuously. **Document model** (`src/core/collab/`): one Yjs doc per logical row — -`page:`, `component:`, `layout:` — plus one `site:default` -doc for the shell and the roster order. Page/component trees map to +`page::`, `component::`, `layout::` — +plus one `site:` doc per branch for the shell and the roster order +(`site:main` for the live site; see [`branches.md`](branches.md)). Page/component trees map to `getMap('tree')` (`rootNodeId` + a `nodes` Y.Map of per-node Y.Maps: `props` as a Y.Map with the module's inline-text prop as Y.Text, nested `breakpointOverrides` Y.Maps, `children` as Y.Array; `parentId` is derived, @@ -571,7 +572,11 @@ and reuse the HTTP path's `validateSiteWriteDiff`/`validatePageWriteDiff` — one enforcement vocabulary on both transports (the validators live in `server/writePolicy/` for exactly that reason). Rejected updates never touch the authoritative doc; the sender gets a targeted reset that reverts its -local fork. Two more socket-level defenses: per-frame payload caps (64 KB +local fork. Every frame carries the doc's lineage (`generation`, minted +when the relay seeds a doc): a write from a dead lineage is reset as +`stale`, and the client provider holds local updates back until the first +inbound frame names the lineage, then sends them as one update — so a row +created and placed inside the bind round trip is never refused. Two more socket-level defenses: per-frame payload caps (64 KB awareness / 4 MB sync, plus the transport `maxPayloadLength`) drop oversized frames before any decode work, and every awareness frame is decoded and checked against the session — a state claiming another user's identity diff --git a/docs/features/site-transfer.md b/docs/features/site-transfer.md index 688fe96a5..9121ea485 100644 --- a/docs/features/site-transfer.md +++ b/docs/features/site-transfer.md @@ -141,6 +141,7 @@ GET accepts filter options as query-string params. POST accepts either a JSON bo includeSite?: boolean // include site shell; default: true includeMediaFolders?: boolean // include the folder tree + asset membership; default: true includeRedirects?: boolean // include published-URL redirects; default: true + branchId?: string // site branch to export (the form POST cannot carry the branch header); default: main } ``` diff --git a/docs/features/spotlight.md b/docs/features/spotlight.md index 2693287d0..68845441b 100644 --- a/docs/features/spotlight.md +++ b/docs/features/spotlight.md @@ -159,6 +159,7 @@ The subscription is **dropped on close** to avoid spurious re-renders. | `settings` | Open framework scale, Open site settings | | `ai` | Open / focus AI assistant | | `account` / `users`| Account security, session revocation, user management | +| `branches` | Switch branch…, Create branch…, Switch to main, Manage branches… (`commands/branches.ts`) | Each command's `when(ctx)` / `workspaces` / `capability` fields filter by user capability + workspace context. `filterCommands(commands, ctx)` runs once per palette open. @@ -196,6 +197,8 @@ There are two kinds of provider: **Local providers** (`pagesProvider`, `siteFilesProvider`) read data from the editor store synchronously. No HTTP call, `debounceMs: 0`. +**Local providers** read a store synchronously with no debounce: `pagesProvider` (the editor store's pages) and `branchesProvider` (the branch store — "Switch to " rows for a typed branch name). + **Server providers** (`mediaProvider`, `contentProvider`, `dataProvider`, `pluginPagesProvider`) fetch via `/admin/api/cms/...`. They are built with shared scaffolding in `serverProvider.ts` (see below). ### Server provider scaffolding (`serverProvider.ts`) diff --git a/docs/reference/architecture-tests.md b/docs/reference/architecture-tests.md index 05738be2f..7839edb86 100644 --- a/docs/reference/architecture-tests.md +++ b/docs/reference/architecture-tests.md @@ -41,6 +41,7 @@ See [docs/reference/database-dialects.md](database-dialects.md). | `data-tables-system-flag.test.ts` | System tables (`posts`, `pages`, `components`) are seeded with `system: true`. | | `no-legacy-content-domain.test.ts` | The `content_*` tables / handlers don't return. Everything lives in `data_*`. | | `no-legacy-pages-table.test.ts` | No `pages` or `page_versions` tables in migrations. | +| `branch-scope-repositories.test.ts` | Every exported repository function on `site` / `data_tables` / `data_rows` takes a `BranchScope`; raw SQL on those tables outside `server/repositories`, `server/branches`, `server/db` names `branch_id`; the physical-id scheme lives only in `src/core/branches/ids.ts`. | ### Page tree diff --git a/docs/reference/capabilities.md b/docs/reference/capabilities.md index 600339d48..d5ee805c4 100644 --- a/docs/reference/capabilities.md +++ b/docs/reference/capabilities.md @@ -16,7 +16,7 @@ For the broader auth flow (sessions, MFA, step-up), see [docs/features/auth-and- --- -## The 38 core capabilities +## The 39 core capabilities ### Read @@ -32,6 +32,7 @@ For the broader auth flow (sessions, MFA, step-up), see [docs/features/auth-and- | `site.structure.edit` | Add / remove / move / rename nodes; manage pages, VCs, classes | Owner, Admin | | `site.content.edit` | Modify content props (text, image src/alt, link href) on existing nodes — no structure or style edits | Owner, Admin, Client | | `site.style.edit` | Modify CSS classes, style overrides, breakpoints, framework tokens | Owner, Admin | +| `site.branches.manage` | Create, rename, delete, merge, and update site branches; share and revoke preview links (see [`features/branches.md`](../features/branches.md)) | Owner, Admin | `SITE_WRITE_CAPABILITIES` is the convenience set `['site.structure.edit', 'site.content.edit', 'site.style.edit']` — defined locally in `server/handlers/cms/siteDocument.ts` and `src/admin/access.ts` at each point of use, not in a shared capabilities module. The transactional site-document save (`PUT /admin/api/cms/site-document`) accepts any site writer, then diff-validates the batch by category: page deletions, page metadata, topology, module identity, non-content props, and dynamic bindings require `site.structure.edit`; content-category props (and site-wide SEO copy on the shell) require `site.content.edit`; inline styles/classes/breakpoint overrides and style rules require `site.style.edit`. Empty change sets are no-op saves any site writer may perform, but changed/deleted components and layouts remain structural work (`site.structure.edit`). diff --git a/docs/reference/database-dialects.md b/docs/reference/database-dialects.md index df24cdf09..ec3587c15 100644 --- a/docs/reference/database-dialects.md +++ b/docs/reference/database-dialects.md @@ -355,6 +355,24 @@ await db.transaction(async (tx) => { The callback receives a `DbClient` scoped to the transaction. If it throws, the transaction is rolled back. +### Adding a generated column + +Both dialects accept a column computed from the same row. SQLite can only ADD a `virtual` generated column; Postgres wants `stored`: + +```sql +-- migrations-sqlite.ts +alter table data_rows add column logical_id text generated always as ( + case when branch_id = 'main' then id else substr(id, length(branch_id) + 2) end +) virtual; + +-- migrations-pg.ts +alter table data_rows add column logical_id text generated always as ( + case when branch_id = 'main' then id else substr(id, length(branch_id) + 2) end +) stored; +``` + +Inserts must not name the column; reads and `returning` may. Migration 026 uses this for `logical_id` on the three branched tables. + --- ## Forbidden patterns diff --git a/docs/reference/persistence-keys.md b/docs/reference/persistence-keys.md index ded530988..1f553d4f4 100644 --- a/docs/reference/persistence-keys.md +++ b/docs/reference/persistence-keys.md @@ -36,14 +36,16 @@ Catalog of every `localStorage` / `sessionStorage` key the admin app writes, and | Key | Owner | Source-of-truth file | |-------------------------------------------|-----------------------------------------------------------------------|-----------------------------------------------------------------| | `instatic-spotlight-pending-action` | The cross-page-reload action a Spotlight command is waiting for (e.g. step-up then resume) | `src/admin/spotlight/pendingAction.ts` | +| `instatic-active-branch` | The site branch this tab edits (absent = main); a `?branch=` URL param seeds it once and is then stripped from the URL | `src/admin/state/activeBranch.ts` | ### Cookies (HttpOnly — not directly readable) | Cookie | Owner | Source-of-truth file | |-------------------------------------------|-----------------------------------------------------------------------|-----------------------------------------------------------------| | `instatic_admin_session` | Admin session token (raw; hashed before lookup) | `server/auth/tokens.ts` → `SESSION_COOKIE_NAME` | +| `instatic_branch_preview` | Branch preview token (raw; hashed before lookup) — public visitors see that branch's draft while it names a live link | `server/branches/previewLinks.ts` → `BRANCH_PREVIEW_COOKIE` | -The session cookie is `HttpOnly`, `Secure` (in production behind TLS), `SameSite=Lax`, `Path=/admin`. The client never reads it directly. +The session cookie is `HttpOnly`, `Secure` (in production behind TLS), `SameSite=Lax`, `Path=/admin`. The client never reads it directly. The preview cookie uses the same attributes with `Path=/` and a 30-day `Max-Age`; revoking the link is what actually ends access. --- diff --git a/docs/server.md b/docs/server.md index 25151e817..6d1befdce 100644 --- a/docs/server.md +++ b/docs/server.md @@ -81,6 +81,8 @@ const routes: readonly RouteHandler[] = [ tryServeHealth, // /health tryServeAi, // /admin/api/ai/* → server/ai/handlers/ tryServeCmsApi, // /admin/api/cms/* → handlers/cms/index.ts + tryServeBranchPreviewLink, // /_instatic/preview/ | /exit → publish/publicRoutes.ts + // sets / clears the branch preview cookie tryServeLoopRuntimeAsset, // /_instatic/loop-runtime.js (fixed CMS asset) tryServeLoop, // /_instatic/loop/* → handlers/cms/loop.ts tryServeHoleRuntimeAsset, // /_instatic/hole-runtime.js (fixed CMS asset) @@ -95,9 +97,11 @@ const routes: readonly RouteHandler[] = [ tryServeUpload, // /uploads/* → uploadsDir (with nosniff hardening) tryServeAdminApp, // /admin/* → dist/index.html (SPA fallback) tryServePublicRoute, // / OR // - // → server/publish/publicRouter.ts - // resolves to page snapshot OR data row + template, - // live-renders, runs publish.html pipeline + // → server/publish/publicRoutes.ts: a live preview + // cookie renders the branch draft (branchPreview.ts), + // otherwise publicRouter.ts resolves to page snapshot + // OR data row + template, live-renders, runs the + // publish.html pipeline trySetupRedirect, // first-run redirect → /admin/setup tryServeNotFoundPage, // fall-through GET → site's 404 page (notFound // template; baked 404.html artefact, else live @@ -140,7 +144,9 @@ This prevents an unknown path under a known namespace from accidentally matching 1. **CSRF defense in depth.** State-changing methods (`POST/PUT/PATCH/DELETE`) must come from an `Origin` matching a configured public origin (`PUBLIC_ORIGIN`, auto-detected from `RENDER_EXTERNAL_URL` / `RAILWAY_PUBLIC_DOMAIN`), or a dev allowlist entry. With nothing configured the check falls back to the inbound `Host` header. Forwarded headers (`X-Forwarded-Host` / `X-Forwarded-Proto`) are never consulted, so `TRUSTED_PROXY_CIDRS` has no bearing on CSRF. `SameSite=Lax` already covers most CSRF; this catches the same-site-different-subdomain edge. -2. **Group dispatch.** The handler walks an ordered chain of route-group handlers, each owning a resource: +2. **Branch scope.** `resolveBranchScope(req, db)` (`server/branches/scope.ts`) turns the `X-Instatic-Branch` header into a `BranchScope` — `MAIN_SCOPE` when absent, `400` for a malformed id, `404 { code: 'branch_not_found' }` for an unknown one. Content handlers receive it and pass it to every repository call; see [`features/branches.md`](features/branches.md). + +3. **Group dispatch.** The handler walks an ordered chain of route-group handlers, each owning a resource: ```ts const response = @@ -151,21 +157,22 @@ const response = ?? (await handleUsersRoutes(req, db)) ?? (await handleRolesRoutes(req, db)) ?? (await handleAuditRoutes(req, db)) - ?? (await handleSiteRoutes(req, db)) - ?? (await handlePagesRoutes(req, db)) - ?? (await handleComponentsRoutes(req, db)) - ?? (await handleRuntimeRoutes(req, db)) + ?? (await handleBranchesRoutes(req, db, scope, options)) + ?? (await handleSiteRoutes(req, db, scope)) + ?? (await handlePagesRoutes(req, db, scope)) + ?? (await handleComponentsRoutes(req, db, scope)) + ?? (await handleRuntimeRoutes(req, db, scope)) ?? (await handleMediaFolderRoutes(req, db)) // before /media/:id ?? (await handleMediaStorageAdminRoutes(req, db, …)) // before /media/:id ?? (await handleMediaRoutes(req, db, …)) ?? (await handlePluginsRoutes(req, db, …)) - ?? (await handleDataRoutes(req, db)) + ?? (await handleDataRoutes(req, db, scope, options)) ?? (await handleDashboardRoutes(req, db)) ?? (await handleFontsRoutes(req, db, …)) - ?? (await handlePublishRoutes(req, db)) - ?? (await handleExportRoute(req, db, options)) - ?? (await handleImportPreviewRoute(req, db)) // before /import (longer path) - ?? (await handleImportRoute(req, db, options)) + ?? (await handlePublishRoutes(req, db, scope)) // 409 off main + ?? (await handleExportRoute(req, db, scope, options)) + ?? (await handleImportPreviewRoute(req, db, scope)) // before /import (longer path) + ?? (await handleImportRoute(req, db, scope, options)) ``` Each group module owns its URL matching and returns `Response | null`. The first non-null wins. Order matters — handler order comments in `index.ts` document the load-bearing precedence (e.g. media folder/storage routes must run before `/media/:id` because that pattern would otherwise eat them). diff --git a/server/ai/content/treeService.test.ts b/server/ai/content/treeService.test.ts index 528b83f32..afba97e21 100644 --- a/server/ai/content/treeService.test.ts +++ b/server/ai/content/treeService.test.ts @@ -4,6 +4,7 @@ import { sqliteMigrations } from '../../db/migrations-sqlite' import { runMigrations } from '../../db/runMigrations' import type { DbClient } from '../../db/client' import { readPageTree, mutatePageTree } from './treeService' +import { MAIN_SCOPE } from '../../branches/scope' const ENTRY_ID = 'page1' @@ -36,7 +37,7 @@ beforeEach(async () => { db = await freshDb() }) describe('content tree service', () => { it('reads a page tree', async () => { - const tree = await readPageTree(db, ENTRY_ID, 'body') + const tree = await readPageTree(db, MAIN_SCOPE, ENTRY_ID, 'body') expect(tree).toBeTruthy() expect((tree as { rootNodeId: string }).rootNodeId).toBe('root') }) @@ -44,6 +45,7 @@ describe('content tree service', () => { it('applies a node insert and persists', async () => { const result = await mutatePageTree( db, + MAIN_SCOPE, ENTRY_ID, 'body', [ @@ -58,7 +60,7 @@ describe('content tree service', () => { ) expect(result.affectedNodeIds).toContain('n_test') - const after = await readPageTree(db, ENTRY_ID, 'body') + const after = await readPageTree(db, MAIN_SCOPE, ENTRY_ID, 'body') expect(JSON.stringify(after)).toContain('n_test') expect((after as { nodes: Record }).nodes.n_test).toBeTruthy() }) @@ -67,6 +69,7 @@ describe('content tree service', () => { await expect( mutatePageTree( db, + MAIN_SCOPE, ENTRY_ID, 'body', [{ kind: 'deleteNode', nodeId: 'root' }], @@ -77,6 +80,6 @@ describe('content tree service', () => { }) it('rejects a non-pageTree field', async () => { - await expect(readPageTree(db, ENTRY_ID, 'title')).rejects.toThrow(/not a pageTree field/) + await expect(readPageTree(db, MAIN_SCOPE, ENTRY_ID, 'title')).rejects.toThrow(/not a pageTree field/) }) }) diff --git a/server/ai/content/treeService.ts b/server/ai/content/treeService.ts index d9c2acdbb..7acb4b71b 100644 --- a/server/ai/content/treeService.ts +++ b/server/ai/content/treeService.ts @@ -18,6 +18,7 @@ import { hookBus } from '@core/plugins/hookBus' import type { ContentEntryActor } from '@core/plugin-sdk' import type { DataRow, DataTable } from '@core/data/schemas' import type { DbClient } from '../../db/client' +import type { BranchScope } from '../../branches/scope' import { getDataRow, getDataTable, saveDataRowDraft } from '../../repositories/data' import { applyContentEntryCellsFilter } from '../../publish/contentEvents' @@ -28,12 +29,13 @@ export interface PageTreeAccessOptions { async function resolvePageTreeField( db: DbClient, + scope: BranchScope, entryId: string, fieldId: string, ): Promise<{ row: DataRow; table: DataTable }> { - const row = await getDataRow(db, entryId) + const row = await getDataRow(db, scope, entryId) if (!row) throw new Error(`Entry "${entryId}" not found`) - const table = await getDataTable(db, row.tableId) + const table = await getDataTable(db, scope, row.tableId) if (!table) throw new Error(`Table for entry "${entryId}" missing`) const field = table.fields.find((f) => f.id === fieldId) if (!field) throw new Error(`Field "${fieldId}" not found on table "${table.slug}"`) @@ -52,24 +54,26 @@ function actorToSaveArgs(actor: ContentEntryActor): { actorUserId: string | null export async function readPageTree( db: DbClient, + scope: BranchScope, entryId: string, fieldId: string, options: PageTreeAccessOptions = {}, ): Promise { - const { row, table } = await resolvePageTreeField(db, entryId, fieldId) + const { row, table } = await resolvePageTreeField(db, scope, entryId, fieldId) options.assertAccess?.(table) return row.cells[fieldId] ?? null } export async function mutatePageTree( db: DbClient, + scope: BranchScope, entryId: string, fieldId: string, operations: readonly TreeOperation[], actor: ContentEntryActor, options: PageTreeAccessOptions = {}, ): Promise<{ tree: unknown; affectedNodeIds: string[] }> { - const { row, table } = await resolvePageTreeField(db, entryId, fieldId) + const { row, table } = await resolvePageTreeField(db, scope, entryId, fieldId) options.assertAccess?.(table) const initial = row.cells[fieldId] @@ -94,6 +98,7 @@ export async function mutatePageTree( const { actorUserId, pluginActorId } = actorToSaveArgs(actor) const updated = await saveDataRowDraft( db, + scope, entryId, { cells: nextCells, slug: row.slug }, actorUserId, diff --git a/server/ai/drivers/types.ts b/server/ai/drivers/types.ts index 0561b6d11..5f315f45d 100644 --- a/server/ai/drivers/types.ts +++ b/server/ai/drivers/types.ts @@ -143,6 +143,8 @@ export interface AiStreamRequest { * `ToolContext` by spreading this and adding their own signal. */ export interface ToolContextBase { + /** Branch the turn's server-resolved tools read and write. */ + readonly branch: import('../../branches/scope').BranchScope readonly db: import('../../db/client').DbClient readonly userId: string /** The caller's capability set — threaded into ToolContext for the re-check gate. */ diff --git a/server/ai/handlers/chat.ts b/server/ai/handlers/chat.ts index 2a0473dcd..286d0011a 100644 --- a/server/ai/handlers/chat.ts +++ b/server/ai/handlers/chat.ts @@ -82,6 +82,7 @@ import type { ToolScope, } from '../runtime/types' import type { AiStreamRequest } from '../drivers/types' +import { resolveBranchScope } from '../../branches/scope' const VALID_SCOPES: ToolScope[] = ['site', 'content', 'data', 'plugin'] const activeChatConversations = new Set() @@ -119,6 +120,11 @@ async function handleAiChat( if (userOrResponse instanceof Response) return userOrResponse const user = userOrResponse + // Server-resolved tools read the branch the workspace is editing — the + // same header every CMS route honours. + const branch = await resolveBranchScope(req, db) + if (branch instanceof Response) return branch + let chatBody: AiChatRequestBody | null try { chatBody = await readValidatedBody(req, AiChatRequestBodySchema, { @@ -366,6 +372,7 @@ async function handleAiChat( // later in the same turn sees current state, not stale turn-start state. const toolContextBase = { db, + branch, userId: user.id, capabilities: user.capabilities, scope, diff --git a/server/ai/mcp/contentAuthorization.test.ts b/server/ai/mcp/contentAuthorization.test.ts index ea4b09a6c..ad7e8e7ce 100644 --- a/server/ai/mcp/contentAuthorization.test.ts +++ b/server/ai/mcp/contentAuthorization.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test' import { createCapabilityTestHarness, type CapabilityTestHarness } from '../../../src/__tests__/helpers/capabilityHarness' import { createDataRow } from '../../repositories/data' import { authorizeMcpContentTool } from './contentAuthorization' +import { MAIN_SCOPE } from '../../branches/scope' describe('MCP content row authorization', () => { let harness: CapabilityTestHarness @@ -29,7 +30,7 @@ describe('MCP content row authorization', () => { }) it('does not let an own-only connector borrow its owner browser\'s any-row authority', async () => { - const foreignRow = await createDataRow(harness.db, { + const foreignRow = await createDataRow(harness.db, MAIN_SCOPE, { id: 'foreign-document', tableId: 'posts', cells: { title: 'Foreign document' }, @@ -54,13 +55,13 @@ describe('MCP content row authorization', () => { }) it('allows own-row grants for owned documents and any-row grants for foreign documents', async () => { - const ownRow = await createDataRow(harness.db, { + const ownRow = await createDataRow(harness.db, MAIN_SCOPE, { id: 'owned-document', tableId: 'posts', cells: { title: 'Owned document' }, slug: 'owned-document', }, ownerId) - const foreignRow = await createDataRow(harness.db, { + const foreignRow = await createDataRow(harness.db, MAIN_SCOPE, { id: 'any-document', tableId: 'posts', cells: { title: 'Any document' }, diff --git a/server/ai/mcp/contentAuthorization.ts b/server/ai/mcp/contentAuthorization.ts index 6df8836de..9c9c80070 100644 --- a/server/ai/mcp/contentAuthorization.ts +++ b/server/ai/mcp/contentAuthorization.ts @@ -10,6 +10,7 @@ import type { CoreCapability } from '@core/capabilities' import type { DbClient } from '../../db/client' import { getDataRow } from '../../repositories/data' +import { MAIN_SCOPE } from '../../branches/scope' const DOCUMENT_EDIT_TOOLS = new Set([ 'content_delete_document', @@ -52,7 +53,7 @@ export async function authorizeMcpContentTool( if (!checksEditOwnership && !checksPublishOwnership) return const documentId = inputDocumentId(input) - const row = await getDataRow(db, documentId) + const row = await getDataRow(db, MAIN_SCOPE, documentId) if (!row) throw new Error(`Document ${documentId} not found.`) if (checksEditOwnership) { diff --git a/server/ai/mcp/publishTool.test.ts b/server/ai/mcp/publishTool.test.ts index f3d811f6d..828431e0b 100644 --- a/server/ai/mcp/publishTool.test.ts +++ b/server/ai/mcp/publishTool.test.ts @@ -8,6 +8,7 @@ import { readArtefact, readStaticAsset } from '../../publish/staticArtefact' import { createBearerConnection } from './connectors/store' import { generatePersonalAccessToken, hashMcpSecret } from './connectors/token' import { handleMcpHttp } from './transports/http' +import { MAIN_SCOPE } from '../../branches/scope' interface AuditRow { action: string @@ -71,7 +72,7 @@ describe('site_publish MCP tool', () => { }) it('deploys the saved draft through the canonical static publish pipeline', async () => { - const site = await getDraftSite(harness.db) + const site = await getDraftSite(harness.db, MAIN_SCOPE) if (!site) throw new Error('default site was not seeded') const now = Date.now() site.styleRules.issue195 = { @@ -85,7 +86,7 @@ describe('site_publish MCP tool', () => { createdAt: now, updatedAt: now, } - await saveDraftSite(harness.db, site) + await saveDraftSite(harness.db, MAIN_SCOPE, site) const { rows: users } = await harness.db<{ id: string }>`select id from users limit 1` const userId = users[0]?.id if (!userId) throw new Error('owner user was not seeded') diff --git a/server/ai/mcp/server.test.ts b/server/ai/mcp/server.test.ts index 0cbc238fb..706c503fa 100644 --- a/server/ai/mcp/server.test.ts +++ b/server/ai/mcp/server.test.ts @@ -9,6 +9,7 @@ import { createDataRow } from '../../repositories/data' import { resolveBridgeToolResult } from '../runtime' import { buildMcpServer } from './server' import { createEditorBridgeStream } from './editorBridge' +import { MAIN_SCOPE } from '../../branches/scope' const decoder = new TextDecoder() @@ -199,7 +200,7 @@ describe('mcp server', () => { insert into users (id, email, email_normalized, display_name, password_hash, role_id) values ('u2', 'u2@example.com', 'u2@example.com', 'User Two', 'x', 'admin') ` - const foreignRow = await createDataRow(db, { + const foreignRow = await createDataRow(db, MAIN_SCOPE, { id: 'foreign-row', tableId: 'posts', cells: { title: 'Foreign row' }, diff --git a/server/ai/mcp/server.ts b/server/ai/mcp/server.ts index f3c8148d9..dd7708a55 100644 --- a/server/ai/mcp/server.ts +++ b/server/ai/mcp/server.ts @@ -26,6 +26,7 @@ import { type EditorBridgeScope, } from './editorBridge' import { runPublishFlush } from '../../publish/publishFlush' +import { MAIN_SCOPE } from '../../branches/scope' export interface McpServerContext { db: DbClient @@ -193,6 +194,9 @@ export function buildMcpServer(ctx: McpServerContext): Server { try { output = await executeAiTool(tool, args ?? {}, bridge, requestContext.mcpReq.signal, { db: ctx.db, + // Headless MCP reads describe the live site. Browser-bridged tools run + // inside whatever branch the connected workspace has open. + branch: MAIN_SCOPE, userId: ctx.userId, capabilities: ctx.capabilities, scope: tool.scope === 'shared' ? 'content' : tool.scope, diff --git a/server/ai/mcp/tools/contextTool.ts b/server/ai/mcp/tools/contextTool.ts index d63241d84..632e556d5 100644 --- a/server/ai/mcp/tools/contextTool.ts +++ b/server/ai/mcp/tools/contextTool.ts @@ -15,6 +15,7 @@ import type { CoreCapability } from '@core/capabilities' import type { AiTool, ToolContext } from '../../runtime/types' import { getDraftSite } from '../../../repositories/site' import { hasEditorBridge } from '../editorBridge' +import { MAIN_SCOPE } from '../../../branches/scope' const CONTEXT_READ_CAPS: readonly CoreCapability[] = [ 'site.read', @@ -57,12 +58,12 @@ export const contextMcpTools: AiTool[] = [ requiredCapabilities: CONTEXT_READ_CAPS, handler: async (input, ctx: ToolContext) => { const { entryId } = input as { entryId?: string } - const site = await getDraftSite(ctx.db) + const site = await getDraftSite(ctx.db, MAIN_SCOPE) const { rows } = await ctx.db` select id, table_id, cells_json from data_rows - where table_id = 'pages' and deleted_at is null + where branch_id = 'main' and table_id = 'pages' and deleted_at is null ` const templates = rows .filter((r) => r.cells_json?.templateEnabled) diff --git a/server/ai/mcp/tools/documentTools.ts b/server/ai/mcp/tools/documentTools.ts index dc583692f..2bf3af9e5 100644 --- a/server/ai/mcp/tools/documentTools.ts +++ b/server/ai/mcp/tools/documentTools.ts @@ -17,6 +17,7 @@ import { Type } from '@core/utils/typeboxHelpers' import { describeAgentDocuments } from '@core/ai' import type { AiTool, ToolContext } from '../../runtime/types' import { getDraftSiteDocument } from '../../../repositories/publish' +import { MAIN_SCOPE } from '../../../branches/scope' export const documentMcpTools: AiTool[] = [ { @@ -28,7 +29,7 @@ export const documentMcpTools: AiTool[] = [ inputSchema: Type.Object({}, { additionalProperties: false }), requiredCapabilities: ['site.read'], handler: async (_input, ctx: ToolContext) => { - const site = await getDraftSiteDocument(ctx.db) + const site = await getDraftSiteDocument(ctx.db, MAIN_SCOPE) if (!site) return { ok: false, error: 'No site found.' } return { currentDocument: null, diff --git a/server/ai/mcp/tools/styleTools.test.ts b/server/ai/mcp/tools/styleTools.test.ts index 7114dd679..7c131c4bd 100644 --- a/server/ai/mcp/tools/styleTools.test.ts +++ b/server/ai/mcp/tools/styleTools.test.ts @@ -4,6 +4,7 @@ import { createCapabilityTestHarness, type CapabilityTestHarness } from '../../. import { saveDraftSite } from '../../../repositories/site' import type { ToolContext } from '../../runtime/types' import { styleMcpTools } from './styleTools' +import { MAIN_SCOPE } from '../../../branches/scope' describe('MCP site_read_styles', () => { let harness: CapabilityTestHarness @@ -18,7 +19,7 @@ describe('MCP site_read_styles', () => { it('includes Core Framework font-token variables in token-inclusive reads', async () => { const base = makeSite() - await saveDraftSite(harness.db, { + await saveDraftSite(harness.db, MAIN_SCOPE, { ...base, settings: { ...base.settings, @@ -55,6 +56,7 @@ describe('MCP site_read_styles', () => { userId: 'owner', capabilities: ['site.read'], scope: 'site', + branch: MAIN_SCOPE, conversationId: 'test', snapshot: null, signal: new AbortController().signal, diff --git a/server/ai/mcp/tools/styleTools.ts b/server/ai/mcp/tools/styleTools.ts index fb8cb755d..08bdcbb48 100644 --- a/server/ai/mcp/tools/styleTools.ts +++ b/server/ai/mcp/tools/styleTools.ts @@ -20,6 +20,7 @@ import { generateClassCSS, generateFrameworkCss } from '@core/publisher' import type { CoreCapability } from '@core/capabilities' import type { AiTool, ToolContext } from '../../runtime/types' import { getDraftSite } from '../../../repositories/site' +import { MAIN_SCOPE } from '../../../branches/scope' const SITE_READ_CAPS: readonly CoreCapability[] = [ 'site.read', @@ -66,7 +67,7 @@ export const styleMcpTools: AiTool[] = [ className?: string includeTokens?: boolean } - const site = await getDraftSite(ctx.db) + const site = await getDraftSite(ctx.db, MAIN_SCOPE) if (!site) return { ok: false, error: 'No site found.' } // Author-defined classes + ambient rules. Framework-generated utility @@ -122,7 +123,7 @@ export const styleMcpTools: AiTool[] = [ inputSchema: Type.Object({}, { additionalProperties: false }), requiredCapabilities: SITE_READ_CAPS, handler: async (_input, ctx: ToolContext) => { - const site = await getDraftSite(ctx.db) + const site = await getDraftSite(ctx.db, MAIN_SCOPE) if (!site) return { ok: false, error: 'No site found.' } return { breakpoints: site.breakpoints.map((b, i) => ({ diff --git a/server/ai/runtime/types.ts b/server/ai/runtime/types.ts index b75e9b4b7..afdc8f51b 100644 --- a/server/ai/runtime/types.ts +++ b/server/ai/runtime/types.ts @@ -135,6 +135,11 @@ export interface AiTool { export interface ToolContext { /** Database client — server-side tool handlers query through this. */ readonly db: import('../../db/client').DbClient + /** + * Branch the tool reads and writes. The chat endpoint resolves it from the + * request like every CMS route; MCP headless tools run on main. + */ + readonly branch: import('../../branches/scope').BranchScope readonly userId: string /** The caller's capability set — handlers and the re-check gate read this. */ readonly capabilities: readonly CoreCapability[] diff --git a/server/ai/tools/content/readTools.test.ts b/server/ai/tools/content/readTools.test.ts index a7c9f39ee..93c83dbed 100644 --- a/server/ai/tools/content/readTools.test.ts +++ b/server/ai/tools/content/readTools.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test' import { createCapabilityTestHarness, type CapabilityTestHarness } from '../../../../src/__tests__/helpers/capabilityHarness' import { createDataTable } from '../../../repositories/data' import { contentReadTools } from './readTools' +import { MAIN_SCOPE } from '../../../branches/scope' describe('content read tools', () => { let harness: CapabilityTestHarness @@ -15,7 +16,7 @@ describe('content read tools', () => { }) it('keeps collection discovery aligned with the Content workspace', async () => { - await createDataTable(harness.db, { + await createDataTable(harness.db, MAIN_SCOPE, { id: 'projects', name: 'Projects', slug: 'projects', @@ -24,7 +25,7 @@ describe('content read tools', () => { singularLabel: 'Project', pluralLabel: 'Projects', }) - await createDataTable(harness.db, { + await createDataTable(harness.db, MAIN_SCOPE, { id: 'people', name: 'People', slug: 'people', @@ -43,6 +44,7 @@ describe('content read tools', () => { userId: 'owner', capabilities: ['data.system.tables.read', 'data.custom.tables.read'], scope: 'content', + branch: MAIN_SCOPE, conversationId: 'test', snapshot: null, signal: new AbortController().signal, diff --git a/server/ai/tools/content/readTools.ts b/server/ai/tools/content/readTools.ts index 255bf7a30..5253de60e 100644 --- a/server/ai/tools/content/readTools.ts +++ b/server/ai/tools/content/readTools.ts @@ -133,7 +133,7 @@ const listCollectionsTool: AiTool = { 'List every Content-workspace collection (routable post types only) with id, slug, label, kind, row count, and primary field id. Pages are edited through Site tools; reusable tables through Data tools.', inputSchema: ListCollectionsInput, handler: async (_input, ctx) => { - const tables = await listDataTablesWithCounts(ctx.db) + const tables = await listDataTablesWithCounts(ctx.db, ctx.branch) return { collections: tables .filter((t) => CONTENT_KIND_VISIBLE.has(t.kind)) @@ -160,7 +160,7 @@ const getCollectionSchemaTool: AiTool = { inputSchema: GetCollectionSchemaInput, handler: async (input, ctx) => { const { tableId } = input as Static - const tables = await listDataTablesWithCounts(ctx.db) + const tables = await listDataTablesWithCounts(ctx.db, ctx.branch) const table = tables.find((t) => t.id === tableId) if (!table) { return { ok: false, error: `Collection ${tableId} not found.` } @@ -202,7 +202,7 @@ const listDocumentsTool: AiTool = { inputSchema: ListDocumentsInput, handler: async (input, ctx) => { const args = input as Static - const all = await listDataRows(ctx.db, args.tableId) + const all = await listDataRows(ctx.db, ctx.branch, args.tableId) let filtered = all if (args.status) filtered = filtered.filter((r) => r.status === args.status) if (args.authorUserId) filtered = filtered.filter((r) => r.authorUserId === args.authorUserId) @@ -236,7 +236,7 @@ const getDocumentTool: AiTool = { inputSchema: GetDocumentInput, handler: async (input, ctx) => { const { documentId } = input as Static - const row = await getDataRow(ctx.db, documentId) + const row = await getDataRow(ctx.db, ctx.branch, documentId) if (!row) { return { ok: false, error: `Document ${documentId} not found.` } } @@ -277,9 +277,9 @@ const searchDocumentsTool: AiTool = { inputSchema: SearchDocumentsInput, handler: async (input, ctx) => { const { query, limit } = input as Static - const results = await searchDataRows(ctx.db, query, limit ?? 25) + const results = await searchDataRows(ctx.db, ctx.branch, query, limit ?? 25) // Only surface Content-workspace post-type rows. - const tables = await listDataTablesWithCounts(ctx.db) + const tables = await listDataTablesWithCounts(ctx.db, ctx.branch) const visibleTableIds = new Set( tables.filter((t) => CONTENT_KIND_VISIBLE.has(t.kind)).map((t) => t.id), ) diff --git a/server/ai/tools/site/readTools.test.ts b/server/ai/tools/site/readTools.test.ts index a140bcc02..bdc8f3cc2 100644 --- a/server/ai/tools/site/readTools.test.ts +++ b/server/ai/tools/site/readTools.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test' import { createCapabilityTestHarness, type CapabilityTestHarness } from '../../../../src/__tests__/helpers/capabilityHarness' import { createDataTable } from '../../../repositories/data' import { siteReadTools } from './readTools' +import { MAIN_SCOPE } from '../../../branches/scope' describe('site read tools', () => { let harness: CapabilityTestHarness @@ -15,7 +16,7 @@ describe('site read tools', () => { }) it('lists only routable post types as template targets', async () => { - await createDataTable(harness.db, { + await createDataTable(harness.db, MAIN_SCOPE, { id: 'projects', name: 'Projects', slug: 'projects', @@ -24,7 +25,7 @@ describe('site read tools', () => { singularLabel: 'Project', pluralLabel: 'Projects', }) - await createDataTable(harness.db, { + await createDataTable(harness.db, MAIN_SCOPE, { id: 'people', name: 'People', slug: 'people', @@ -42,6 +43,7 @@ describe('site read tools', () => { userId: 'owner', capabilities: ['site.read'], scope: 'site', + branch: MAIN_SCOPE, conversationId: 'test', snapshot: null, signal: new AbortController().signal, diff --git a/server/ai/tools/site/readTools.ts b/server/ai/tools/site/readTools.ts index 3ab763b1b..0a5a91398 100644 --- a/server/ai/tools/site/readTools.ts +++ b/server/ai/tools/site/readTools.ts @@ -124,7 +124,7 @@ const listPostTypesTool: AiTool = { 'List the routable post types a `postTypes` template can target. Each entry has { slug, label, routeBase, kind }; pass the `slug` values to site_set_page_template\'s `target.tableSlugs`. System tables, pages, components, layouts, and reusable data tables are excluded.', inputSchema: ListPostTypesInput, handler: async (_input, ctx) => { - const tables = await listDataTablesWithCounts(ctx.db) + const tables = await listDataTablesWithCounts(ctx.db, ctx.branch) const postTypes = tables .filter((t) => t.kind === 'postType' && t.routeBase.trim() !== '') .map((t) => ({ @@ -215,7 +215,7 @@ const listLoopSourcesTool: AiTool = { filterSchema: source.filterSchema, orderByOptions: source.orderByOptions, })) - const tables = await listDataTablesWithCounts(ctx.db) + const tables = await listDataTablesWithCounts(ctx.db, ctx.branch) const dataMeta = buildDataMeta(tables) const dataRowsSource = loopSourceRegistry.get('data.rows') const dataRowsFields = dataRowsSource?.fields.map(loopFieldToAgentField) ?? [] diff --git a/server/auth/capabilities.ts b/server/auth/capabilities.ts index bc76761c3..a0232cae3 100644 --- a/server/auth/capabilities.ts +++ b/server/auth/capabilities.ts @@ -57,6 +57,7 @@ const adminCapabilities: CoreCapability[] = [ 'content.publish.own', 'content.publish.any', 'content.manage', + 'site.branches.manage', 'media.read', 'media.write', 'media.replace', diff --git a/server/branches/contentHash.ts b/server/branches/contentHash.ts new file mode 100644 index 000000000..226f482ea --- /dev/null +++ b/server/branches/contentHash.ts @@ -0,0 +1,102 @@ +/** + * Content hashes for branch bases and three-way diffs. + * + * A base records what an entity looked like when a branch was forked (or last + * merged); comparing a side's current hash against it says whether that side + * changed. The hashed shape must be exactly what merge compares, so the three + * projections below are the single definition of "the content of a row, a + * table, and the shell". + */ +import { createHash } from 'node:crypto' +import type { Static, TSchema } from '@sinclair/typebox' +import { Type, safeParseValue } from '@core/utils/typeboxHelpers' +import { canonicalJson } from '@core/utils/canonicalJson' +import { DataFieldSchema, DataTableKindSchema, type DataRow, type DataTable } from '@core/data/schemas' +import type { SiteShell } from '@core/page-tree' + +export type BranchEntityKind = 'row' | 'table' | 'site' + +export interface RowContent { + tableId: string + cells: Record + slug: string +} + +export interface TableContent { + name: string + slug: string + kind: DataTable['kind'] + routeBase: string + singularLabel: string + pluralLabel: string + primaryFieldId: string + fields: DataTable['fields'] +} + +export interface SiteContent { + name: string + shell: Omit +} + +export function rowContent(row: Pick): RowContent { + return { tableId: row.tableId, cells: row.cells, slug: row.slug } +} + +export function tableContent(table: DataTable): TableContent { + return { + name: table.name, + slug: table.slug, + kind: table.kind, + routeBase: table.routeBase, + singularLabel: table.singularLabel, + pluralLabel: table.pluralLabel, + primaryFieldId: table.primaryFieldId, + fields: table.fields, + } +} + +export function siteContent(shell: SiteShell): SiteContent { + const { id: _id, name, createdAt: _createdAt, updatedAt: _updatedAt, ...rest } = shell + return { name, shell: rest } +} + +export function contentHash(value: unknown): string { + return createHash('sha256').update(canonicalJson(value)).digest('hex') +} + +// --------------------------------------------------------------------------- +// Wire/storage validation — merged content is rebuilt from JSON and must +// match the shapes the repositories accept before it is written back. +// --------------------------------------------------------------------------- + +export const RowContentSchema = Type.Object({ + tableId: Type.String(), + cells: Type.Record(Type.String(), Type.Unknown()), + slug: Type.String(), +}) + +export const TableContentSchema = Type.Object({ + name: Type.String(), + slug: Type.String(), + kind: DataTableKindSchema, + routeBase: Type.String(), + singularLabel: Type.String(), + pluralLabel: Type.String(), + primaryFieldId: Type.String(), + fields: Type.Array(DataFieldSchema), +}) + +export const SiteContentSchema = Type.Object({ + name: Type.String(), + shell: Type.Record(Type.String(), Type.Unknown()), +}) + +/** Parse merged content back into its typed shape; throws on drift. */ +export function parseContent(schema: T, value: unknown, what: string): Static { + const parsed = safeParseValue(schema, value) + if (!parsed.ok) { + const detail = parsed.errors.map((issue) => `${issue.path}: ${issue.message}`).join('; ') + throw new Error(`[branches] merged ${what} content is malformed: ${detail}`) + } + return parsed.value +} diff --git a/server/branches/deleteBranch.ts b/server/branches/deleteBranch.ts new file mode 100644 index 000000000..9bad34470 --- /dev/null +++ b/server/branches/deleteBranch.ts @@ -0,0 +1,52 @@ +/** + * Delete a branch — drop its content rows, its collab documents, and its + * registry row (bases and preview links cascade from the registry row). + * + * Main can never be deleted. The relay, when supplied, tombstones the branch + * and evicts its resident docs first so an in-flight persist cannot + * resurrect a blob after the delete; bound sockets receive a reset, rebind, + * are told the branch is gone, and the client falls back to main. A delete + * that fails after that lifts the tombstone again — the rows survived, so + * the branch must accept documents (reseeded from its rows) again. + */ +import { encodeCollabDocId, siteDocId } from '@core/collab' +import { MAIN_BRANCH_ID } from '@core/branches' +import type { DbClient } from '../db/client' +import type { CollabRelay } from '../collab/relay' +import { deleteBranchRow } from '../repositories/branches' +import { serializeCollabAwareWrite } from '../repositories/rowWriteEvents' + +export async function deleteBranch( + db: DbClient, + branchId: string, + relay: CollabRelay | null, +): Promise { + if (branchId === MAIN_BRANCH_ID) return false + // Tombstone the branch in the relay BEFORE the rows go: from here on a + // socket that rebinds is told the branch is gone instead of reseeding + // rows that are about to be deleted. The delete itself runs on the + // collab-aware lane so no relay persist interleaves with it. + if (relay) await relay.forgetBranch(branchId) + + const rowDocPrefixes = (['page', 'component', 'layout'] as const).map( + (kind) => `${encodeCollabDocId({ kind, branchId, rowId: '' })}%`, + ) + + try { + return await serializeCollabAwareWrite(() => db.transaction(async (tx) => { + const deleted = await deleteBranchRow(tx, branchId) + if (!deleted) return false + await tx`delete from data_rows where branch_id = ${branchId}` + await tx`delete from data_tables where branch_id = ${branchId}` + await tx`delete from site where branch_id = ${branchId}` + await tx`delete from collab_documents where doc_id = ${siteDocId(branchId)}` + for (const prefix of rowDocPrefixes) { + await tx`delete from collab_documents where doc_id like ${prefix}` + } + return true + })) + } catch (err) { + await relay?.rememberBranch(branchId) + throw err + } +} diff --git a/server/branches/entities.ts b/server/branches/entities.ts new file mode 100644 index 000000000..427f581b7 --- /dev/null +++ b/server/branches/entities.ts @@ -0,0 +1,72 @@ +/** + * The mergeable entities of a branch — the site shell, every table, every + * row — in one keyed map, with the content projection the merge compares + * and hashes. Shared by fork (to record bases) and merge (to plan). + */ +import { SITE_SHELL_LOGICAL_ID } from '@core/branches' +import type { DataRow, DataTable } from '@core/data/schemas' +import type { DbClient } from '../db/client' +import type { BranchScope } from './scope' +import { rowContent, siteContent, tableContent, type BranchEntityKind } from './contentHash' +import { listDataRows, listDataTables } from '../repositories/data' +import { getDraftSite } from '../repositories/site' + +export interface BranchEntity { + kind: BranchEntityKind + logicalId: string + label: string + /** Logical id of the row's table; null for tables and the shell. */ + tableId: string | null + tableName: string | null + content: unknown +} + +export const SITE_ENTITY_KEY = `site:${SITE_SHELL_LOGICAL_ID}` + +export function entityKey(kind: BranchEntityKind, logicalId: string): string { + return `${kind}:${logicalId}` +} + +function rowLabel(row: DataRow, table: DataTable): string { + const title = row.cells[table.primaryFieldId] ?? row.cells.title + if (typeof title === 'string' && title.trim()) return title.trim() + return row.slug || row.id +} + +export async function collectBranchEntities(db: DbClient, scope: BranchScope): Promise> { + const entities = new Map() + const shell = await getDraftSite(db, scope) + if (shell) { + entities.set(SITE_ENTITY_KEY, { + kind: 'site', + logicalId: SITE_SHELL_LOGICAL_ID, + label: 'Site settings', + tableId: null, + tableName: null, + content: siteContent(shell), + }) + } + const tables = await listDataTables(db, scope) + for (const table of tables) { + entities.set(entityKey('table', table.id), { + kind: 'table', + logicalId: table.id, + label: table.name, + tableId: null, + tableName: null, + content: tableContent(table), + }) + const rows = await listDataRows(db, scope, table.id) + for (const row of rows) { + entities.set(entityKey('row', row.id), { + kind: 'row', + logicalId: row.id, + label: rowLabel(row, table), + tableId: table.id, + tableName: table.singularLabel, + content: rowContent(row), + }) + } + } + return entities +} diff --git a/server/branches/fork.ts b/server/branches/fork.ts new file mode 100644 index 000000000..4e1e739e8 --- /dev/null +++ b/server/branches/fork.ts @@ -0,0 +1,149 @@ +/** + * Fork a branch — copy a branch's whole content (shell, tables, rows) under a + * new branch id and record the merge bases. + * + * Bases are MAIN's content at fork time, whatever the branch was forked + * from: merges and updates always compare against main, so a branch forked + * off another branch must still see everything the parent added as its own + * changes. Entities that only exist on the parent get no base (they become + * "create" on merge). + * + * Runs as ONE transaction so a half-copied branch can never exist. Media, + * plugins, users, versions, and redirects are shared with — or belong to — + * main and are never copied. Collab blobs are not copied either: the relay + * seeds a branch doc from its row JSON the first time someone opens it. + */ +import type { SiteBranch } from '@core/branches' +import { physicalId, SITE_SHELL_LOGICAL_ID } from '@core/branches' +import type { DataRowStatus } from '@core/data/schemas' +import type { DbClient } from '../db/client' +import { insertBranch } from '../repositories/branches' +import { upsertBranchBases, type BranchBase } from '../repositories/branchBases' +import { listDataTables } from '../repositories/data' +import { getDraftSite } from '../repositories/site' +import { contentHash } from './contentHash' +import { collectBranchEntities } from './entities' +import { MAIN_SCOPE, type BranchScope } from './scope' +import { runPublishFlush } from '../publish/publishFlush' +import { serializeCollabAwareWrite } from '../repositories/rowWriteEvents' + +export interface ForkBranchInput { + id: string + name: string + fromBranchId: string + createdByUserId: string | null +} + +interface RawRow { + logical_id: string + table_id: string + cells_json: Record + slug: string + status: DataRowStatus + author_user_id: string | null + created_by_user_id: string | null + updated_by_user_id: string | null + published_by_user_id: string | null + plugin_actor_id: string | null + created_at: string | Date + updated_at: string | Date + published_at: string | Date | null +} + +/** + * A scheduled row cannot stay scheduled on a branch — only main publishes — + * so it lands as a draft; every other status is informational ("live on + * main") and survives the copy. + */ +function branchStatus(status: DataRowStatus): DataRowStatus { + return status === 'scheduled' ? 'draft' : status +} + +export async function forkBranch(db: DbClient, input: ForkBranchInput): Promise { + const from: BranchScope = { branchId: input.fromBranchId } + const to: BranchScope = { branchId: input.id } + + // Live editors hold edits in the relay's debounce window: persist them so + // the copy — and the bases read from main — see exactly what people see, + // and hold the collab-aware lane so no persist lands between the two. + await runPublishFlush() + return serializeCollabAwareWrite(() => db.transaction(async (tx) => { + const branch = await insertBranch(tx, { + id: input.id, + name: input.name, + baseBranchId: input.fromBranchId, + createdByUserId: input.createdByUserId, + }) + + // Shell — one row, copied with a fresh seq. + const shell = await getDraftSite(tx, from) + if (shell) { + await tx` + insert into site (id, name, settings_json, seq, branch_id) + select ${physicalId(to.branchId, SITE_SHELL_LOGICAL_ID)}, name, settings_json, 0, + ${to.branchId} + from site + where branch_id = ${from.branchId} + ` + } + + // Tables — the physical key is minted per row in TS (the single scheme). + const tables = await listDataTables(tx, from) + const tablePhysicalIds = new Map() + for (const table of tables) { + const physical = physicalId(to.branchId, table.id) + tablePhysicalIds.set(physicalId(from.branchId, table.id), physical) + await tx` + insert into data_tables ( + id, branch_id, name, slug, kind, route_base, singular_label, + plural_label, primary_field_id, fields_json, system, + created_by_user_id, updated_by_user_id, created_at, updated_at + ) + select ${physical}, ${to.branchId}, name, slug, kind, route_base, + singular_label, plural_label, primary_field_id, fields_json, system, + created_by_user_id, updated_by_user_id, created_at, updated_at + from data_tables + where id = ${physicalId(from.branchId, table.id)} + ` + } + + // Rows — live rows only; versions, schedules, and seqs do not cross. + const { rows } = await tx` + select logical_id, table_id, cells_json, slug, status, + author_user_id, created_by_user_id, updated_by_user_id, published_by_user_id, + plugin_actor_id, created_at, updated_at, published_at + from data_rows + where branch_id = ${from.branchId} + and deleted_at is null + ` + for (const row of rows) { + const tableId = tablePhysicalIds.get(row.table_id) + if (!tableId) continue // a row of a soft-deleted table has nowhere to go + const status = branchStatus(row.status) + await tx` + insert into data_rows ( + id, branch_id, table_id, cells_json, slug, status, + author_user_id, created_by_user_id, updated_by_user_id, published_by_user_id, + plugin_actor_id, created_at, updated_at, published_at + ) + values ( + ${physicalId(to.branchId, row.logical_id)}, ${to.branchId}, + ${tableId}, ${row.cells_json}, ${row.slug}, ${status}, + ${row.author_user_id}, ${row.created_by_user_id}, ${row.updated_by_user_id}, + ${row.published_by_user_id}, ${row.plugin_actor_id}, + ${row.created_at}, ${row.updated_at}, ${row.published_at} + ) + ` + } + + const mainEntities = await collectBranchEntities(tx, MAIN_SCOPE) + const bases: BranchBase[] = [...mainEntities.values()].map((entity) => ({ + kind: entity.kind, + logicalId: entity.logicalId, + contentHash: contentHash(entity.content), + content: entity.content, + })) + await upsertBranchBases(tx, to.branchId, bases) + return branch + })) +} diff --git a/server/branches/merge.ts b/server/branches/merge.ts new file mode 100644 index 000000000..f16f72b81 --- /dev/null +++ b/server/branches/merge.ts @@ -0,0 +1,443 @@ +/** + * Merging a branch into main, and updating a branch from main. + * + * Both are the same three-way comparison run in opposite directions. Every + * entity (the site shell, each table, each row) is compared on three sides: + * the BASE — main's content when the branch and main last agreed (fork, or + * the latest merge/update; kept in `site_branch_bases`) — the side receiving + * changes (`into`), and the side contributing them (`from`). + * + * - only `from` moved → applied + * - only `into` moved → nothing to do + * - both moved, different fields → merged field by field + * - both moved, same field → conflict; the reviewer picks a side + * + * MERGE (branch → main) writes the result to main, mirrors it onto the + * branch so both sides agree, and records it as the new base. UPDATE + * (main → branch) only ever writes the branch: main is the live site and an + * update must never touch it, so the base becomes main's content as of the + * update. Row publish status is never part of the content: a merge changes + * drafts, never what is live. + */ +import { MAIN_BRANCH_ID, mergeJson } from '@core/branches' +import { validateSite } from '@core/persistence/validate' +import type { DbClient } from '../db/client' +import { MAIN_SCOPE, isMainScope, type BranchScope } from './scope' +import { + RowContentSchema, + SiteContentSchema, + TableContentSchema, + contentHash, + parseContent, + type BranchEntityKind, +} from './contentHash' +import { collectBranchEntities, type BranchEntity } from './entities' +import { deleteBranchBases, listBranchBases, upsertBranchBases, type BranchBase } from '../repositories/branchBases' +import { touchBranch } from '../repositories/branches' +import { + createDataTable, + getDataRow, + getDataTable, + restoreDataTable, + saveDataRowDraft, + softDeleteDataRow, + softDeleteDataTable, + updateDataRowTable, + updateDataTable, + upsertDataRowDraft, +} from '../repositories/data' +import { getDraftSite, saveDraftSite } from '../repositories/site' +import { + notifyRowWrite, + notifyShellWrite, + serializeCollabAwareWrite, + type RowWriteKind, +} from '../repositories/rowWriteEvents' +import { + emitContentEntryCreated, + emitContentEntryDeleted, + emitContentEntryUpdated, +} from '../publish/contentEvents' +import { runPublishFlush } from '../publish/publishFlush' + +/** `merge`: branch → main. `update`: main → branch. */ +export type MergeDirection = 'merge' | 'update' +/** Which side wins a conflicting entity. */ +export type MergeResolution = 'into' | 'from' +export type MergeAction = 'create' | 'update' | 'delete' + +export interface MergeChange { + /** `:` — the key resolutions are addressed by. */ + key: string + kind: BranchEntityKind + logicalId: string + label: string + tableId: string | null + tableName: string | null + action: MergeAction + /** Field paths both sides changed differently; non-empty means a decision is needed. */ + conflicts: string[] +} + +export interface MergePlan { + branchId: string + direction: MergeDirection + from: string + into: string + changes: MergeChange[] + conflictCount: number +} + +interface Work { + change: MergeChange + ours: BranchEntity | undefined + theirs: BranchEntity | undefined + /** The outcome when there is no conflict: content, or null for a deletion. */ + result: unknown | null +} + +export class MergeConflictsUnresolvedError extends Error { + readonly keys: string[] + + constructor(keys: string[]) { + super(`Resolve ${keys.length} conflicting change${keys.length === 1 ? '' : 's'} before merging`) + this.name = 'MergeConflictsUnresolvedError' + this.keys = keys + } +} + +/** A planned change that cannot be applied as such (e.g. a table that still has rows). */ +export class MergeApplyError extends Error { + readonly key: string + + constructor(key: string, message: string) { + super(message) + this.name = 'MergeApplyError' + this.key = key + } +} + +const DELETED_MARKER = '(deleted)' + +function scopesFor(branchId: string, direction: MergeDirection): { from: BranchScope; into: BranchScope } { + const branch: BranchScope = { branchId } + return direction === 'merge' ? { from: branch, into: MAIN_SCOPE } : { from: MAIN_SCOPE, into: branch } +} + +/** Site first, then table creates/updates, rows, and table deletes last. */ +function changeOrder(change: MergeChange): number { + if (change.kind === 'site') return 0 + if (change.kind === 'table') return change.action === 'delete' ? 3 : 1 + return 2 +} + +function describe(entity: BranchEntity, action: MergeAction, conflicts: string[]): MergeChange { + return { + key: `${entity.kind}:${entity.logicalId}`, + kind: entity.kind, + logicalId: entity.logicalId, + label: entity.label, + tableId: entity.tableId, + tableName: entity.tableName, + action, + conflicts, + } +} + +interface PlanResult { + plan: MergePlan + work: Work[] + /** + * Entities identical on both sides whose base is stale or missing. Not + * changes — but applying moves their base forward so a later edit on one + * side is not reported as a conflict against content both sides share. + */ + converged: BranchBase[] + /** Bases of entities gone from both sides — a later re-creation must read as new. */ + stale: Array<{ kind: BranchEntityKind; logicalId: string }> +} + +/** + * Compute what a merge (or update) would do. Pure with respect to the + * database — nothing is written. + */ +export async function planBranchMerge( + db: DbClient, + branchId: string, + direction: MergeDirection, +): Promise { + if (branchId === MAIN_BRANCH_ID) throw new Error('main cannot be merged into itself') + const { from, into } = scopesFor(branchId, direction) + const bases = new Map((await listBranchBases(db, branchId)).map((base) => [`${base.kind}:${base.logicalId}`, base])) + const [fromEntities, intoEntities] = await Promise.all([ + collectBranchEntities(db, from), + collectBranchEntities(db, into), + ]) + + const work: Work[] = [] + const converged: BranchBase[] = [] + const stale: PlanResult['stale'] = [] + const keys = new Set([...fromEntities.keys(), ...intoEntities.keys(), ...bases.keys()]) + for (const key of keys) { + const theirs = fromEntities.get(key) + const ours = intoEntities.get(key) + const base = bases.get(key) + const theirsHash = theirs ? contentHash(theirs.content) : null + const oursHash = ours ? contentHash(ours.content) : null + if (theirsHash === oursHash) { + if (ours && base?.contentHash !== oursHash) { + converged.push({ kind: ours.kind, logicalId: ours.logicalId, contentHash: oursHash!, content: ours.content }) + } else if (!ours && base) { + stale.push({ kind: base.kind, logicalId: base.logicalId }) + } + continue + } + + if (!theirs) { + if (!base || !ours) continue + const conflicts = base.contentHash === oursHash ? [] : [DELETED_MARKER] + work.push({ change: describe(ours, 'delete', conflicts), ours, theirs, result: null }) + continue + } + if (!ours) { + if (base && base.contentHash === theirsHash) continue + const conflicts = base ? [DELETED_MARKER] : [] + work.push({ change: describe(theirs, 'create', conflicts), ours, theirs, result: theirs.content }) + continue + } + if (base && base.contentHash === theirsHash) continue + if (base && base.contentHash === oursHash) { + work.push({ change: describe(theirs, 'update', []), ours, theirs, result: theirs.content }) + continue + } + const merged = mergeJson(base?.content, ours.content, theirs.content) + work.push({ change: describe(theirs, 'update', merged.conflicts), ours, theirs, result: merged.value }) + } + + work.sort((a, b) => changeOrder(a.change) - changeOrder(b.change) || a.change.label.localeCompare(b.change.label)) + const changes = work.map((entry) => entry.change) + return { + plan: { + branchId, + direction, + from: from.branchId, + into: into.branchId, + changes, + conflictCount: changes.filter((change) => change.conflicts.length > 0).length, + }, + work, + converged, + stale, + } +} + +function resolvedResult(entry: Work, resolutions: Readonly>): unknown | null { + if (entry.change.conflicts.length === 0) return entry.result + const resolution = resolutions[entry.change.key] + if (resolution === 'from') return entry.theirs?.content ?? null + return entry.ours?.content ?? null +} + +interface RowNotice { + kind: RowWriteKind + tableId: string + rowId: string + /** Cell ids that changed on an update (for the content event). */ + changedFieldIds: string[] +} + +interface WriteNotices { + rows: RowNotice[] + shell: boolean +} + +function changedCellIds(before: unknown, after: unknown): string[] { + const a = (before as { cells?: Record } | null)?.cells ?? {} + const b = (after as { cells?: Record } | null)?.cells ?? {} + const keys = new Set([...Object.keys(a), ...Object.keys(b)]) + return [...keys].filter((key) => JSON.stringify(a[key]) !== JSON.stringify(b[key])) +} + +async function writeEntity( + tx: DbClient, + scope: BranchScope, + entry: Work, + result: unknown | null, + actorUserId: string | null, + notices: WriteNotices, +): Promise { + const { kind, logicalId, key } = entry.change + if (kind === 'site') { + const current = await getDraftSite(tx, scope) + if (!current || result === null) return + const content = parseContent(SiteContentSchema, result, 'site') + // The merged shell is rebuilt from stored JSON — validate it as a whole + // before it becomes the draft, exactly like the relay's projection. + const shell = validateSite({ + ...current, + ...content.shell, + id: current.id, + name: content.name, + createdAt: current.createdAt, + updatedAt: Date.now(), + }) + await saveDraftSite(tx, scope, shell, actorUserId, { collabInternal: true }) + notices.shell = true + return + } + if (kind === 'table') { + if (result === null) { + const deleted = await softDeleteDataTable(tx, scope, logicalId, actorUserId) + if (!deleted) { + throw new MergeApplyError( + key, + `The table "${entry.change.label}" still has rows on ${scope.branchId}; delete them or keep the table`, + ) + } + return + } + const content = parseContent(TableContentSchema, result, 'table') + const settings = { + name: content.name, + slug: content.slug, + routeBase: content.routeBase, + singularLabel: content.singularLabel, + pluralLabel: content.pluralLabel, + primaryFieldId: content.primaryFieldId, + fields: content.fields, + updatedByUserId: actorUserId, + } + if (await getDataTable(tx, scope, logicalId)) { + await updateDataTable(tx, scope, logicalId, settings) + return + } + // A table this side had deleted comes back with the incoming settings. + if (await restoreDataTable(tx, scope, logicalId, settings)) return + await createDataTable(tx, scope, { + id: logicalId, + ...content, + createdByUserId: actorUserId, + updatedByUserId: actorUserId, + }) + return + } + if (result === null) { + const deleted = await softDeleteDataRow(tx, scope, logicalId, actorUserId, { collabInternal: true }) + if (deleted) notices.rows.push({ kind: 'delete', tableId: deleted.tableId, rowId: logicalId, changedFieldIds: [] }) + return + } + const content = parseContent(RowContentSchema, result, 'row') + const existing = await getDataRow(tx, scope, logicalId) + if (existing) { + if (existing.tableId !== content.tableId) { + await updateDataRowTable(tx, scope, logicalId, content.tableId, actorUserId, { collabInternal: true }) + notices.rows.push({ kind: 'delete', tableId: existing.tableId, rowId: logicalId, changedFieldIds: [] }) + } + await saveDataRowDraft(tx, scope, logicalId, { cells: content.cells, slug: content.slug }, actorUserId, null, { collabInternal: true }) + notices.rows.push({ + kind: 'update', + tableId: content.tableId, + rowId: logicalId, + changedFieldIds: changedCellIds({ cells: existing.cells }, content), + }) + return + } + await upsertDataRowDraft( + tx, + scope, + { id: logicalId, tableId: content.tableId, cells: content.cells, slug: content.slug }, + actorUserId, + { collabInternal: true }, + ) + notices.rows.push({ kind: 'create', tableId: content.tableId, rowId: logicalId, changedFieldIds: [] }) +} + +function emitCollabNotices(scope: BranchScope, notices: WriteNotices): void { + const byTable = new Map>() + for (const notice of notices.rows) { + const byKind = byTable.get(notice.tableId) ?? new Map() + byKind.set(notice.kind, [...(byKind.get(notice.kind) ?? []), notice.rowId]) + byTable.set(notice.tableId, byKind) + } + for (const [tableId, byKind] of byTable) { + for (const [kind, rowIds] of byKind) notifyRowWrite({ branchId: scope.branchId, tableId, rowIds, kind }) + } + if (notices.shell) notifyShellWrite(scope.branchId) +} + +/** Plugins learn about main's rows the moment a merge changes them. */ +async function emitContentEvents(db: DbClient, notices: WriteNotices, actorUserId: string | null): Promise { + const actor = actorUserId ? { kind: 'user' as const, userId: actorUserId } : { kind: 'system' as const } + for (const notice of notices.rows) { + if (notice.kind === 'create') await emitContentEntryCreated(db, MAIN_SCOPE, notice.rowId, actor) + else if (notice.kind === 'update') await emitContentEntryUpdated(db, MAIN_SCOPE, notice.rowId, notice.changedFieldIds, actor) + else await emitContentEntryDeleted(db, MAIN_SCOPE, notice.rowId, actor) + } +} + +export interface ApplyMergeInput { + branchId: string + direction: MergeDirection + resolutions: Readonly> + actorUserId: string | null +} + +export interface ApplyMergeResult { + plan: MergePlan +} + +/** + * Apply a merge or update. Replans against the live data first so a change + * that landed after the reviewer looked is never applied unseen: a new + * conflict without a resolution aborts before anything is written. + */ +export async function applyBranchMerge(db: DbClient, input: ApplyMergeInput): Promise { + // Live editors keep edits in the relay's debounce window; persist them so + // the merge reads exactly what people see. + await runPublishFlush() + // Everything that writes runs on the collab-aware lane; the plugin hooks + // fire AFTER it releases — a listener that writes content takes the same + // lane and would otherwise wait on the very merge that is waiting on it. + const { plan, into, intoNotices } = await serializeCollabAwareWrite(async () => { + const { plan, work, converged, stale } = await planBranchMerge(db, input.branchId, input.direction) + const unresolved = plan.changes + .filter((change) => change.conflicts.length > 0 && !input.resolutions[change.key]) + .map((change) => change.key) + if (unresolved.length > 0) throw new MergeConflictsUnresolvedError(unresolved) + + const { from, into } = scopesFor(input.branchId, input.direction) + const mirrorOntoFrom = input.direction === 'merge' + const intoNotices: WriteNotices = { rows: [], shell: false } + const fromNotices: WriteNotices = { rows: [], shell: false } + + await db.transaction(async (tx) => { + const bases: BranchBase[] = [...converged] + const removed: Array<{ kind: BranchEntityKind; logicalId: string }> = [...stale] + for (const entry of work) { + const result = resolvedResult(entry, input.resolutions) + const resultHash = result === null ? null : contentHash(result) + const oursHash = entry.ours ? contentHash(entry.ours.content) : null + const theirsHash = entry.theirs ? contentHash(entry.theirs.content) : null + if (resultHash !== oursHash) await writeEntity(tx, into, entry, result, input.actorUserId, intoNotices) + if (mirrorOntoFrom && resultHash !== theirsHash) { + await writeEntity(tx, from, entry, result, input.actorUserId, fromNotices) + } + // After a merge both sides hold the result. After an update main is + // untouched, so main's content is what the branch last agreed with. + const nextBase = mirrorOntoFrom ? result : entry.theirs?.content ?? null + const { kind, logicalId } = entry.change + if (nextBase === null) removed.push({ kind, logicalId }) + else bases.push({ kind, logicalId, contentHash: contentHash(nextBase), content: nextBase }) + } + await upsertBranchBases(tx, input.branchId, bases) + await deleteBranchBases(tx, input.branchId, removed) + await touchBranch(tx, input.branchId) + }) + + emitCollabNotices(into, intoNotices) + if (mirrorOntoFrom) emitCollabNotices(from, fromNotices) + return { plan, into, intoNotices } + }) + if (isMainScope(into)) await emitContentEvents(db, intoNotices, input.actorUserId) + return { plan } +} diff --git a/server/branches/previewLinks.ts b/server/branches/previewLinks.ts new file mode 100644 index 000000000..d36330646 --- /dev/null +++ b/server/branches/previewLinks.ts @@ -0,0 +1,98 @@ +/** + * Branch preview links — how a reviewer without an admin account sees a + * branch's draft on the public site. + * + * 1. An editor issues a link: a random token, stored hashed on + * `site_branch_previews`, one active link per branch. + * 2. Opening `/_instatic/preview/` validates it and sets an + * HttpOnly cookie carrying the token, then redirects to the site root. + * 3. Every public GET with that cookie renders the branch's draft instead + * of the published site (see server/publish/branchPreview.ts), with a + * banner offering `/_instatic/preview/exit`, which clears the cookie. + * + * Revoking (or deleting the branch) invalidates the cookie on the next + * request — the cookie is only ever the token, never a grant of its own. + */ +import { createHash, randomBytes } from 'node:crypto' +import type { DbClient } from '../db/client' +import { publicOriginIsHttps } from '../auth/security' +import { + createBranchPreview, + resolveBranchPreviewToken, + type BranchPreview, +} from '../repositories/branchPreviews' +import { branchExists } from '../repositories/branches' + +export const BRANCH_PREVIEW_COOKIE = 'instatic_branch_preview' +export const BRANCH_PREVIEW_PATH_PREFIX = '/_instatic/preview/' +export const BRANCH_PREVIEW_EXIT_PATH = `${BRANCH_PREVIEW_PATH_PREFIX}exit` +/** A preview cookie outlives a typical review round; revocation is the real bound. */ +const PREVIEW_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30 +const TOKEN_PATTERN = /^[A-Za-z0-9_-]{20,128}$/ + +export function hashPreviewToken(token: string): string { + return createHash('sha256').update(token).digest('hex') +} + +/** `/_instatic/preview/` — the path a shared link points at. */ +export function previewEntryPath(token: string): string { + return `${BRANCH_PREVIEW_PATH_PREFIX}${token}` +} + +/** The token from an entry path, or null when the path is not one. */ +export function previewTokenFromPath(pathname: string): string | null { + if (!pathname.startsWith(BRANCH_PREVIEW_PATH_PREFIX)) return null + const token = pathname.slice(BRANCH_PREVIEW_PATH_PREFIX.length) + return TOKEN_PATTERN.test(token) ? token : null +} + +export async function issueBranchPreviewLink( + db: DbClient, + input: { branchId: string; createdByUserId: string | null }, +): Promise<{ token: string; preview: BranchPreview }> { + const token = randomBytes(24).toString('base64url') + const preview = await createBranchPreview(db, { + branchId: input.branchId, + tokenHash: hashPreviewToken(token), + createdByUserId: input.createdByUserId, + }) + return { token, preview } +} + +/** The branch a token currently grants, or null when it is unknown or revoked. */ +export async function resolvePreviewToken(db: DbClient, token: string): Promise { + if (!TOKEN_PATTERN.test(token)) return null + const branchId = await resolveBranchPreviewToken(db, hashPreviewToken(token)) + if (!branchId || !(await branchExists(db, branchId))) return null + return branchId +} + +function readCookie(req: Request, name: string): string { + const cookie = req.headers.get('cookie') ?? '' + for (const part of cookie.split(';')) { + const [rawKey, ...rawValue] = part.trim().split('=') + if (rawKey === name) return rawValue.join('=') + } + return '' +} + +/** The branch this request previews via its cookie, or null. */ +export async function resolvePreviewCookie(req: Request, db: DbClient): Promise { + const token = readCookie(req, BRANCH_PREVIEW_COOKIE) + if (!token) return null + return resolvePreviewToken(db, token) +} + +function cookieAttributes(req: Request): string { + const secure = publicOriginIsHttps() || req.url.startsWith('https://') + const base = 'Path=/; HttpOnly; SameSite=Lax' + return secure ? `${base}; Secure` : base +} + +export function previewCookie(req: Request, token: string): string { + return `${BRANCH_PREVIEW_COOKIE}=${token}; ${cookieAttributes(req)}; Max-Age=${PREVIEW_COOKIE_MAX_AGE_SECONDS}` +} + +export function clearPreviewCookie(req: Request): string { + return `${BRANCH_PREVIEW_COOKIE}=; ${cookieAttributes(req)}; Max-Age=0` +} diff --git a/server/branches/scope.ts b/server/branches/scope.ts new file mode 100644 index 000000000..77c5dd8c0 --- /dev/null +++ b/server/branches/scope.ts @@ -0,0 +1,64 @@ +/** + * Branch scope — which branch a piece of server work reads and writes. + * + * Every repository call on the branched tables (`site`, `data_tables`, + * `data_rows`) takes a `BranchScope` explicitly. CMS requests resolve theirs + * once, from the `X-Instatic-Branch` header, in the dispatcher; paths that + * are only ever meaningful for the live site (publishing, scheduling, public + * routes, forms, plugins, the dashboard, MCP headless reads) pass + * `MAIN_SCOPE`. + */ +import { MAIN_BRANCH_ID, isValidBranchId } from '@core/branches' +import type { DbClient } from '../db/client' +import { jsonResponse } from '../http' +import { branchExists } from '../repositories/branches' + +export interface BranchScope { + readonly branchId: string +} + +export const MAIN_SCOPE: BranchScope = Object.freeze({ branchId: MAIN_BRANCH_ID }) + +export const BRANCH_HEADER = 'x-instatic-branch' + +/** Error code the client keys on to fall back to main. */ +export const BRANCH_NOT_FOUND_CODE = 'branch_not_found' + +export function isMainScope(scope: BranchScope): boolean { + return scope.branchId === MAIN_BRANCH_ID +} + +/** + * Resolve the request's branch. A missing or `main` header is the main + * branch without touching the database; anything else must be a well-formed + * id naming an existing branch, otherwise the caller gets a 400 or a 404 + * carrying `BRANCH_NOT_FOUND_CODE`. + */ +export async function resolveBranchScope( + req: Request, + db: DbClient, +): Promise { + return resolveBranchScopeById(db, req.headers.get(BRANCH_HEADER)?.trim() ?? '') +} + +/** + * `resolveBranchScope` for a branch id that arrived outside the header — the + * export download is a form POST, which cannot carry one, so it names the + * branch in its body. + */ +export async function resolveBranchScopeById( + db: DbClient, + raw: string, +): Promise { + if (raw === '' || raw === MAIN_BRANCH_ID) return MAIN_SCOPE + if (!isValidBranchId(raw)) { + return jsonResponse({ error: 'Invalid branch id' }, { status: 400 }) + } + if (!(await branchExists(db, raw))) { + return jsonResponse( + { error: `Branch "${raw}" does not exist`, code: BRANCH_NOT_FOUND_CODE }, + { status: 404 }, + ) + } + return { branchId: raw } +} diff --git a/server/collab/relay.ts b/server/collab/relay.ts index 9e91a8e1c..74b580a4a 100644 --- a/server/collab/relay.ts +++ b/server/collab/relay.ts @@ -32,30 +32,23 @@ import * as Y from 'yjs' import { nanoid } from 'nanoid' import { - encodeCollabDocId, + isSiteDocId, parseCollabDocId, projectSiteDoc, - SITE_DOC_ID, - type CollabDocKind, + siteDocId, } from '@core/collab' import type { DbClient } from '../db/client' import { deleteCollabDocuments, getCollabDocumentState, + listCollabDocumentIdsForBranch, putCollabDocumentState, } from '../repositories/collabDocuments' -import { - registerRowWriteListener, - registerShellWriteListener, -} from '../repositories/rowWriteEvents' import { registerPublishFlush } from '../publish/publishFlush' +import { BranchGoneError, createRelayBranchAdmission } from './relayBranches' +import { createInvalidationTracker } from './relayInvalidations' import { createRelayPersistence } from './relayPersistence' - -const TABLE_KIND: Record> = { - pages: 'page', - components: 'component', - layouts: 'layout', -} +import { createRelayResetQueue } from './relayResetQueue' interface RelayEntry { doc: Y.Doc @@ -94,6 +87,18 @@ export interface CollabRelay { resetDocs(docIds: readonly string[]): Promise /** Flush every dirty doc now (tests + shutdown). */ flushAll(): Promise + /** + * A branch was deleted: drop its resident docs and roster state without + * persisting, and stop accepting its doc ids. Bound sockets receive a + * reset for each dropped doc. + */ + forgetBranch(branchId: string): Promise + /** + * A branch id was (re)created, or its delete failed: accept its docs again, + * reseeded from the rows — a reset dropped while the branch was tombstoned + * may have left a stored blob behind an out-of-relay write. + */ + rememberBranch(branchId: string): Promise /** Detach the row-write reset sources and drop all docs (tests). */ destroy(): Promise } @@ -114,9 +119,10 @@ export function createCollabRelay( const resetGates = new Map>() const updateListeners = new Set() const resetListeners = new Set() - const invalidationVersions = new Map() - const activeInvalidations = new Set() - let nextInvalidationVersion = 0 + // Which branches may have documents minted for them (see relayBranches.ts). + const branches = createRelayBranchAdmission(db) + // Out-of-relay write markers (see relayInvalidations.ts). + const invalidation = createInvalidationTracker((docId) => branches.docForgotten(docId)) // A reset evicts live docs before deleting their stored lineages. If that // deletion fails, preserve socket ref counts until a later retry can reseed // the docs; the sockets still list those ids in their bound-doc sets. @@ -125,35 +131,21 @@ export function createCollabRelay( isResident: (docId) => entries.has(docId), schedulePersist: (docId) => schedulePersist(docId), openDoc: async (docId) => { await openDoc(docId) }, - invalidationVersion: (docId) => invalidationVersions.get(docId) ?? 0, + invalidationVersion: (docId) => invalidation.version(docId), }) - function activateInvalidations(docIds: readonly string[]): Map { - const versions = new Map() - const version = ++nextInvalidationVersion - for (const docId of docIds) { - invalidationVersions.set(docId, version) - activeInvalidations.add(docId) - versions.set(docId, version) - } - return versions - } - - function finishInvalidations(versions: ReadonlyMap): void { - for (const [docId, version] of versions) { - // A newer queued invalidation still owns the active marker. - if ((invalidationVersions.get(docId) ?? 0) === version) { - activeInvalidations.delete(docId) - } - } - } - async function persistNow(docId: string): Promise { const entry = entries.get(docId) if (!entry || !entry.dirty) return 'clean' + // A forgotten branch has nowhere to persist to (its tables are gone); + // the eviction that follows drops the entry. + if (branches.docForgotten(docId)) { + entry.dirty = false + return 'clean' + } entry.dirty = false - const invalidationVersion = invalidationVersions.get(docId) ?? 0 - const invalidationCutoff = nextInvalidationVersion + const invalidationVersion = invalidation.version(docId) + const invalidationCutoff = invalidation.cutoff() const state = Y.encodeStateAsUpdate(entry.doc) const snapshot = new Y.Doc() Y.applyUpdate(snapshot, state, 'persist-snapshot') @@ -167,8 +159,8 @@ export function createCollabRelay( // queued reset is authoritative; never overwrite it with this older // collaborative snapshot. if ( - activeInvalidations.has(docId) || - (invalidationVersions.get(docId) ?? 0) !== invalidationVersion + invalidation.isActive(docId) || + invalidation.version(docId) !== invalidationVersion ) { return 'superseded' as const } @@ -245,10 +237,17 @@ export function createCollabRelay( if (inFlight) return inFlight const open = (async () => { - // Row-doc persistence needs a current roster snapshot. Register this - // page opening before awaiting SITE so resetDocs can see and drain it. - if (parsed && parsed.kind !== 'site' && !persistence.hasRosterSnapshot()) { - await openDoc(SITE_DOC_ID, ignoreResetGate) + if (parsed && !(await branches.admits(parsed.branchId))) { + throw new BranchGoneError(parsed.branchId, docId) + } + // Row-doc persistence needs a current roster snapshot of ITS branch. + // Register this page opening before awaiting the branch's site doc so + // resetDocs can see and drain it. A reset that already gates this doc + // is waiting for exactly this open to settle before it evicts the + // result — so this open must not block on the site gate that same + // reset holds, or the two wait on each other forever. + if (parsed && parsed.kind !== 'site' && !persistence.hasRosterSnapshot(parsed.branchId)) { + await openDoc(siteDocId(parsed.branchId), ignoreResetGate || resetGates.has(docId)) } const doc = new Y.Doc() const stored = await getCollabDocumentState(db, docId) @@ -265,9 +264,14 @@ export function createCollabRelay( generation = nanoid() minted = true } + // The branch may have been forgotten while this open awaited the site + // doc, the blob, or the seed — never install an entry for it. + if (parsed && branches.forgotten(parsed.branchId)) { + throw new BranchGoneError(parsed.branchId, docId) + } const updateHandler = (update: Uint8Array, origin: unknown) => { for (const listener of updateListeners) listener(docId, update, origin, generation) - if (docId === SITE_DOC_ID) persistence.observeSiteRoster(doc) + if (parsed?.kind === 'site') persistence.observeSiteRoster(parsed.branchId, doc) schedulePersist(docId) } doc.on('update', updateHandler) @@ -286,7 +290,7 @@ export function createCollabRelay( // subsequent retain/release operations update one logical ref count. if (heldRefs > 0) heldResetRefs.delete(docId) if (parsed?.kind === 'site') { - persistence.observeSiteRoster(doc) + persistence.observeSiteRoster(parsed.branchId, doc) } else if (parsed) { persistence.noteOpenedRow(docId, { stored: stored !== null, seeded }) } @@ -360,18 +364,30 @@ export function createCollabRelay( } } + /** Site docs first — each branch's shell sweep must free slugs before its row docs write them. */ function orderedEntryDocIds(): string[] { const docIds = [...entries.keys()] - return entries.has(SITE_DOC_ID) - ? [SITE_DOC_ID, ...docIds.filter((docId) => docId !== SITE_DOC_ID)] - : docIds + return [ + ...docIds.filter((docId) => isSiteDocId(docId)), + ...docIds.filter((docId) => !isSiteDocId(docId)), + ] } async function resetDocs( docIds: readonly string[], ownedInvalidations?: ReadonlyMap, ): Promise { - const affected = [...new Set(docIds.filter((id) => parseCollabDocId(id) !== null))] + // A deleted branch has nothing to reseed from: its ids drop out, and the + // markers they were activated with (before the tombstone) go with them. + const affected: string[] = [] + for (const docId of new Set(docIds)) { + if (parseCollabDocId(docId) === null) continue + if (branches.docForgotten(docId)) { + invalidation.release(docId) + continue + } + affected.push(docId) + } if (affected.length === 0) return const existingGates = [...new Set( affected.flatMap((docId) => { @@ -382,7 +398,7 @@ export function createCollabRelay( if (existingGates.length > 0) { await Promise.all(existingGates) const refreshedInvalidations = ownedInvalidations - ? activateInvalidations(affected) + ? invalidation.activate(affected) : undefined return resetDocs(affected, refreshedInvalidations) } @@ -391,7 +407,7 @@ export function createCollabRelay( releaseResetGate = resolve }) for (const docId of affected) resetGates.set(docId, resetGate) - const invalidations = ownedInvalidations ?? activateInvalidations(affected) + const invalidations = ownedInvalidations ?? invalidation.activate(affected) let completed = false try { // An open that registered before this gate may already be reading the old @@ -412,9 +428,12 @@ export function createCollabRelay( // phase for this id, including when that write is a deletion. persistence.markRowEstablished(docId) } - const resetsSite = affected.includes(SITE_DOC_ID) - if (resetsSite) { - const siteEntry = entries.get(SITE_DOC_ID) + const affectedSiteDocs = affected.flatMap((docId) => { + const parsedSite = parseCollabDocId(docId) + return parsedSite?.kind === 'site' ? [{ docId, branchId: parsedSite.branchId }] : [] + }) + for (const { docId: affectedSiteDocId, branchId } of affectedSiteDocs) { + const siteEntry = entries.get(affectedSiteDocId) if (siteEntry) { // The shell write that triggered this reset must win, so do not persist // the stale collaborative shell. Apply only its authoritative deletion @@ -429,8 +448,9 @@ export function createCollabRelay( const sweep = siteEntry.persistChain.then(() => persistence.serializeMutation(() => persistence.sweepRosterDeletions( + branchId, projected.rosters, - invalidations.get(SITE_DOC_ID) ?? nextInvalidationVersion, + invalidations.get(affectedSiteDocId) ?? invalidation.cutoff(), new Set(affected), ), ), @@ -442,7 +462,7 @@ export function createCollabRelay( } // The site doc reseeds from the DB on next bind. Its first later persist // must run a full sweep even if the old and replacement keys compare equal. - persistence.invalidateRosterSweep() + persistence.invalidateRosterSweep(branchId) } // Flush the docs we are NOT resetting first. The site doc reseeds its @@ -485,11 +505,11 @@ export function createCollabRelay( for (const docId of affected) settling.delete(docId) } - if (resetsSite) { + for (const { docId: affectedSiteDocId } of affectedSiteDocs) { // Keep the old authority through eviction/deletion, then replace it in // one step by observing the freshly seeded site doc. There is never a // null-authority window in which a dirty deleted row can pass its guard. - await openDoc(SITE_DOC_ID, true) + await openDoc(affectedSiteDocId, true) } // Re-register the ref counts the eviction dropped. Without this the next @@ -508,7 +528,7 @@ export function createCollabRelay( // A failed reset leaves the ids actively invalidated. The queued reset // path retains the failed batch and explicit flushes refuse to publish; // a later successful retry owns a newer version and clears the marker. - if (completed) finishInvalidations(invalidations) + if (completed) invalidation.finish(invalidations) for (const docId of affected) { if (resetGates.get(docId) === resetGate) resetGates.delete(docId) } @@ -516,86 +536,16 @@ export function createCollabRelay( } } - // ── Out-of-relay write sources → resets ─────────────────────────────────── + // ── Out-of-relay write sources → resets (see relayResetQueue.ts) ────────── - const pendingResetDocIds = new Set() - const failedResetDocIds = new Set() - let resetBatchScheduled = false - let resetChain: Promise = Promise.resolve() - let failedResetError: unknown = null - - function queueResetDocs(docIds: readonly string[]): void { - // Mark synchronously with the post-commit notification. The microtask - // batching below must not create a window for an older persist to write. - const combined = [...new Set([...failedResetDocIds, ...docIds])] - failedResetDocIds.clear() - failedResetError = null - activateInvalidations(combined) - for (const docId of combined) pendingResetDocIds.add(docId) - if (resetBatchScheduled) return - resetBatchScheduled = true - queueMicrotask(() => { - resetBatchScheduled = false - const batch = [...pendingResetDocIds] - pendingResetDocIds.clear() - if (batch.length === 0) return - const invalidations = new Map( - batch.map((docId) => [docId, invalidationVersions.get(docId) ?? 0]), - ) - const reset = resetChain.then(() => resetDocs(batch, invalidations)) - resetChain = reset.then( - () => undefined, - (err) => { - failedResetError = err - for (const docId of batch) failedResetDocIds.add(docId) - console.error('[collab] reset after out-of-relay write failed:', err) - }, - ) - }) - } - - const detachRowListener = registerRowWriteListener((event) => { - const kind = TABLE_KIND[event.tableId] - if (!kind) return - const docIds = event.rowIds.map((rowId) => encodeCollabDocId({ kind, rowId })) - // The site-document batch API reports creations in its changed-id group as - // `update`. An id absent from the observed roster therefore also means - // membership may have changed and site authority must reseed. - if ( - event.kind !== 'update' || - !persistence.hasRosterSnapshot() || - docIds.some((docId) => !persistence.rosterContains(docId)) - ) docIds.push(SITE_DOC_ID) - queueResetDocs(docIds) - }) - const detachShellListener = registerShellWriteListener(() => { - queueResetDocs([SITE_DOC_ID]) + const resetQueue = createRelayResetQueue({ + activateInvalidations: (docIds) => invalidation.activate(docIds), + invalidationVersion: (docId) => invalidation.version(docId), + resetDocs, + hasRosterSnapshot: (branchId) => persistence.hasRosterSnapshot(branchId), + rosterContains: (docId) => persistence.rosterContains(docId), }) - - async function drainResetQueue(throwOnFailure = true): Promise { - let retriedFailure = false - for (;;) { - // Let a batch queued by the current call stack attach to resetChain. - await Promise.resolve() - const observed = resetChain - await observed - if (failedResetError) { - if (!throwOnFailure) return - if (!retriedFailure) { - const retry = [...failedResetDocIds] - retriedFailure = true - queueResetDocs(retry) - continue - } - throw failedResetError - } - if ( - !resetBatchScheduled && - pendingResetDocIds.size === 0 && - resetChain === observed - ) return - } - } + const drainResetQueue = resetQueue.drainResetQueue async function flushAll(): Promise { await drainResetQueue() @@ -683,9 +633,52 @@ export function createCollabRelay( }, resetDocs, flushAll, + forgetBranch: async (branchId) => { + branches.forget(branchId) + resetQueue.forgetBranch(branchId) + invalidation.releaseBranch(branchId) + // An open that passed the branch check before the tombstone may still + // be installing its entry — let every in-flight open of the branch + // settle, then evict from a fresh snapshot. + for (const [docId, inFlight] of [...opening]) { + if (parseCollabDocId(docId)?.branchId !== branchId) continue + try { + await inFlight + } catch { + // A refused or failed open installed nothing. + } + } + const docIds = [...entries.keys()].filter( + (docId) => parseCollabDocId(docId)?.branchId === branchId, + ) + for (const docId of docIds) { + // The sockets still list these docs as bound (they are told the + // branch is gone, not unbound). Hold their counts exactly as a reset + // does: a reopen after the branch is remembered starts at the right + // count, and a stale close drains the held count instead of driving + // a live entry's refs negative. + // A reset mid-eviction may already hold the authoritative count. + const refs = entries.get(docId)?.refs ?? 0 + if (refs > 0 && !heldResetRefs.has(docId)) heldResetRefs.set(docId, refs) + await evict(docId, { persist: false }) + } + persistence.forgetBranch(branchId) + for (const docId of docIds) { + for (const listener of resetListeners) listener(docId) + } + }, + rememberBranch: (branchId) => branches.remember(branchId, async () => { + // The rows are authoritative again, and a stored blob may predate an + // out-of-relay write whose reset was dropped while the branch was + // tombstoned. Drop every stored blob UNDER the tombstone — nothing can + // open or persist the branch meanwhile — so the next open reseeds from + // the rows. After a completed delete nothing is stored, so a re-fork + // deletes nothing. + const stored = await listCollabDocumentIdsForBranch(db, branchId) + await deleteCollabDocuments(db, stored) + }), destroy: async () => { - detachRowListener() - detachShellListener() + resetQueue.detach() detachPublishFlush() await drainResetQueue() await persistence.drainRecoveries(true) diff --git a/server/collab/relayBranches.ts b/server/collab/relayBranches.ts new file mode 100644 index 000000000..40c143879 --- /dev/null +++ b/server/collab/relayBranches.ts @@ -0,0 +1,113 @@ +/** + * Relay branch admission — which branches the relay mints documents for. + * + * A collab doc id names a branch, and a client must never be able to mint + * documents — and through them rows — for a branch that was never created + * or that has been deleted. The registry is read once per branch and the + * positive answer cached. A deleted branch is tombstoned through `forget` + * BEFORE its rows go, so a socket rebinding during the delete transaction + * (while the registry row still exists) is refused instead of reseeding + * rows that are about to vanish. + * + * `remember` lifts the tombstone when the id is forked again, or when a + * delete failed after tombstoning. A tombstoned branch is revived through a + * caller-supplied step (the relay purges its stored blobs, so the next open + * reseeds from the rows) that runs UNDER the tombstone. When that step fails + * — typically the same database outage that failed the delete — the branch + * stays refused and every later `admits` retries the step, single-flight, + * until it succeeds; a branch is never left tombstoned for good. + */ +import { MAIN_BRANCH_ID } from '@core/branches' +import { parseCollabDocId } from '@core/collab' +import type { DbClient } from '../db/client' +import { branchExists } from '../repositories/branches' + +/** Thrown by the relay's `openDoc` for a doc whose branch does not exist (or was deleted). */ +export class BranchGoneError extends Error { + readonly branchId: string + + constructor(branchId: string, docId: string) { + super(`[collab] branch "${branchId}" is gone; refusing doc ${docId}`) + this.name = 'BranchGoneError' + this.branchId = branchId + } +} + +export interface RelayBranchAdmission { + /** True when the branch exists and is not tombstoned. Reads the registry at most once per branch. */ + admits(branchId: string): Promise + /** True once `forget` tombstoned the branch — no registry read. */ + forgotten(branchId: string): boolean + /** `forgotten` for a doc id: true when the doc names a tombstoned branch. */ + docForgotten(docId: string): boolean + forget(branchId: string): void + /** + * Accept the branch again. A tombstoned branch first runs `revive` under + * the tombstone; on failure it stays refused and `admits` retries. Never + * throws — a failed attempt is logged. + */ + remember(branchId: string, revive: () => Promise): Promise +} + +export function createRelayBranchAdmission(db: DbClient): RelayBranchAdmission { + const confirmed = new Set([MAIN_BRANCH_ID]) + const tombstoned = new Set() + /** Tombstoned branches waiting for their revive step to succeed. */ + const reviving = new Map Promise>() + const reviveAttempts = new Map>() + + function revive(branchId: string): Promise { + const step = reviving.get(branchId) + if (!step) return Promise.resolve(!tombstoned.has(branchId)) + const running = reviveAttempts.get(branchId) + if (running) return running + const attempt = (async () => { + try { + await step() + } catch (err) { + console.error(`[collab] reviving branch "${branchId}" failed; it stays refused until the next attempt:`, err) + return false + } finally { + reviveAttempts.delete(branchId) + } + // A `forget` during the step wins: the branch stays tombstoned. + if (reviving.get(branchId) !== step) return false + reviving.delete(branchId) + // The caller believes the delete failed; the registry has the last word. + if (!(await branchExists(db, branchId))) return false + tombstoned.delete(branchId) + confirmed.add(branchId) + return true + })() + reviveAttempts.set(branchId, attempt) + return attempt + } + + return { + async admits(branchId) { + if (tombstoned.has(branchId)) return revive(branchId) + if (confirmed.has(branchId)) return true + if (!(await branchExists(db, branchId))) return false + confirmed.add(branchId) + return true + }, + forgotten: (branchId) => tombstoned.has(branchId), + docForgotten(docId) { + const parsed = parseCollabDocId(docId) + return parsed !== null && tombstoned.has(parsed.branchId) + }, + forget(branchId) { + tombstoned.add(branchId) + confirmed.delete(branchId) + reviving.delete(branchId) + }, + async remember(branchId, step) { + if (!tombstoned.has(branchId)) { + confirmed.add(branchId) + return + } + reviving.set(branchId, step) + await revive(branchId) + }, + } +} diff --git a/server/collab/relayInvalidations.ts b/server/collab/relayInvalidations.ts new file mode 100644 index 000000000..f540fc498 --- /dev/null +++ b/server/collab/relayInvalidations.ts @@ -0,0 +1,73 @@ +/** + * Invalidation markers — which docs an out-of-relay write has invalidated, + * and the version each was invalidated at. + * + * `activate` runs synchronously with the post-commit notification and marks + * the docs; the reset batch that follows `finish`es the versions it owned. + * In between, a persist of a marked doc writes its blob but skips the derived + * row JSON (`persistNow` reports "superseded"): the queued reset is + * authoritative. Every marker must therefore be released by exactly one + * path — `finish` when the reset ran, or `release` / `releaseBranch` when the + * reset was dropped for a tombstoned branch. A marker nothing releases would + * make every persist after the branch is admitted again report "superseded": + * blob written, row JSON never. + */ +import { parseCollabDocId } from '@core/collab' + +export interface InvalidationTracker { + /** Mark docs invalidated at a fresh version; returns the versions this batch owns. */ + activate(docIds: readonly string[]): Map + /** A reset batch completed: clear the active marker of every doc it still owns. */ + finish(owned: ReadonlyMap): void + /** Drop one doc's marker outright (its reset was dropped). */ + release(docId: string): void + /** Drop every marker held by a branch's docs. */ + releaseBranch(branchId: string): void + /** The version a doc was last invalidated at; 0 when never. */ + version(docId: string): number + /** True while a reset owning the doc's marker has not finished. */ + isActive(docId: string): boolean + /** The latest version handed out — the cutoff a persist snapshot is taken at. */ + cutoff(): number +} + +/** `skip` names docs that must never receive a marker (a tombstoned branch's). */ +export function createInvalidationTracker(skip: (docId: string) => boolean): InvalidationTracker { + const versions = new Map() + const active = new Set() + let next = 0 + + function release(docId: string): void { + versions.delete(docId) + active.delete(docId) + } + + return { + activate(docIds) { + const owned = new Map() + const version = ++next + for (const docId of docIds) { + if (skip(docId)) continue + versions.set(docId, version) + active.add(docId) + owned.set(docId, version) + } + return owned + }, + finish(owned) { + for (const [docId, version] of owned) { + // A newer activation still owns the active marker. + if ((versions.get(docId) ?? 0) === version) active.delete(docId) + } + }, + release, + releaseBranch(branchId) { + for (const docId of [...versions.keys()]) { + if (parseCollabDocId(docId)?.branchId === branchId) release(docId) + } + }, + version: (docId) => versions.get(docId) ?? 0, + isActive: (docId) => active.has(docId), + cutoff: () => next, + } +} diff --git a/server/collab/relayPersistence.ts b/server/collab/relayPersistence.ts index 02ea8ad83..2e3e62dfe 100644 --- a/server/collab/relayPersistence.ts +++ b/server/collab/relayPersistence.ts @@ -4,6 +4,11 @@ * The relay owns document lifecycle and socket references; this module owns the * database-facing half of a Y document: deterministic JSON seeding, derived * row/site writes, authoritative roster deletion, and undo recovery. + * + * Every doc id carries its branch (see `@core/collab` docIds), and every + * roster is kept PER BRANCH: a branch's site doc is the authority for that + * branch's row docs only, and its sweep can only ever soft-delete rows of + * that branch. */ import * as Y from 'yjs' import { @@ -21,6 +26,7 @@ import { } from '@core/collab' import '@modules/base' // registry population — inline-text props seed as Y.Text import type { SiteShell } from '@core/page-tree' +import { MAIN_BRANCH_ID, SITE_SHELL_LOGICAL_ID } from '@core/branches' import { pageFromRow, pageToCells } from '@core/data/pageFromRow' import { visualComponentFromRow, visualComponentToCells } from '@core/data/componentFromRow' import { savedLayoutFromRow, savedLayoutToCells } from '@core/data/layoutFromRow' @@ -28,6 +34,7 @@ import { vcSlugFromName } from '@core/visualComponents' import { layoutSlugFromName } from '@core/layouts' import { validateSite } from '@core/persistence/validate' import type { DbClient } from '../db/client' +import type { BranchScope } from '../branches/scope' import { getDataRow, listDataRowIdSlugs, @@ -56,15 +63,16 @@ interface RelayPersistenceHooks { } export interface RelayPersistence { - hasRosterSnapshot(): boolean + hasRosterSnapshot(branchId: string): boolean rosterContains(docId: string): boolean - observeSiteRoster(doc: Y.Doc): void + observeSiteRoster(branchId: string, doc: Y.Doc): void noteOpenedRow(docId: string, state: { stored: boolean; seeded: boolean }): void markRowEstablished(docId: string): void isUnrosteredEstablishedDoc(docId: string): boolean seedFromJson(docId: string, doc: Y.Doc): Promise serializeMutation(operation: () => Promise): Promise sweepRosterDeletions( + branchId: string, rosters: SiteRosters, invalidationCutoff: number, protectedDocIds?: ReadonlySet, @@ -74,7 +82,9 @@ export interface RelayPersistence { doc: Y.Doc, invalidationCutoff: number, ): Promise - invalidateRosterSweep(): void + invalidateRosterSweep(branchId: string): void + /** Drop every roster and recovery record of a branch that no longer exists. */ + forgetBranch(branchId: string): void drainRecoveries(throwOnFailure: boolean): Promise } @@ -82,39 +92,49 @@ export function createRelayPersistence( db: DbClient, hooks: RelayPersistenceHooks, ): RelayPersistence { - // Last roster set the site-doc persist actually swept, so shell-field-only - // persists skip the three full-table scans. Reset when the site doc resets. - let lastSweptRostersKey: string | null = null + // Last roster set each branch's site-doc persist actually swept, so + // shell-field-only persists skip the three full-table scans. Cleared for a + // branch when its site doc resets. + const lastSweptRostersKey = new Map() /** - * The site roster is authoritative for an established row doc. A freshly - * created client doc may arrive before its roster frame, so it remains - * provisional until either the roster names it or its first derived row is - * written. Once established, removing it from the roster defers all later - * row writes instead of letting a dirty editor resurrect the deletion. + * A branch's site roster is authoritative for its established row docs. A + * freshly created client doc may arrive before its roster frame, so it + * remains provisional until either the roster names it or its first + * derived row is written. Once established, removing it from the roster + * defers all later row writes instead of letting a dirty editor resurrect + * the deletion. */ - let rosterDocIds: Set | null = null + const rosterDocIdsByBranch = new Map>() const knownRowDocIds = new Set() const provisionalRowDocIds = new Set() const pendingRosterRecoveries = new Map>() const failedRosterRecoveries = new Set() + function branchOf(docId: string): string { + return parseCollabDocId(docId)?.branchId ?? MAIN_BRANCH_ID + } + + function rosterFor(docId: string): Set | null { + return rosterDocIdsByBranch.get(branchOf(docId)) ?? null + } + function serializeMutation(operation: () => Promise): Promise { return serializeCollabAwareWrite(operation) } - function projectedRosterDocIds(doc: Y.Doc): Set { + function projectedRosterDocIds(branchId: string, doc: Y.Doc): Set { const { rosters } = projectSiteDoc(doc) return new Set([ - ...rosters.pages.map((rowId) => encodeCollabDocId({ kind: 'page', rowId })), - ...rosters.components.map((rowId) => encodeCollabDocId({ kind: 'component', rowId })), - ...rosters.layouts.map((rowId) => encodeCollabDocId({ kind: 'layout', rowId })), + ...rosters.pages.map((rowId) => encodeCollabDocId({ kind: 'page', branchId, rowId })), + ...rosters.components.map((rowId) => encodeCollabDocId({ kind: 'component', branchId, rowId })), + ...rosters.layouts.map((rowId) => encodeCollabDocId({ kind: 'layout', branchId, rowId })), ]) } - function observeSiteRoster(doc: Y.Doc): void { - const previous = rosterDocIds - const next = projectedRosterDocIds(doc) - rosterDocIds = next + function observeSiteRoster(branchId: string, doc: Y.Doc): void { + const previous = rosterDocIdsByBranch.get(branchId) ?? null + const next = projectedRosterDocIds(branchId, doc) + rosterDocIdsByBranch.set(branchId, next) for (const docId of next) { knownRowDocIds.add(docId) provisionalRowDocIds.delete(docId) @@ -136,9 +156,9 @@ export function createRelayPersistence( // Collaborative deletion keeps the row blob as an undo tombstone. Only // a stored lineage can be revived; never mint an empty page here. const stored = await getCollabDocumentState(db, docId) - if (!stored || !rosterDocIds?.has(docId)) return + if (!stored || !rosterFor(docId)?.has(docId)) return await hooks.openDoc(docId) - if (rosterDocIds?.has(docId)) hooks.schedulePersist(docId) + if (rosterFor(docId)?.has(docId)) hooks.schedulePersist(docId) })() pendingRosterRecoveries.set(docId, recovery) void recovery.catch((err) => { @@ -170,19 +190,21 @@ export function createRelayPersistence( } function isUnrosteredEstablishedDoc(docId: string): boolean { - return rosterDocIds !== null && knownRowDocIds.has(docId) && !rosterDocIds.has(docId) + const roster = rosterFor(docId) + return roster !== null && knownRowDocIds.has(docId) && !roster.has(docId) } async function seedFromJson(docId: string, doc: Y.Doc): Promise { const parsed = parseCollabDocId(docId) if (!parsed) return false + const scope: BranchScope = { branchId: parsed.branchId } if (parsed.kind === 'site') { - const shell = await getDraftSite(db) - if (!shell) return false // pre-setup — nothing to seed + const shell = await getDraftSite(db, scope) + if (!shell) return false // pre-setup, or a branch with no shell — nothing to seed const [pages, components, layouts] = await Promise.all([ - listDataRowIdSlugs(db, 'pages'), - listDataRowIdSlugs(db, 'components'), - listDataRowIdSlugs(db, 'layouts'), + listDataRowIdSlugs(db, scope, 'pages'), + listDataRowIdSlugs(db, scope, 'components'), + listDataRowIdSlugs(db, scope, 'layouts'), ]) seedSiteDocFromParts(doc, shell as unknown as Record, { pages: pages.map((row) => row.id), @@ -191,7 +213,7 @@ export function createRelayPersistence( }) return true } - const row = await getDataRow(db, parsed.rowId) + const row = await getDataRow(db, scope, parsed.rowId) if (!row || row.tableId !== KIND_TABLE[parsed.kind]) return false if (parsed.kind === 'page') { seedPageDoc(doc, pageFromRow(row)) @@ -208,35 +230,41 @@ export function createRelayPersistence( } async function sweepRosterDeletions( + branchId: string, rosters: SiteRosters, invalidationCutoff: number, protectedDocIds: ReadonlySet = new Set(), ): Promise { + const scope: BranchScope = { branchId } let deletedPublished = false for (const [kind, table, ids] of [ ['page', 'pages', rosters.pages], ['component', 'components', rosters.components], ['layout', 'layouts', rosters.layouts], ] as const) { - const live = await listDataRowIdSlugs(db, table) + const live = await listDataRowIdSlugs(db, scope, table) const keep = new Set(ids) for (const row of live) { - const rowDocId = encodeCollabDocId({ kind, rowId: row.id }) + const rowDocId = encodeCollabDocId({ kind, branchId, rowId: row.id }) // A newer roster frame or authoritative row write may land while this // snapshot's sweep is queued. Only writes ordered AFTER the snapshot // are protected; an older invalidation still resetting must not erase // a later collaborative deletion. + // Read the live roster per row: a newer roster frame (a peer's undo of + // this very deletion) can replace the set while the sweep awaits. if ( keep.has(row.id) || - rosterDocIds?.has(rowDocId) || + rosterDocIdsByBranch.get(branchId)?.has(rowDocId) || hooks.invalidationVersion(rowDocId) > invalidationCutoff || protectedDocIds.has(rowDocId) ) continue - const deleted = await softDeleteDataRow(db, row.id, null, { collabInternal: true }) + const deleted = await softDeleteDataRow(db, scope, row.id, null, { collabInternal: true }) if (deleted?.status === 'published') deletedPublished = true } } - if (deletedPublished) await bumpPublishVersionSerialized() + // Only main's routes are served; a branch deletion never touches the + // public render cache. + if (deletedPublished && branchId === MAIN_BRANCH_ID) await bumpPublishVersionSerialized() } async function persistDerivedJson( @@ -246,6 +274,7 @@ export function createRelayPersistence( ): Promise { const parsed = parseCollabDocId(docId) if (!parsed) return 'incomplete' + const scope: BranchScope = { branchId: parsed.branchId } if (parsed.kind === 'site') { const projected = projectSiteDoc(doc) if (Object.keys(projected.shell).length === 0) return 'incomplete' @@ -255,7 +284,7 @@ export function createRelayPersistence( // per-mutation noise) — inject them at the persistence boundary. shell = validateSite({ ...projected.shell, - id: 'default', + id: SITE_SHELL_LOGICAL_ID, updatedAt: typeof projected.shell.updatedAt === 'number' ? projected.shell.updatedAt : Date.now(), }) @@ -265,15 +294,15 @@ export function createRelayPersistence( console.error('[collab] projected shell failed validation — JSON write skipped:', err) return 'invalid' } - await saveDraftSite(db, shell, null, { collabInternal: true }) + await saveDraftSite(db, scope, shell, null, { collabInternal: true }) const rostersKey = projected.rosters.pages.join(',') + '|' + projected.rosters.components.join(',') + '|' + projected.rosters.layouts.join(',') - if (rostersKey === lastSweptRostersKey) return 'written' - await sweepRosterDeletions(projected.rosters, invalidationCutoff) - lastSweptRostersKey = rostersKey + if (rostersKey === lastSweptRostersKey.get(parsed.branchId)) return 'written' + await sweepRosterDeletions(parsed.branchId, projected.rosters, invalidationCutoff) + lastSweptRostersKey.set(parsed.branchId, rostersKey) return 'written' } @@ -303,6 +332,7 @@ export function createRelayPersistence( await upsertDataRowDraft( db, + scope, { id: parsed.rowId, tableId: table, cells, slug }, null, { collabInternal: true }, @@ -313,7 +343,7 @@ export function createRelayPersistence( async function drainRecoveries(throwOnFailure: boolean): Promise { for (const docId of [...failedRosterRecoveries]) { - if (rosterDocIds?.has(docId)) scheduleRosterRecovery(docId) + if (rosterFor(docId)?.has(docId)) scheduleRosterRecovery(docId) else failedRosterRecoveries.delete(docId) } while (pendingRosterRecoveries.size > 0) { @@ -331,9 +361,23 @@ export function createRelayPersistence( } } + function forgetBranch(branchId: string): void { + rosterDocIdsByBranch.delete(branchId) + lastSweptRostersKey.delete(branchId) + for (const docId of [...knownRowDocIds]) { + if (branchOf(docId) === branchId) knownRowDocIds.delete(docId) + } + for (const docId of [...provisionalRowDocIds]) { + if (branchOf(docId) === branchId) provisionalRowDocIds.delete(docId) + } + for (const docId of [...failedRosterRecoveries]) { + if (branchOf(docId) === branchId) failedRosterRecoveries.delete(docId) + } + } + return { - hasRosterSnapshot: () => rosterDocIds !== null, - rosterContains: (docId) => rosterDocIds?.has(docId) ?? false, + hasRosterSnapshot: (branchId) => rosterDocIdsByBranch.has(branchId), + rosterContains: (docId) => rosterFor(docId)?.has(docId) ?? false, observeSiteRoster, noteOpenedRow, markRowEstablished, @@ -342,7 +386,8 @@ export function createRelayPersistence( serializeMutation, sweepRosterDeletions, persistDerivedJson, - invalidateRosterSweep: () => { lastSweptRostersKey = null }, + invalidateRosterSweep: (branchId) => { lastSweptRostersKey.delete(branchId) }, + forgetBranch, drainRecoveries, } } diff --git a/server/collab/relayResetQueue.ts b/server/collab/relayResetQueue.ts new file mode 100644 index 000000000..ef5e951d5 --- /dev/null +++ b/server/collab/relayResetQueue.ts @@ -0,0 +1,152 @@ +/** + * Out-of-relay write sources → document resets. + * + * Repository writes that bypass the relay (HTTP site saves, data-workspace + * edits, imports, plugin packs, merges) announce themselves through the + * row/shell write seams. This module turns those announcements into batched + * `resetDocs` calls: every write is marked invalidated SYNCHRONOUSLY (so no + * older persist can slip in before the reset), then the batch runs on a + * microtask, chained so resets never overlap. A failed batch is retained and + * retried by the next queue or by an explicit drain. + */ +import { encodeCollabDocId, parseCollabDocId, siteDocId, type CollabDocKind } from '@core/collab' +import { + registerRowWriteListener, + registerShellWriteListener, +} from '../repositories/rowWriteEvents' + +const TABLE_KIND: Record> = { + pages: 'page', + components: 'component', + layouts: 'layout', +} + +interface ResetQueueHooks { + /** Mark docs invalidated and hand back their versions (see relay.ts). */ + activateInvalidations(docIds: readonly string[]): Map + invalidationVersion(docId: string): number + resetDocs(docIds: readonly string[], invalidations: ReadonlyMap): Promise + hasRosterSnapshot(branchId: string): boolean + rosterContains(docId: string): boolean +} + +export interface RelayResetQueue { + queueResetDocs(docIds: readonly string[]): void + /** Wait for every queued reset; retries a failed batch once when throwing. */ + drainResetQueue(throwOnFailure?: boolean): Promise + /** + * A branch was deleted: drop its pending and failed ids so the queue can + * never retry a reset that has nothing to reseed from. Later resets for + * the id are filtered by the relay at reset time (its admission decides, + * so an id forked again — or whose delete failed — is reset normally). + */ + forgetBranch(branchId: string): void + /** Detach the row/shell write listeners. */ + detach(): void +} + +function branchOf(docId: string): string | null { + return parseCollabDocId(docId)?.branchId ?? null +} + +export function createRelayResetQueue(hooks: ResetQueueHooks): RelayResetQueue { + const pendingResetDocIds = new Set() + const failedResetDocIds = new Set() + let resetBatchScheduled = false + let resetChain: Promise = Promise.resolve() + let failedResetError: unknown = null + + function queueResetDocs(docIds: readonly string[]): void { + // Mark synchronously with the post-commit notification. The microtask + // batching below must not create a window for an older persist to write. + const combined = [...new Set([...failedResetDocIds, ...docIds])] + failedResetDocIds.clear() + failedResetError = null + hooks.activateInvalidations(combined) + for (const docId of combined) pendingResetDocIds.add(docId) + if (resetBatchScheduled) return + resetBatchScheduled = true + queueMicrotask(() => { + resetBatchScheduled = false + const batch = [...pendingResetDocIds] + pendingResetDocIds.clear() + if (batch.length === 0) return + const invalidations = new Map( + batch.map((docId) => [docId, hooks.invalidationVersion(docId)]), + ) + const reset = resetChain.then(() => hooks.resetDocs(batch, invalidations)) + resetChain = reset.then( + () => undefined, + (err) => { + failedResetError = err + for (const docId of batch) failedResetDocIds.add(docId) + console.error('[collab] reset after out-of-relay write failed:', err) + }, + ) + }) + } + + const detachRowListener = registerRowWriteListener((event) => { + const kind = TABLE_KIND[event.tableId] + if (!kind) return + const branchId = event.branchId + const docIds = event.rowIds.map((rowId) => encodeCollabDocId({ kind, branchId, rowId })) + // The site-document batch API reports creations in its changed-id group as + // `update`. An id absent from the observed roster therefore also means + // membership may have changed and site authority must reseed. + if ( + event.kind !== 'update' || + !hooks.hasRosterSnapshot(branchId) || + docIds.some((docId) => !hooks.rosterContains(docId)) + ) docIds.push(siteDocId(branchId)) + queueResetDocs(docIds) + }) + const detachShellListener = registerShellWriteListener((branchId) => { + queueResetDocs([siteDocId(branchId)]) + }) + + async function drainResetQueue(throwOnFailure = true): Promise { + let retriedFailure = false + for (;;) { + // Let a batch queued by the current call stack attach to resetChain. + await Promise.resolve() + const observed = resetChain + await observed + if (failedResetError) { + if (!throwOnFailure) return + if (!retriedFailure) { + const retry = [...failedResetDocIds] + retriedFailure = true + queueResetDocs(retry) + continue + } + throw failedResetError + } + if ( + !resetBatchScheduled && + pendingResetDocIds.size === 0 && + resetChain === observed + ) return + } + } + + function forgetBranch(branchId: string): void { + for (const docId of [...pendingResetDocIds]) { + if (branchOf(docId) === branchId) pendingResetDocIds.delete(docId) + } + for (const docId of [...failedResetDocIds]) { + if (branchOf(docId) === branchId) failedResetDocIds.delete(docId) + } + if (failedResetDocIds.size === 0) failedResetError = null + } + + return { + queueResetDocs, + drainResetQueue, + forgetBranch, + detach: () => { + detachRowListener() + detachShellListener() + }, + } +} diff --git a/server/collab/socket.ts b/server/collab/socket.ts index 2fc6855f2..790b99203 100644 --- a/server/collab/socket.ts +++ b/server/collab/socket.ts @@ -53,6 +53,7 @@ import { validateGuardedUpdate } from './updateGuard' import { originAllowed } from '../auth/security' import type { DbClient } from '../db/client' import { jsonResponse } from '../http' +import { BranchGoneError } from './relayBranches' import type { CollabRelay, RelayDoc } from './relay' export { SITE_SOCKET_PATH } @@ -460,6 +461,16 @@ export function createCollabSocketLayer(relay: CollabRelay) { frame = decodeCollabFrame(new Uint8Array(raw)) await dispatchFrame(ws, frame) } catch (err) { + if (err instanceof BranchGoneError && frame) { + // The doc's branch was deleted. The client must leave the branch, + // not rebind — rebinding would loop through this refusal forever. + try { + sendReset(ws, frame.docId, 'gone') + } catch (_sendErr) { + // Socket already closing — nothing to recover. + } + return + } console.error('[collab] socket message handler failed:', err) // A sync-write frame whose guard/apply threw left the sender's local // doc diverged from the authoritative one — reset it so their client diff --git a/server/db/migrations-pg.ts b/server/db/migrations-pg.ts index 7e3130d06..b44387415 100644 --- a/server/db/migrations-pg.ts +++ b/server/db/migrations-pg.ts @@ -1190,4 +1190,105 @@ export const pgMigrations: Migration[] = [ on plugin_media_sources (asset_id); `, }, + { + // See migrations-sqlite.ts:026 — site branches. Same semantic effect: + // branch registry, `branch_id` + `logical_id` on the three branched + // tables backfilled to main, per-branch table-slug uniqueness, collab + // doc ids gaining a branch segment, and the base-hash + preview tables. + id: '027_site_branches', + sql: ` + create table if not exists site_branches ( + id text primary key, + name text not null, + base_branch_id text, + created_by_user_id text references users(id) on delete set null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() + ); + + insert into site_branches (id, name, base_branch_id) + values ('main', 'main', null) + on conflict (id) do nothing; + + alter table site add column branch_id text not null default 'main'; + alter table site add column logical_id text generated always as ( + case when branch_id = 'main' then id else substr(id, length(branch_id) + 2) end + ) stored; + + create unique index if not exists site_branch_idx + on site (branch_id); + + alter table data_tables add column branch_id text not null default 'main'; + alter table data_tables add column logical_id text generated always as ( + case when branch_id = 'main' then id else substr(id, length(branch_id) + 2) end + ) stored; + + create index if not exists data_tables_branch_idx + on data_tables (branch_id); + + drop index if exists data_tables_slug_active_idx; + + create unique index if not exists data_tables_branch_slug_active_idx + on data_tables (branch_id, slug) + where deleted_at is null; + + alter table data_rows add column branch_id text not null default 'main'; + alter table data_rows add column logical_id text generated always as ( + case when branch_id = 'main' then id else substr(id, length(branch_id) + 2) end + ) stored; + + create index if not exists data_rows_branch_idx + on data_rows (branch_id); + + update collab_documents + set doc_id = 'site:main' + where doc_id = 'site:default'; + + update collab_documents + set doc_id = 'page:main:' || substr(doc_id, 6) + where doc_id like 'page:%' + and doc_id not like 'page:main:%'; + + update collab_documents + set doc_id = 'component:main:' || substr(doc_id, 11) + where doc_id like 'component:%' + and doc_id not like 'component:main:%'; + + update collab_documents + set doc_id = 'layout:main:' || substr(doc_id, 8) + where doc_id like 'layout:%' + and doc_id not like 'layout:main:%'; + + create table if not exists site_branch_bases ( + branch_id text not null references site_branches(id) on delete cascade, + kind text not null, + logical_id text not null, + content_hash text not null, + content_json jsonb not null default '{}'::jsonb, + primary key (branch_id, kind, logical_id) + ); + + create table if not exists site_branch_previews ( + id text primary key, + branch_id text not null references site_branches(id) on delete cascade, + token_hash text not null, + expires_at timestamptz, + created_by_user_id text references users(id) on delete set null, + created_at timestamptz not null default now(), + revoked_at timestamptz + ); + + create unique index if not exists site_branch_previews_token_idx + on site_branch_previews (token_hash); + + create index if not exists site_branch_previews_branch_idx + on site_branch_previews (branch_id); + + update roles + set capabilities_json = capabilities_json || '["site.branches.manage"]'::jsonb, + updated_at = current_timestamp + where id in ('owner', 'admin') + and not (capabilities_json ? 'site.branches.manage'); + `, + }, ] diff --git a/server/db/migrations-sqlite.ts b/server/db/migrations-sqlite.ts index db3deb93a..43ad9ad30 100644 --- a/server/db/migrations-sqlite.ts +++ b/server/db/migrations-sqlite.ts @@ -1266,4 +1266,113 @@ export const sqliteMigrations: Migration[] = [ on plugin_media_sources (asset_id); `, }, + { + // Site branches. Every content row keeps its LOGICAL id on every branch; + // the physical primary key stays `id` and is `:` off + // main (see src/core/branches/ids.ts). Existing rows all belong to + // `main`, where physical == logical, so nothing moves. Collab doc ids + // gain a branch segment. Nothing is dropped except the table-slug + // uniqueness index, which is recreated per branch. + id: '027_site_branches', + sql: ` + create table if not exists site_branches ( + id text primary key, + name text not null, + base_branch_id text, + created_by_user_id text references users(id) on delete set null, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ); + + insert into site_branches (id, name, base_branch_id) + values ('main', 'main', null) + on conflict (id) do nothing; + + alter table site add column branch_id text not null default 'main'; + alter table site add column logical_id text generated always as ( + case when branch_id = 'main' then id else substr(id, length(branch_id) + 2) end + ) virtual; + + create unique index if not exists site_branch_idx + on site (branch_id); + + alter table data_tables add column branch_id text not null default 'main'; + alter table data_tables add column logical_id text generated always as ( + case when branch_id = 'main' then id else substr(id, length(branch_id) + 2) end + ) virtual; + + create index if not exists data_tables_branch_idx + on data_tables (branch_id); + + drop index if exists data_tables_slug_active_idx; + + create unique index if not exists data_tables_branch_slug_active_idx + on data_tables (branch_id, slug) + where deleted_at is null; + + alter table data_rows add column branch_id text not null default 'main'; + alter table data_rows add column logical_id text generated always as ( + case when branch_id = 'main' then id else substr(id, length(branch_id) + 2) end + ) virtual; + + create index if not exists data_rows_branch_idx + on data_rows (branch_id); + + update collab_documents + set doc_id = 'site:main' + where doc_id = 'site:default'; + + update collab_documents + set doc_id = 'page:main:' || substr(doc_id, 6) + where doc_id like 'page:%' + and doc_id not like 'page:main:%'; + + update collab_documents + set doc_id = 'component:main:' || substr(doc_id, 11) + where doc_id like 'component:%' + and doc_id not like 'component:main:%'; + + update collab_documents + set doc_id = 'layout:main:' || substr(doc_id, 8) + where doc_id like 'layout:%' + and doc_id not like 'layout:main:%'; + + create table if not exists site_branch_bases ( + branch_id text not null references site_branches(id) on delete cascade, + kind text not null, + logical_id text not null, + content_hash text not null, + content_json text not null default '{}', + primary key (branch_id, kind, logical_id) + ); + + create table if not exists site_branch_previews ( + id text primary key, + branch_id text not null references site_branches(id) on delete cascade, + token_hash text not null, + expires_at text, + created_by_user_id text references users(id) on delete set null, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + revoked_at text + ); + + create unique index if not exists site_branch_previews_token_idx + on site_branch_previews (token_hash); + + create index if not exists site_branch_previews_branch_idx + on site_branch_previews (branch_id); + + -- Branch management is an Owner/Admin power; the boot-time role sync + -- re-applies this, the migration records it for the seed snapshot. + update roles + set capabilities_json = json_insert(capabilities_json, '$[#]', 'site.branches.manage'), + updated_at = current_timestamp + where id in ('owner', 'admin') + and not exists ( + select 1 + from json_each(roles.capabilities_json) + where value = 'site.branches.manage' + ); + `, + }, ] diff --git a/server/forms/handler.ts b/server/forms/handler.ts index 8fc5eee09..84b1ae002 100644 --- a/server/forms/handler.ts +++ b/server/forms/handler.ts @@ -31,6 +31,7 @@ import { publicFormPerFormRateLimit, publicFormPerIpRateLimit, } from './rateLimit' +import { MAIN_SCOPE } from '../branches/scope' type PublicFormRoute = 'challenge' | 'submit' @@ -120,7 +121,7 @@ async function handleSubmit(req: Request, db: DbClient): Promise { return badRequest('Invalid form submission') } - const table = await getDataTable(db, snapshot.targetTableId) + const table = await getDataTable(db, MAIN_SCOPE, snapshot.targetTableId) if (!table || !isFormSubmissionTargetTable(table)) { return jsonResponse({ error: 'Form target not found' }, { status: 404 }) } @@ -134,12 +135,12 @@ async function handleSubmit(req: Request, db: DbClient): Promise { return jsonResponse({ error: 'Invalid form values', errors: validation.errors }, { status: 400 }) } - const row = await createDataRow(db, { + const row = await createDataRow(db, MAIN_SCOPE, { tableId: table.id, cells: validation.cells, slug: '', }) - await emitContentEntryCreated(db, row.id, { kind: 'system' }) + await emitContentEntryCreated(db, MAIN_SCOPE, row.id, { kind: 'system' }) return jsonResponse({ ok: true, rowId: row.id }) } diff --git a/server/handlers/cms/branches.ts b/server/handlers/cms/branches.ts new file mode 100644 index 000000000..0708f20af --- /dev/null +++ b/server/handlers/cms/branches.ts @@ -0,0 +1,309 @@ +/** + * Site branches endpoints — the branch REGISTRY. Branch content is addressed + * through the `X-Instatic-Branch` header on the ordinary content routes + * (see server/branches/scope.ts); nothing here reads or writes rows except + * through the fork and delete operations. + * + * GET /admin/api/cms/branches every branch, main first (site.read) + * POST /admin/api/cms/branches fork a branch (site.branches.manage) + * PATCH /admin/api/cms/branches/:id rename (site.branches.manage) + * DELETE /admin/api/cms/branches/:id delete, discarding its work (site.branches.manage + step-up) + * GET /admin/api/cms/branches/:id/preview the active preview link (site.read) + * POST /admin/api/cms/branches/:id/preview issue a new preview link (site.branches.manage) + * DELETE /admin/api/cms/branches/:id/preview revoke the preview link (site.branches.manage) + * GET /admin/api/cms/branches/:id/merge plan merging into main (site.branches.manage) + * POST /admin/api/cms/branches/:id/merge merge into main (site.branches.manage + step-up) + * GET /admin/api/cms/branches/:id/update plan updating from main (site.branches.manage) + * POST /admin/api/cms/branches/:id/update update from main (site.branches.manage) + * + * Main is fixed: it cannot be renamed or deleted. Every mutation lands in + * the audit log. + */ +import { + ApplyMergeBodySchema, + BRANCH_NAME_MAX_LENGTH, + CreateBranchBodySchema, + RenameBranchBodySchema, + isMainBranch, + isValidBranchId, + slugifyBranchName, + type MergeDirection, +} from '@core/branches' +import type { DbClient } from '../../db/client' +import type { BranchScope } from '../../branches/scope' +import { forkBranch } from '../../branches/fork' +import { deleteBranch } from '../../branches/deleteBranch' +import { issueBranchPreviewLink, previewEntryPath } from '../../branches/previewLinks' +import { MergeApplyError, MergeConflictsUnresolvedError, applyBranchMerge, planBranchMerge } from '../../branches/merge' +import { runPublishFlush } from '../../publish/publishFlush' +import { expectedOrigin } from '../../auth/security' +import { getActiveBranchPreview, revokeBranchPreviews } from '../../repositories/branchPreviews' +import { requireCapability, requireStepUp } from '../../auth/authz' +import { badRequest, jsonResponse, methodNotAllowed, readValidatedBody } from '../../http' +import { createAuditEvent } from '../../repositories/audit' +import { branchExists, getBranch, listBranches, renameBranch } from '../../repositories/branches' +import { CMS_API_PREFIX, requestAuditContext, type CmsHandlerOptions } from './shared' + +const BRANCHES_PATH = `${CMS_API_PREFIX}/branches` +const BRANCH_ITEM_PREFIX = `${BRANCHES_PATH}/` + +function normalizeName(raw: string): string | null { + const name = raw.trim().replace(/\s+/g, ' ') + if (name.length === 0 || name.length > BRANCH_NAME_MAX_LENGTH) return null + return name +} + +export async function handleBranchesRoutes( + req: Request, + db: DbClient, + _scope: BranchScope, + options: CmsHandlerOptions = {}, +): Promise { + const url = new URL(req.url) + if (url.pathname === BRANCHES_PATH) { + if (req.method === 'GET') return handleList(req, db) + if (req.method === 'POST') return handleCreate(req, db, options) + return methodNotAllowed() + } + if (!url.pathname.startsWith(BRANCH_ITEM_PREFIX)) return null + const segments = url.pathname.slice(BRANCH_ITEM_PREFIX.length).split('/').map(decodeURIComponent) + const branchId = segments[0] ?? '' + if (branchId.length === 0) return null + if (segments.length === 1) { + if (req.method === 'PATCH') return handleRename(req, db, branchId) + if (req.method === 'DELETE') return handleDelete(req, db, branchId, options) + return methodNotAllowed() + } + if (segments.length === 2 && segments[1] === 'preview') { + if (req.method === 'GET') return handlePreviewState(req, db, branchId) + if (req.method === 'POST') return handlePreviewIssue(req, db, branchId) + if (req.method === 'DELETE') return handlePreviewRevoke(req, db, branchId) + return methodNotAllowed() + } + if (segments.length === 2 && (segments[1] === 'merge' || segments[1] === 'update')) { + const direction: MergeDirection = segments[1] + if (req.method === 'GET') return handleMergePlan(req, db, branchId, direction) + if (req.method === 'POST') return handleMergeApply(req, db, branchId, direction, options) + return methodNotAllowed() + } + return null +} + +async function handleMergePlan( + req: Request, + db: DbClient, + branchId: string, + direction: MergeDirection, +): Promise { + const user = await requireCapability(req, db, 'site.branches.manage') + if (user instanceof Response) return user + if (isMainBranch(branchId)) return badRequest('Main is the live site; it is what branches merge into') + if (!(await getBranch(db, branchId))) return branchNotFound(branchId) + // Live editors hold edits in the relay's debounce window — persist them so + // the review shows exactly what people see on the canvas. + await runPublishFlush() + const { plan } = await planBranchMerge(db, branchId, direction) + return jsonResponse({ plan }) +} + +async function handleMergeApply( + req: Request, + db: DbClient, + branchId: string, + direction: MergeDirection, + options: CmsHandlerOptions, +): Promise { + const user = await requireCapability(req, db, 'site.branches.manage') + if (user instanceof Response) return user + if (isMainBranch(branchId)) return badRequest('Main is the live site; it is what branches merge into') + // Merging rewrites main's drafts wholesale; updating rewrites the branch. + // Both are re-verified like publishing is. + const stepUp = await requireStepUp(req, db, user) + if (stepUp) return stepUp + const branch = await getBranch(db, branchId) + if (!branch) return branchNotFound(branchId) + const body = await readValidatedBody(req, ApplyMergeBodySchema) + if (!body) return badRequest('Invalid merge payload') + + let plan + try { + plan = (await applyBranchMerge(db, { + branchId, + direction, + resolutions: body.resolutions ?? {}, + actorUserId: user.id, + })).plan + } catch (err) { + if (err instanceof MergeConflictsUnresolvedError) { + return jsonResponse({ error: err.message, code: 'merge_conflicts', keys: err.keys }, { status: 409 }) + } + if (err instanceof MergeApplyError) { + return jsonResponse({ error: err.message, code: 'merge_apply', key: err.key }, { status: 409 }) + } + throw err + } + await createAuditEvent(db, { + actorUserId: user.id, + action: direction === 'merge' ? 'branch.merge' : 'branch.update', + targetType: 'branch', + targetId: branchId, + metadata: { name: branch.name, changes: plan.changes.length, conflicts: plan.conflictCount }, + ...requestAuditContext(req), + }) + + let branchDeleted = false + if (direction === 'merge' && body.deleteBranch) { + branchDeleted = await deleteBranch(db, branchId, options.collabRelay ?? null) + if (branchDeleted) { + await createAuditEvent(db, { + actorUserId: user.id, + action: 'branch.delete', + targetType: 'branch', + targetId: branchId, + metadata: { name: branch.name, afterMerge: true }, + ...requestAuditContext(req), + }) + } + } + return jsonResponse({ plan, branchDeleted }) +} + +async function handlePreviewState(req: Request, db: DbClient, branchId: string): Promise { + const user = await requireCapability(req, db, 'site.read') + if (user instanceof Response) return user + if (isMainBranch(branchId)) return badRequest('Main is the live site; it has no preview link') + if (!(await getBranch(db, branchId))) return branchNotFound(branchId) + return jsonResponse({ preview: await getActiveBranchPreview(db, branchId) }) +} + +async function handlePreviewIssue(req: Request, db: DbClient, branchId: string): Promise { + const user = await requireCapability(req, db, 'site.branches.manage') + if (user instanceof Response) return user + if (isMainBranch(branchId)) return badRequest('Main is the live site; it has no preview link') + if (!(await getBranch(db, branchId))) return branchNotFound(branchId) + const { token, preview } = await issueBranchPreviewLink(db, { branchId, createdByUserId: user.id }) + await createAuditEvent(db, { + actorUserId: user.id, + action: 'branch.preview.share', + targetType: 'branch', + targetId: branchId, + metadata: { previewId: preview.id }, + ...requestAuditContext(req), + }) + return jsonResponse({ url: `${expectedOrigin(req)}${previewEntryPath(token)}`, preview }, { status: 201 }) +} + +async function handlePreviewRevoke(req: Request, db: DbClient, branchId: string): Promise { + const user = await requireCapability(req, db, 'site.branches.manage') + if (user instanceof Response) return user + if (!(await getBranch(db, branchId))) return branchNotFound(branchId) + const revoked = await revokeBranchPreviews(db, branchId) + if (revoked > 0) { + await createAuditEvent(db, { + actorUserId: user.id, + action: 'branch.preview.revoke', + targetType: 'branch', + targetId: branchId, + metadata: { revoked }, + ...requestAuditContext(req), + }) + } + return jsonResponse({ ok: true }) +} + +function branchNotFound(branchId: string): Response { + return jsonResponse({ error: `Branch "${branchId}" does not exist` }, { status: 404 }) +} + +async function handleList(req: Request, db: DbClient): Promise { + const user = await requireCapability(req, db, 'site.read') + if (user instanceof Response) return user + return jsonResponse({ branches: await listBranches(db) }) +} + +async function handleCreate(req: Request, db: DbClient, options: CmsHandlerOptions): Promise { + const user = await requireCapability(req, db, 'site.branches.manage') + if (user instanceof Response) return user + const body = await readValidatedBody(req, CreateBranchBodySchema) + if (!body) return badRequest('Invalid branch payload') + + const name = normalizeName(body.name) + if (!name) return badRequest(`Branch names are 1 to ${BRANCH_NAME_MAX_LENGTH} characters`) + const id = body.id?.trim() || slugifyBranchName(name) + if (!isValidBranchId(id)) { + return badRequest('Branch ids use lowercase letters, digits, dots, and dashes') + } + if (isMainBranch(id)) return badRequest('"main" is the live site and cannot be recreated') + if (await branchExists(db, id)) { + return jsonResponse({ error: `A branch with the id "${id}" already exists` }, { status: 409 }) + } + const fromBranchId = body.fromBranchId?.trim() || 'main' + if (!isValidBranchId(fromBranchId) || !(await branchExists(db, fromBranchId))) { + return jsonResponse({ error: `Branch "${fromBranchId}" does not exist` }, { status: 404 }) + } + + const branch = await forkBranch(db, { id, name, fromBranchId, createdByUserId: user.id }) + await options.collabRelay?.rememberBranch(branch.id) + await createAuditEvent(db, { + actorUserId: user.id, + action: 'branch.create', + targetType: 'branch', + targetId: branch.id, + metadata: { name: branch.name, fromBranchId }, + ...requestAuditContext(req), + }) + return jsonResponse({ branch }, { status: 201 }) +} + +async function handleRename(req: Request, db: DbClient, branchId: string): Promise { + const user = await requireCapability(req, db, 'site.branches.manage') + if (user instanceof Response) return user + if (isMainBranch(branchId)) return badRequest('The main branch cannot be renamed') + const body = await readValidatedBody(req, RenameBranchBodySchema) + if (!body) return badRequest('Invalid branch payload') + const name = normalizeName(body.name) + if (!name) return badRequest(`Branch names are 1 to ${BRANCH_NAME_MAX_LENGTH} characters`) + + const previous = await getBranch(db, branchId) + if (!previous) return jsonResponse({ error: `Branch "${branchId}" does not exist` }, { status: 404 }) + const branch = await renameBranch(db, branchId, name) + if (!branch) return jsonResponse({ error: `Branch "${branchId}" does not exist` }, { status: 404 }) + await createAuditEvent(db, { + actorUserId: user.id, + action: 'branch.rename', + targetType: 'branch', + targetId: branch.id, + metadata: { from: previous.name, to: branch.name }, + ...requestAuditContext(req), + }) + return jsonResponse({ branch }) +} + +async function handleDelete( + req: Request, + db: DbClient, + branchId: string, + options: CmsHandlerOptions, +): Promise { + const user = await requireCapability(req, db, 'site.branches.manage') + if (user instanceof Response) return user + if (isMainBranch(branchId)) return badRequest('The main branch cannot be deleted') + // Deleting a branch discards every unmerged change on it — re-verify the + // actor the same way user deletion does. + const stepUp = await requireStepUp(req, db, user) + if (stepUp) return stepUp + + const branch = await getBranch(db, branchId) + if (!branch) return jsonResponse({ error: `Branch "${branchId}" does not exist` }, { status: 404 }) + const deleted = await deleteBranch(db, branchId, options.collabRelay ?? null) + if (!deleted) return jsonResponse({ error: `Branch "${branchId}" does not exist` }, { status: 404 }) + await createAuditEvent(db, { + actorUserId: user.id, + action: 'branch.delete', + targetType: 'branch', + targetId: branchId, + metadata: { name: branch.name }, + ...requestAuditContext(req), + }) + return jsonResponse({ ok: true }) +} diff --git a/server/handlers/cms/components.ts b/server/handlers/cms/components.ts index 7e48f7df7..b5017129a 100644 --- a/server/handlers/cms/components.ts +++ b/server/handlers/cms/components.ts @@ -19,11 +19,16 @@ * shell + pages + components + layouts atomically. */ import type { DbClient } from '../../db/client' +import type { BranchScope } from '../../branches/scope' import { requireCapability } from '../../auth/authz' import { methodNotAllowed } from '../../http' import { CMS_API_PREFIX, siteCollectionRowsResponse } from './shared' -export async function handleComponentsRoutes(req: Request, db: DbClient): Promise { +export async function handleComponentsRoutes( + req: Request, + db: DbClient, + scope: BranchScope, +): Promise { const url = new URL(req.url) if (url.pathname !== `${CMS_API_PREFIX}/components`) return null if (req.method !== 'GET') return methodNotAllowed() @@ -31,5 +36,5 @@ export async function handleComponentsRoutes(req: Request, db: DbClient): Promis const user = await requireCapability(req, db, 'site.read') if (user instanceof Response) return user - return siteCollectionRowsResponse(db, 'components') + return siteCollectionRowsResponse(db, scope, 'components') } diff --git a/server/handlers/cms/dashboard/activity.ts b/server/handlers/cms/dashboard/activity.ts index 742957c95..121bab3f8 100644 --- a/server/handlers/cms/dashboard/activity.ts +++ b/server/handlers/cms/dashboard/activity.ts @@ -115,7 +115,7 @@ async function loadRouteBases( const routeBaseById = new Map() for (const id of tableIds) { const { rows } = await db<{ route_base: string | null }>` - select route_base from data_tables where id = ${id} + select route_base from data_tables where id = ${id} and branch_id = 'main' ` routeBaseById.set(id, rows[0]?.route_base ?? null) } diff --git a/server/handlers/cms/dashboard/posts.ts b/server/handlers/cms/dashboard/posts.ts index 955e09f94..5997a96fa 100644 --- a/server/handlers/cms/dashboard/posts.ts +++ b/server/handlers/cms/dashboard/posts.ts @@ -22,7 +22,8 @@ export async function readPostsStats( const { rows: postTypeRows } = await db<{ id: string }>` select id from data_tables - where kind = 'postType' + where branch_id = 'main' + and kind = 'postType' and deleted_at is null ` const postTypeIds = postTypeRows.map((r) => r.id) @@ -83,7 +84,8 @@ async function readPostsHistogram( const { rows } = await db<{ table_id: string; published_at: string | Date }>` select table_id, published_at from data_rows - where deleted_at is null + where branch_id = 'main' + and deleted_at is null and status = 'published' and published_at is not null and published_at >= ${sinceIso} diff --git a/server/handlers/cms/dashboard/publishLineup.ts b/server/handlers/cms/dashboard/publishLineup.ts index e4bd5bca2..c28d67d24 100644 --- a/server/handlers/cms/dashboard/publishLineup.ts +++ b/server/handlers/cms/dashboard/publishLineup.ts @@ -94,7 +94,8 @@ async function fetchSlice( r.published_at from data_rows r join data_tables t on t.id = r.table_id - where r.deleted_at is null + where r.branch_id = 'main' + and r.deleted_at is null and r.status = 'scheduled' and r.scheduled_publish_at is not null order by r.scheduled_publish_at asc @@ -112,7 +113,8 @@ async function fetchSlice( r.published_at from data_rows r join data_tables t on t.id = r.table_id - where r.deleted_at is null + where r.branch_id = 'main' + and r.deleted_at is null and r.status = 'published' and r.published_at is not null order by r.published_at desc @@ -131,7 +133,8 @@ async function fetchSlice( r.published_at from data_rows r join data_tables t on t.id = r.table_id - where r.deleted_at is null + where r.branch_id = 'main' + and r.deleted_at is null and r.status = 'draft' order by r.updated_at desc limit ${limit} diff --git a/server/handlers/cms/dashboard/shared.ts b/server/handlers/cms/dashboard/shared.ts index 36ecdb9ee..02901c303 100644 --- a/server/handlers/cms/dashboard/shared.ts +++ b/server/handlers/cms/dashboard/shared.ts @@ -46,7 +46,8 @@ export async function readStatusCounts( const { rows } = await db<{ status: string; count: number | string }>` select status, count(*) as count from data_rows - where table_id = ${tableId} + where branch_id = 'main' + and table_id = ${tableId} and deleted_at is null group by status ` @@ -79,7 +80,8 @@ export async function readPublishedSinceCount( const { rows } = await db<{ count: number | string }>` select count(*) as count from data_rows - where table_id = ${tableId} + where branch_id = 'main' + and table_id = ${tableId} and deleted_at is null and status = 'published' and published_at is not null diff --git a/server/handlers/cms/data/index.ts b/server/handlers/cms/data/index.ts index 74fb61e14..21fdc5897 100644 --- a/server/handlers/cms/data/index.ts +++ b/server/handlers/cms/data/index.ts @@ -24,6 +24,7 @@ * underscores), and it avoids any risk of the table/:id pattern eating it. */ import type { DbClient } from '../../../db/client' +import type { BranchScope } from '../../../branches/scope' import type { CmsHandlerOptions } from '../shared' import { handleDataMetaRoutes } from './meta' import { handleDataSearchRoute } from './search' @@ -33,10 +34,11 @@ import { handleDataRowRoutes } from './rows' export async function handleDataRoutes( req: Request, db: DbClient, + scope: BranchScope, options: CmsHandlerOptions = {}, ): Promise { - return (await handleDataMetaRoutes(req, db)) - ?? (await handleDataSearchRoute(req, db)) - ?? (await handleDataTableRoutes(req, db)) - ?? (await handleDataRowRoutes(req, db, options)) + return (await handleDataMetaRoutes(req, db, scope)) + ?? (await handleDataSearchRoute(req, db, scope)) + ?? (await handleDataTableRoutes(req, db, scope)) + ?? (await handleDataRowRoutes(req, db, scope, options)) } diff --git a/server/handlers/cms/data/meta.ts b/server/handlers/cms/data/meta.ts index 6fc18262c..618f32676 100644 --- a/server/handlers/cms/data/meta.ts +++ b/server/handlers/cms/data/meta.ts @@ -15,10 +15,12 @@ import { listDataTables } from '../../../repositories/data' import { jsonResponse } from '../../../http' import { CMS_API_PREFIX } from '../shared' import { requireDataAccess } from './access' +import type { BranchScope } from '../../../branches/scope' export async function handleDataMetaRoutes( req: Request, db: DbClient, + scope: BranchScope, ): Promise { const { pathname } = new URL(req.url) @@ -26,7 +28,7 @@ export async function handleDataMetaRoutes( const access = await requireDataAccess(req, db) if (access instanceof Response) return access - const tables = await listDataTables(db) + const tables = await listDataTables(db, scope) return jsonResponse({ meta: buildDataMeta(tables) }) } diff --git a/server/handlers/cms/data/preview.ts b/server/handlers/cms/data/preview.ts index d8dfeeae0..a4878db59 100644 --- a/server/handlers/cms/data/preview.ts +++ b/server/handlers/cms/data/preview.ts @@ -36,6 +36,7 @@ import { applyPublishedHtmlPipeline } from '../../../publish/publishedHtmlPipeli import { badRequest, jsonResponse, readValidatedBody } from '../../../http' import { canReadDataRow, canReadTable, forbidden, requireDataAccess } from './access' import type { RouteParams } from '../routeTable' +import type { BranchScope } from '../../../branches/scope' const CSS_ASSET_BASE_URL = '/_instatic/css/' const LOOP_ENDPOINT_BASE_URL = '/_instatic/loop/' @@ -61,14 +62,15 @@ export async function handleRowPreview( req: Request, db: DbClient, params: RouteParams, + scope: BranchScope, ): Promise { const user = await requireDataAccess(req, db) if (user instanceof Response) return user - const row = await getDataRow(db, params.id) + const row = await getDataRow(db, scope, params.id) if (!row) return jsonResponse({ error: 'Row not found' }, { status: 404 }) - const table = await getDataTable(db, row.tableId) + const table = await getDataTable(db, scope, row.tableId) if (!table) return jsonResponse({ error: 'Table not found' }, { status: 404 }) // System-table rows need data.system.tables.read even to preview (GHSA-x69h). if (!canReadTable(user, table)) return jsonResponse({ error: 'Row not found' }, { status: 404 }) @@ -114,7 +116,9 @@ export async function handleRowPreview( entryStack: [publishedDataRowToLoopItem(draftPublishedRow)], route: buildRouteFrame(syntheticUrl.toString()), } - const loopData = await prefetchLoopData(merged, snapshot.site, db) + const loopData = await prefetchLoopData(merged, snapshot.site, db, undefined, { + branchId: scope.branchId, + }) const mediaAssets = await prefetchMediaAssets(merged, snapshot.site, registry, db, { templateContext, loopData, diff --git a/server/handlers/cms/data/rows.ts b/server/handlers/cms/data/rows.ts index 8c6dac2e2..d1db903db 100644 --- a/server/handlers/cms/data/rows.ts +++ b/server/handlers/cms/data/rows.ts @@ -34,6 +34,9 @@ import { updateDataRowAuthor, updateDataRowStatus, updateDataRowTable, + getDataRowBySlug, + getDataRowVersion, + listDataRowVersions, } from '../../../repositories/data' import { publishDataRow, removeDataRowArtefact } from '../../../publish/publishRow' import { runPublishFlush } from '../../../publish/publishFlush' @@ -64,6 +67,7 @@ import { requireDataRowMover, } from './access' import { handleRowPreview } from './preview' +import { isMainScope, type BranchScope } from '../../../branches/scope' // --------------------------------------------------------------------------- // Helpers @@ -75,6 +79,19 @@ function rowNotFound(): Response { return jsonResponse(ROW_NOT_FOUND_BODY, { status: 404 }) } +/** + * Publishing and scheduling exist on `main` only — a branch reaches the live + * site by being merged. The Content UI disables these actions on a branch + * with an inline reason; this is the server-side backstop. + */ +function branchOnlyResponse(scope: BranchScope): Response | null { + if (isMainScope(scope)) return null + return jsonResponse( + { error: 'Publishing is only available on main. Merge this branch first.' }, + { status: 409 }, + ) +} + type DataRowAuditAction = | 'data.row.update' | 'data.row.delete' @@ -115,18 +132,19 @@ async function recordRowAuditEvent( */ async function loadRowForAccess( db: DbClient, + scope: BranchScope, rowId: string, user: AuthUser, check: (user: AuthUser, row: DataRow) => boolean, ): Promise { - const row = await getDataRow(db, rowId) + const row = await getDataRow(db, scope, rowId) if (!row) return rowNotFound() // Enforce the table-family read boundary BEFORE the row-ownership check. A // persona granted a broad content.* capability but NOT data.system.tables.read // satisfies row ownership on every row, so without this it could read, edit, // and publish system-table rows (pages/posts drafts, author identity). The // schema-read sibling already checks this; the row layer did not (GHSA-x69h). - const table = await getDataTable(db, row.tableId) + const table = await getDataTable(db, scope, row.tableId) if (!table || !canReadTable(user, table)) return rowNotFound() if (!check(user, row)) return forbidden() return row @@ -149,11 +167,12 @@ async function handleRowItemGet( req: Request, db: DbClient, params: RouteParams, + scope: BranchScope, ): Promise { const user = await requireDataAccess(req, db) if (user instanceof Response) return user - const row = await loadRowForAccess(db, params.id, user, canReadDataRow) + const row = await loadRowForAccess(db, scope, params.id, user, canReadDataRow) if (row instanceof Response) return row return jsonResponse({ row }) } @@ -162,18 +181,19 @@ async function handleRowItemPatch( req: Request, db: DbClient, params: RouteParams, + scope: BranchScope, ): Promise { const rowId = params.id const user = await requireDataEditor(req, db) if (user instanceof Response) return user - const currentRow = await loadRowForAccess(db, rowId, user, canEditDataRow) + const currentRow = await loadRowForAccess(db, scope, rowId, user, canEditDataRow) if (currentRow instanceof Response) return currentRow const body = await readValidatedBody(req, RowUpsertBodySchema) if (!body) return badRequest('Invalid row payload') - const table = await getDataTable(db, currentRow.tableId) + const table = await getDataTable(db, scope, currentRow.tableId) if (!table) return rowNotFound() const rawCells = body.cells ?? currentRow.cells @@ -187,7 +207,7 @@ async function handleRowItemPatch( }) const slug = slugForTable(table, cells) - const row = await saveDataRowDraft(db, rowId, { cells, slug }, user.id) + const row = await saveDataRowDraft(db, scope, rowId, { cells, slug }, user.id) if (!row) return rowNotFound() // Changed cell ids: the patch's own keys plus any keys the filter added // or rewrote. Plugins watch this list to loop-guard their own writes; @@ -195,7 +215,7 @@ async function handleRowItemPatch( const patchedIds = body.cells ? Object.keys(body.cells) : [] const filterChangedIds = Object.keys(cells).filter((k) => cells[k] !== rawCells[k]) const changedFieldIds = [...new Set([...patchedIds, ...filterChangedIds])] - await emitContentEntryUpdated(db, rowId, changedFieldIds, { kind: 'user', userId: user.id }) + await emitContentEntryUpdated(db, scope, rowId, changedFieldIds, { kind: 'user', userId: user.id }) await recordRowAuditEvent(db, user, req, 'data.row.update', row) return jsonResponse({ row }) } @@ -204,28 +224,30 @@ async function handleRowItemDelete( req: Request, db: DbClient, params: RouteParams, + scope: BranchScope, options: CmsHandlerOptions, ): Promise { const rowId = params.id const user = await requireDataEditor(req, db) if (user instanceof Response) return user - const currentRow = await loadRowForAccess(db, rowId, user, canEditDataRow) + const currentRow = await loadRowForAccess(db, scope, rowId, user, canEditDataRow) if (currentRow instanceof Response) return currentRow - const row = await softDeleteDataRow(db, rowId, user.id) + const row = await softDeleteDataRow(db, scope, rowId, user.id) if (!row) return rowNotFound() // Prune the baked public artefact — a deleted row must stop being served // by Layer A, which reads the disk slot with no DB awareness (ISS-039). - if (options.uploadsDir) { + // Only main is served: a branch row never had an artefact or a cached route. + if (options.uploadsDir && isMainScope(scope)) { await removeDataRowArtefact(db, options.uploadsDir, rowId, row.slug).catch((err) => { console.error('[publish:row] failed to remove artefact for deleted row', rowId, err) }) } // Layer B mirror of the artefact prune: a published row's route is // retracted, so the render cache must stop serving it. - if (row.status === 'published') await bumpPublishVersionSerialized() - await emitContentEntryDeleted(db, rowId, { kind: 'user', userId: user.id }) + if (row.status === 'published' && isMainScope(scope)) await bumpPublishVersionSerialized() + await emitContentEntryDeleted(db, scope, rowId, { kind: 'user', userId: user.id }) await recordRowAuditEvent(db, user, req, 'data.row.delete', row) return jsonResponse({ row }) } @@ -234,17 +256,20 @@ async function handleRowPublish( req: Request, db: DbClient, params: RouteParams, + scope: BranchScope, options: CmsHandlerOptions, ): Promise { const rowId = params.id const user = await requireDataPublisher(req, db) if (user instanceof Response) return user + const offMain = branchOnlyResponse(scope) + if (offMain) return offMain - const currentRow = await loadRowForAccess(db, rowId, user, canPublishDataRow) + const currentRow = await loadRowForAccess(db, scope, rowId, user, canPublishDataRow) if (currentRow instanceof Response) return currentRow const result = await publishDataRow(db, rowId, user.id, options.uploadsDir) - await emitContentEntryUpdated(db, rowId, ['status'], { kind: 'user', userId: user.id }) + await emitContentEntryUpdated(db, scope, rowId, ['status'], { kind: 'user', userId: user.id }) await recordRowAuditEvent(db, user, req, 'data.row.publish', result.row, { versionNumber: result.version.versionNumber, }) @@ -259,10 +284,13 @@ async function handleRowSchedulePost( req: Request, db: DbClient, params: RouteParams, + scope: BranchScope, ): Promise { const rowId = params.id const user = await requireDataPublisher(req, db) if (user instanceof Response) return user + const offMain = branchOnlyResponse(scope) + if (offMain) return offMain // Flush the collab relay before reading the row, exactly as `publishDataRow` // does. A page created or edited in the visual editor lives in the relay's @@ -270,7 +298,7 @@ async function handleRowSchedulePost( // after creating it would otherwise 404 with "Data row not found". await runPublishFlush() - const currentRow = await loadRowForAccess(db, rowId, user, canPublishDataRow) + const currentRow = await loadRowForAccess(db, scope, rowId, user, canPublishDataRow) if (currentRow instanceof Response) return currentRow const body = await readValidatedBody(req, RowScheduleBodySchema) @@ -285,7 +313,7 @@ async function handleRowSchedulePost( } const whenIso = when.toISOString() - const row = await scheduleDataRowPublish(db, rowId, whenIso, user.id) + const row = await scheduleDataRowPublish(db, scope, rowId, whenIso, user.id) if (!row) return rowNotFound() await recordRowAuditEvent(db, user, req, 'data.row.schedule', row, { scheduledPublishAt: whenIso, @@ -297,15 +325,16 @@ async function handleRowScheduleDelete( req: Request, db: DbClient, params: RouteParams, + scope: BranchScope, ): Promise { const rowId = params.id const user = await requireDataPublisher(req, db) if (user instanceof Response) return user - const currentRow = await loadRowForAccess(db, rowId, user, canPublishDataRow) + const currentRow = await loadRowForAccess(db, scope, rowId, user, canPublishDataRow) if (currentRow instanceof Response) return currentRow - const row = await cancelScheduledPublish(db, rowId, user.id) + const row = await cancelScheduledPublish(db, scope, rowId, user.id) if (!row) { // Either the row doesn't exist OR it wasn't scheduled. The repo // function gates on `status = 'scheduled'`, so a non-scheduled @@ -320,6 +349,7 @@ async function handleRowStatus( req: Request, db: DbClient, params: RouteParams, + scope: BranchScope, options: CmsHandlerOptions, ): Promise { const rowId = params.id @@ -329,14 +359,15 @@ async function handleRowStatus( const body = await readValidatedBody(req, RowStatusBodySchema) if (!body) return badRequest('Status must be draft or unpublished') - const currentRow = await loadRowForAccess(db, rowId, user, canEditDataRow) + const currentRow = await loadRowForAccess(db, scope, rowId, user, canEditDataRow) if (currentRow instanceof Response) return currentRow - const row = await updateDataRowStatus(db, rowId, body.status, user.id) + const row = await updateDataRowStatus(db, scope, rowId, body.status, user.id) if (!row) return rowNotFound() // draft and unpublished both leave public visibility — prune the baked // artefact so Layer A stops serving the retracted content (ISS-039). - if (options.uploadsDir) { + // Only main has artefacts. + if (options.uploadsDir && isMainScope(scope)) { await removeDataRowArtefact(db, options.uploadsDir, rowId, row.slug).catch((err) => { console.error('[publish:row] failed to remove artefact for retracted row', rowId, err) }) @@ -349,6 +380,7 @@ async function handleRowAuthor( req: Request, db: DbClient, params: RouteParams, + scope: BranchScope, ): Promise { const rowId = params.id const user = await requireDataAuthorManager(req, db) @@ -360,10 +392,10 @@ async function handleRowAuthor( const author = await findUserById(db, body.authorUserId) if (!author || author.status !== 'active') return badRequest('Author must be an active user') - const currentRow = await getDataRow(db, rowId) + const currentRow = await getDataRow(db, scope, rowId) if (!currentRow) return rowNotFound() - const row = await updateDataRowAuthor(db, rowId, body.authorUserId, user.id) + const row = await updateDataRowAuthor(db, scope, rowId, body.authorUserId, user.id) if (!row) return rowNotFound() await createAuditEvent(db, { @@ -384,6 +416,7 @@ async function handleRowTable( req: Request, db: DbClient, params: RouteParams, + scope: BranchScope, ): Promise { const rowId = params.id // Cross-collection move = structurally distinct from cell-level editing. @@ -397,10 +430,10 @@ async function handleRowTable( const body = await readValidatedBody(req, RowTableBodySchema) if (!body || !body.tableId.trim()) return badRequest('Table is required') - const currentRow = await loadRowForAccess(db, rowId, user, canEditDataRow) + const currentRow = await loadRowForAccess(db, scope, rowId, user, canEditDataRow) if (currentRow instanceof Response) return currentRow - const result = await updateDataRowTable(db, rowId, body.tableId, user.id) + const result = await updateDataRowTable(db, scope, rowId, body.tableId, user.id) if (result.ok) { await recordRowAuditEvent(db, user, req, 'data.row.move', result.row) return jsonResponse({ row: result.row }) @@ -426,9 +459,87 @@ async function handleRowTable( // exclusive with the bare `/rows/:id` item route — order is not load-bearing // for correctness. They are still declared specific-first to mirror the // original dispatcher and read top-down. +/** + * GET /admin/api/cms/data/rows/:id/versions — every published version of + * the row, newest first. Versions come from publishes on main; the list is + * the same from any branch because the row's logical id is shared. + */ +async function handleRowVersionsList( + req: Request, + db: DbClient, + params: RouteParams, + scope: BranchScope, +): Promise { + const user = await requireDataAccess(req, db) + if (user instanceof Response) return user + const row = await loadRowForAccess(db, scope, params.id, user, canReadDataRow) + if (row instanceof Response) return row + return jsonResponse({ versions: await listDataRowVersions(db, row.id) }) +} + +/** + * POST /admin/api/cms/data/rows/:id/versions/:versionId/restore — copy a + * published version's content back into the row's DRAFT on the request's + * branch. Nothing is published: the restored draft still goes through + * publish (on main) or merge (on a branch). + */ +async function handleRowVersionRestore( + req: Request, + db: DbClient, + params: RouteParams, + scope: BranchScope, +): Promise { + const user = await requireDataEditor(req, db) + if (user instanceof Response) return user + const current = await loadRowForAccess(db, scope, params.id, user, canEditDataRow) + if (current instanceof Response) return current + const version = await getDataRowVersion(db, current.id, params.versionId) + if (!version) return jsonResponse({ error: 'Version not found' }, { status: 404 }) + const table = await getDataTable(db, scope, current.tableId) + if (!table) return rowNotFound() + + // The restored cells go through the same pipeline as a draft save: the + // `content.entry.cells` filter, then the slug derived from the cells — + // the version's stored slug may since have been taken by another row. + const cells = await applyContentEntryCellsFilter(version.cells, { + tableSlug: table.slug, + entryId: current.id, + actor: { kind: 'user', userId: user.id }, + }) + const slug = slugForTable(table, cells) + if (slug) { + const holder = await getDataRowBySlug(db, scope, table.id, slug) + if (holder && holder.id !== current.id) { + return jsonResponse( + { error: `Another ${table.singularLabel.toLowerCase()} now uses the slug "${slug}"; change its slug first` }, + { status: 409 }, + ) + } + } + + const row = await saveDataRowDraft(db, scope, current.id, { cells, slug }, user.id) + if (!row) return rowNotFound() + await emitContentEntryUpdated( + db, + scope, + current.id, + Object.keys(cells).filter((key) => JSON.stringify(cells[key]) !== JSON.stringify(current.cells[key])), + { kind: 'user', userId: user.id }, + ) + await createAuditEvent(db, { + actorUserId: user.id, + action: 'version.restore', + targetType: 'data_row', + targetId: current.id, + metadata: { versionId: version.id, versionNumber: version.versionNumber, branchId: scope.branchId }, + ...requestAuditContext(req), + }) + return jsonResponse({ row }) +} + const ROW_ITEM = `${CMS_API_PREFIX}/data/rows/(?[^/]+)` -const DATA_ROW_ROUTES: readonly Route<[CmsHandlerOptions]>[] = [ +const DATA_ROW_ROUTES: readonly Route<[BranchScope, CmsHandlerOptions]>[] = [ { method: 'GET', pattern: `${CMS_API_PREFIX}/data/authors`, handler: handleListAuthors }, { method: 'POST', pattern: new RegExp(`^${ROW_ITEM}/publish$`), handler: handleRowPublish }, { method: 'POST', pattern: new RegExp(`^${ROW_ITEM}/schedule$`), handler: handleRowSchedulePost }, @@ -437,6 +548,12 @@ const DATA_ROW_ROUTES: readonly Route<[CmsHandlerOptions]>[] = [ { method: 'PATCH', pattern: new RegExp(`^${ROW_ITEM}/author$`), handler: handleRowAuthor }, { method: 'PATCH', pattern: new RegExp(`^${ROW_ITEM}/table$`), handler: handleRowTable }, { method: 'POST', pattern: new RegExp(`^${ROW_ITEM}/preview$`), handler: handleRowPreview }, + { method: 'GET', pattern: new RegExp(`^${ROW_ITEM}/versions$`), handler: handleRowVersionsList }, + { + method: 'POST', + pattern: new RegExp(`^${ROW_ITEM}/versions/(?[^/]+)/restore$`), + handler: handleRowVersionRestore, + }, { method: 'GET', pattern: new RegExp(`^${ROW_ITEM}$`), handler: handleRowItemGet }, { method: 'PATCH', pattern: new RegExp(`^${ROW_ITEM}$`), handler: handleRowItemPatch }, { method: 'DELETE', pattern: new RegExp(`^${ROW_ITEM}$`), handler: handleRowItemDelete }, @@ -445,7 +562,8 @@ const DATA_ROW_ROUTES: readonly Route<[CmsHandlerOptions]>[] = [ export async function handleDataRowRoutes( req: Request, db: DbClient, + scope: BranchScope, options: CmsHandlerOptions = {}, ): Promise { - return runRouteTable(req, db, DATA_ROW_ROUTES, options) + return runRouteTable(req, db, DATA_ROW_ROUTES, scope, options) } diff --git a/server/handlers/cms/data/search.ts b/server/handlers/cms/data/search.ts index a110e0b5c..28ace538c 100644 --- a/server/handlers/cms/data/search.ts +++ b/server/handlers/cms/data/search.ts @@ -18,6 +18,7 @@ import { searchDataRows } from '../../../repositories/data' import { jsonResponse, methodNotAllowed } from '../../../http' import { CMS_API_PREFIX } from '../shared' import { canReadTable, canSeeAllDataRows, requireDataAccess } from './access' +import type { BranchScope } from '../../../branches/scope' const SEARCH_PATH = `${CMS_API_PREFIX}/data/search` const DEFAULT_LIMIT = 25 @@ -26,6 +27,7 @@ const MAX_LIMIT = 100 export async function handleDataSearchRoute( req: Request, db: DbClient, + scope: BranchScope, ): Promise { const url = new URL(req.url) if (url.pathname !== SEARCH_PATH) return null @@ -45,7 +47,7 @@ export async function handleDataSearchRoute( ) const visibility = canSeeAllDataRows(user) ? {} : { ownerUserId: user.id } - const results = await searchDataRows(db, rawQuery, limit, visibility) + const results = await searchDataRows(db, scope, rawQuery, limit, visibility) // A broad content.* capability satisfies requireDataAccess, but the search // must not surface system-table rows (pages/posts) to a caller without // data.system.tables.read (GHSA-x69h). Drop them, and keep the internal diff --git a/server/handlers/cms/data/tables.ts b/server/handlers/cms/data/tables.ts index cf0ef89ab..663e6e5d6 100644 --- a/server/handlers/cms/data/tables.ts +++ b/server/handlers/cms/data/tables.ts @@ -65,6 +65,8 @@ import { protectedBuiltInCreateCellKey, } from '@core/data/systemTableGuard' import { requireStepUp } from '../../../auth/authz' +import { isMainScope, type BranchScope } from '../../../branches/scope' +import { physicalId } from '@core/branches' // --------------------------------------------------------------------------- // Helpers @@ -73,8 +75,8 @@ import { requireStepUp } from '../../../auth/authz' function buildTablePatch( body: TablePatchBody, actorUserId: string, -): Parameters[2] | { error: string } { - const update: Parameters[2] = {} +): Parameters[3] | { error: string } { + const update: Parameters[3] = {} if (body.name !== undefined) { if (!body.name.trim()) return { error: 'Table name is required' } @@ -155,7 +157,11 @@ async function requireAnyRead(req: Request, db: DbClient): Promise { +async function handleTablesCollection( + req: Request, + db: DbClient, + scope: BranchScope, +): Promise { // GET = schema-level read (Data workspace floor; `content.*` callers also // accepted because the loop picker calls this and needs to know what tables // exist). POST = create a CUSTOM table (`data.custom.tables.manage` + step-up @@ -176,7 +182,7 @@ async function handleTablesCollection(req: Request, db: DbClient): Promise { // GET = schema read (Data workspace OR loop pickers in site editor). if (req.method === 'GET') { const user = await requireAnyRead(req, db) if (user instanceof Response) return user - const table = await getDataTable(db, tableId) + const table = await getDataTable(db, scope, tableId) if (!table) return jsonResponse({ error: 'Table not found' }, { status: 404 }) // A custom-only persona must not read a system table by id. Content-row // callers (loop pickers) may still resolve any table. @@ -254,7 +261,7 @@ async function handleTableItem( // affect the public URL surface. const user = await requireDataTablesRead(req, db) if (user instanceof Response) return user - const table = await getDataTable(db, tableId) + const table = await getDataTable(db, scope, tableId) if (!table) return jsonResponse({ error: 'Table not found' }, { status: 404 }) if (!canManageTable(user, table)) return forbidden() const stepUp = await requireStepUp(req, db, user) @@ -271,14 +278,14 @@ async function handleTableItem( const frozenError = assertSystemTableUpdateAllowed(table, update) if (frozenError) return badRequest(frozenError) - const updated = await updateDataTable(db, tableId, update) + const updated = await updateDataTable(db, scope, tableId, update) if (!updated) return jsonResponse({ error: 'Table not found' }, { status: 404 }) await recordTableAuditEvent(db, user, req, 'data.table.update', updated) return jsonResponse({ table: updated }) } if (req.method === 'DELETE') { - const deleted = await softDeleteDataTable(db, tableId, user.id) + const deleted = await softDeleteDataTable(db, scope, tableId, user.id) if (!deleted) return jsonResponse({ error: 'Table cannot be deleted' }, { status: 409 }) await recordTableAuditEvent(db, user, req, 'data.table.delete', deleted) return jsonResponse({ table: deleted }) @@ -290,6 +297,7 @@ async function handleTableItem( async function handleTableRows( req: Request, db: DbClient, + scope: BranchScope, tableId: string, ): Promise { const user = req.method === 'POST' @@ -297,7 +305,7 @@ async function handleTableRows( : await requireDataAccess(req, db) if (user instanceof Response) return user - const table = await getDataTable(db, tableId) + const table = await getDataTable(db, scope, tableId) if (!table) return jsonResponse({ error: 'Table not found' }, { status: 404 }) if (req.method === 'GET') { @@ -305,7 +313,7 @@ async function handleTableRows( // system table's rows still needs data.system.tables.read (GHSA-x69h). if (!canReadTable(user, table)) return jsonResponse({ error: 'Table not found' }, { status: 404 }) const visibility = canSeeAllDataRows(user) ? {} : { ownerUserId: user.id } - return jsonResponse({ rows: await listDataRows(db, tableId, visibility) }) + return jsonResponse({ rows: await listDataRows(db, scope, tableId, visibility) }) } if (req.method === 'POST') { @@ -335,7 +343,7 @@ async function handleTableRows( // opaque 500 — leaving the caller (often a script or an MCP connector) to // guess whether it hit a bug or a duplicate. Name it instead. if (slug) { - const clash = await getDataRowBySlug(db, tableId, slug) + const clash = await getDataRowBySlug(db, scope, tableId, slug) if (clash) { return jsonResponse( { error: `A row with slug "${slug}" already exists in this table.`, conflictRowId: clash.id }, @@ -344,8 +352,8 @@ async function handleTableRows( } } - const row = await createDataRow(db, { tableId, cells, slug }, user.id) - await emitContentEntryCreated(db, row.id, { kind: 'user', userId: user.id }) + const row = await createDataRow(db, scope, { tableId, cells, slug }, user.id) + await emitContentEntryCreated(db, scope, row.id, { kind: 'user', userId: user.id }) await createAuditEvent(db, { actorUserId: user.id, action: 'data.row.create', @@ -368,6 +376,7 @@ async function handleTableRows( async function handleTableLoopPreview( req: Request, db: DbClient, + scope: BranchScope, tableId: string, ): Promise { if (req.method !== 'GET') return methodNotAllowed() @@ -375,7 +384,7 @@ async function handleTableLoopPreview( const user = await requireDataAccess(req, db) if (user instanceof Response) return user - const table = await getDataTable(db, tableId) + const table = await getDataTable(db, scope, tableId) if (!table) return jsonResponse({ error: 'Table not found' }, { status: 404 }) const url = new URL(req.url) @@ -395,7 +404,10 @@ async function handleTableLoopPreview( }) const result = await fetchPublishedDataRowItems(db, { - tableId, + // The loop source binds PHYSICAL table ids; on a branch the rows are + // drafts (publishing is main-only). + tableId: physicalId(scope.branchId, tableId), + drafts: !isMainScope(scope), orderBy, direction, limit, @@ -420,11 +432,12 @@ const TABLE_LOOP_PREVIEW_PATTERN = /^\/admin\/api\/cms\/data\/tables\/([^/]+)\/l export async function handleDataTableRoutes( req: Request, db: DbClient, + scope: BranchScope, ): Promise { const { pathname } = new URL(req.url) if (pathname === `${CMS_API_PREFIX}/data/tables`) { - return handleTablesCollection(req, db) + return handleTablesCollection(req, db, scope) } // Sub-routes must match before the bare `/tables/:id` so that pattern @@ -432,17 +445,17 @@ export async function handleDataTableRoutes( // matches the whole tail). const loopPreviewMatch = pathname.match(TABLE_LOOP_PREVIEW_PATTERN) if (loopPreviewMatch) { - return handleTableLoopPreview(req, db, decodeURIComponent(loopPreviewMatch[1])) + return handleTableLoopPreview(req, db, scope, decodeURIComponent(loopPreviewMatch[1])) } const rowsMatch = pathname.match(TABLE_ROWS_PATTERN) if (rowsMatch) { - return handleTableRows(req, db, decodeURIComponent(rowsMatch[1])) + return handleTableRows(req, db, scope, decodeURIComponent(rowsMatch[1])) } const itemMatch = pathname.match(TABLE_ITEM_PATTERN) if (itemMatch) { - return handleTableItem(req, db, decodeURIComponent(itemMatch[1])) + return handleTableItem(req, db, scope, decodeURIComponent(itemMatch[1])) } return null diff --git a/server/handlers/cms/export.ts b/server/handlers/cms/export.ts index 51f199d8b..e56cf01fe 100644 --- a/server/handlers/cms/export.ts +++ b/server/handlers/cms/export.ts @@ -56,6 +56,7 @@ import { } from '@core/data/bundleArchive' import { canSeeAllDataRows } from './data/access' import { createStoredZipStream, estimateStoredZipSize, type StoredZipEntry } from '../../archive/storedZip' +import { resolveBranchScopeById, type BranchScope } from '../../branches/scope' const EXPORT_PATH = `${CMS_API_PREFIX}/export` const EXPORT_ESTIMATE_PATH = `${CMS_API_PREFIX}/export/estimate` @@ -111,6 +112,7 @@ interface ExportSelection { export async function handleExportRoute( req: Request, db: DbClient, + scope: BranchScope, options: CmsHandlerOptions = {}, ): Promise { const url = new URL(req.url) @@ -143,6 +145,8 @@ export async function handleExportRoute( let includeSite: boolean let includeMediaFolders: boolean let includeRedirects: boolean + // The form-POST download names its branch in the body (see ExportRequestSchema). + let exportScope = scope if (req.method === 'POST') { const exportReq = await readValidatedBody(req, ExportRequestSchema, { @@ -156,6 +160,11 @@ export async function handleExportRoute( includeSite = exportReq.includeSite ?? true includeMediaFolders = exportReq.includeMediaFolders ?? true includeRedirects = exportReq.includeRedirects ?? true + if (exportReq.branchId !== undefined) { + const requested = await resolveBranchScopeById(db, exportReq.branchId) + if (requested instanceof Response) return requested + exportScope = requested + } } else { // GET supports whole-table selection only (comma-separated ids); row-level // subsets are a POST-only concern (the export dialog always POSTs). @@ -170,14 +179,14 @@ export async function handleExportRoute( } // Always load the site shell — needed for sourceSiteName even when includeSite=false - const shell = await getDraftSite(db) + const shell = await getDraftSite(db, exportScope) if (!shell) { return jsonResponse({ error: 'Site not initialised — run setup before exporting' }, { status: 404 }) } // Resolve the table set: all tables for a full export, or just the named ones. const selectionByTable = selections ? new Map(selections.map((s) => [s.tableId, s])) : null - let tables = await listDataTables(db) + let tables = await listDataTables(db, exportScope) if (selectionByTable) { tables = tables.filter((t) => selectionByTable.has(t.id)) } @@ -190,7 +199,7 @@ export async function handleExportRoute( const visibility = canSeeAllDataRows(user) ? {} : { ownerUserId: user.id } const rowsPerTable = await Promise.all( tables.map(async (table) => { - const all = await listDataRows(db, table.id, visibility) + const all = await listDataRows(db, exportScope, table.id, visibility) const sel = selectionByTable?.get(table.id) if (!sel?.rowIds) return all const want = new Set(sel.rowIds) diff --git a/server/handlers/cms/import.ts b/server/handlers/cms/import.ts index 39137fb20..4486a7ee3 100644 --- a/server/handlers/cms/import.ts +++ b/server/handlers/cms/import.ts @@ -70,6 +70,7 @@ import { serializeCollabAwareWrite, type RowWriteKind, } from '../../repositories/rowWriteEvents' +import type { BranchScope } from '../../branches/scope' // The four system table ids that are always seeded and never deleted. const SYSTEM_TABLE_IDS = new Set(['posts', 'pages', 'components', 'layouts']) @@ -109,6 +110,7 @@ function looksLikeZipArchive(body: ArrayBuffer): boolean { export async function handleImportRoute( req: Request, db: DbClient, + scope: BranchScope, options: CmsHandlerOptions = {}, ): Promise { const url = new URL(req.url) @@ -206,7 +208,7 @@ export async function handleImportRoute( let shellWasWritten = false if (strategy === 'replace') { for (const tableId of affectedCollabRows.keys()) { - for (const row of await listDataRows(db, tableId)) { + for (const row of await listDataRows(db, scope, tableId)) { affectedCollabRows.get(tableId)?.add(row.id) } } @@ -215,22 +217,26 @@ export async function handleImportRoute( if (strategy === 'replace') { // Wipe-and-replace: delete all rows + custom tables, then reimport. await db.transaction(async (tx) => { - // 1. Delete ALL data rows (covers all tables) - await tx`delete from data_rows` + // 1. Delete ALL of this branch's data rows (covers all tables) + await tx`delete from data_rows where branch_id = ${scope.branchId}` - // 2. Delete all non-system data tables - await tx`delete from data_tables where system = 0 or system = false` + // 2. Delete this branch's non-system data tables + await tx` + delete from data_tables + where branch_id = ${scope.branchId} + and (system = 0 or system = false) + ` // 3. Load remaining system tables so we know which bundle tables to // update vs insert. - const existingTables = await listDataTables(tx) + const existingTables = await listDataTables(tx, scope) const existingTableIds = new Set(existingTables.map((t) => t.id)) // 4. Upsert tables from the bundle for (const table of bundle.tables) { if (existingTableIds.has(table.id)) { // System table already present — update its fields - await updateDataTable(tx, table.id, { + await updateDataTable(tx, scope, table.id, { name: table.name, slug: table.slug, routeBase: table.routeBase, @@ -242,7 +248,7 @@ export async function handleImportRoute( tablesAffected++ } else if (!SYSTEM_TABLE_IDS.has(table.id)) { // Custom table — insert with original id - await createDataTable(tx, { + await createDataTable(tx, scope, { id: table.id, name: table.name, slug: table.slug, @@ -271,14 +277,14 @@ export async function handleImportRoute( createdAt: row.createdAt, updatedAt: row.updatedAt, } - await replaceDataRow(tx, input) + await replaceDataRow(tx, scope, input) affectedCollabRows.get(row.tableId)?.add(row.id) rowsInserted++ } // 6. Replace the site shell (only when the bundle carries one) if (bundle.site) { - await saveDraftSite(tx, bundle.site, null, { collabInternal: true }) + await saveDraftSite(tx, scope, bundle.site, null, { collabInternal: true }) shellWasWritten = true } @@ -310,7 +316,7 @@ export async function handleImportRoute( await db.transaction(async (tx) => { // Tables: insert if absent, skip if the id already exists for (const table of bundle.tables) { - const inserted = await insertDataTableIfAbsent(tx, { + const inserted = await insertDataTableIfAbsent(tx, scope, { id: table.id, name: table.name, slug: table.slug, @@ -336,7 +342,7 @@ export async function handleImportRoute( createdAt: row.createdAt, updatedAt: row.updatedAt, } - const inserted = await insertDataRowIfAbsent(tx, input) + const inserted = await insertDataRowIfAbsent(tx, scope, input) if (inserted) { affectedCollabRows.get(row.tableId)?.add(row.id) rowsInserted++ @@ -355,14 +361,14 @@ export async function handleImportRoute( // bundle does not mention, and both its old and new collab docs/rosters // must be invalidated after commit. const existingRowTables = new Map() - for (const table of await listDataTables(tx)) { - const existing = await listDataRows(tx, table.id) + for (const table of await listDataTables(tx, scope)) { + const existing = await listDataRows(tx, scope, table.id) for (const row of existing) existingRowTables.set(row.id, row.tableId) } // Tables: insert if absent, update if already present for (const table of bundle.tables) { - const inserted = await insertDataTableIfAbsent(tx, { + const inserted = await insertDataTableIfAbsent(tx, scope, { id: table.id, name: table.name, slug: table.slug, @@ -374,7 +380,7 @@ export async function handleImportRoute( fields: table.fields, }) if (!inserted) { - await updateDataTable(tx, table.id, { + await updateDataTable(tx, scope, table.id, { name: table.name, slug: table.slug, routeBase: table.routeBase, @@ -399,7 +405,7 @@ export async function handleImportRoute( createdAt: row.createdAt, updatedAt: row.updatedAt, } - await upsertDataRow(tx, input) + await upsertDataRow(tx, scope, input) const previousTableId = existingRowTables.get(row.id) if (previousTableId === undefined) { createdCollabRows.get(row.tableId)?.add(row.id) @@ -418,27 +424,28 @@ export async function handleImportRoute( // Site shell: overwrite if the bundle carries one if (bundle.site) { - await saveDraftSite(tx, bundle.site, null, { collabInternal: true }) + await saveDraftSite(tx, scope, bundle.site, null, { collabInternal: true }) shellWasWritten = true } }) } - if (shellWasWritten) notifyShellWrite() + const branchId = scope.branchId + if (shellWasWritten) notifyShellWrite(branchId) if (strategy === 'merge-overwrite') { for (const [tableId, ids] of affectedCollabRows) { - if (ids.size > 0) notifyRowWrite({ tableId, rowIds: [...ids], kind: 'update' }) + if (ids.size > 0) notifyRowWrite({ branchId, tableId, rowIds: [...ids], kind: 'update' }) } for (const [tableId, ids] of removedCollabRows) { - if (ids.size > 0) notifyRowWrite({ tableId, rowIds: [...ids], kind: 'delete' }) + if (ids.size > 0) notifyRowWrite({ branchId, tableId, rowIds: [...ids], kind: 'delete' }) } for (const [tableId, ids] of createdCollabRows) { - if (ids.size > 0) notifyRowWrite({ tableId, rowIds: [...ids], kind: 'create' }) + if (ids.size > 0) notifyRowWrite({ branchId, tableId, rowIds: [...ids], kind: 'create' }) } } else { const eventKind: RowWriteKind = strategy === 'replace' ? 'delete' : 'create' for (const [tableId, ids] of affectedCollabRows) { - if (ids.size > 0) notifyRowWrite({ tableId, rowIds: [...ids], kind: eventKind }) + if (ids.size > 0) notifyRowWrite({ branchId, tableId, rowIds: [...ids], kind: eventKind }) } } }) diff --git a/server/handlers/cms/importArchive.ts b/server/handlers/cms/importArchive.ts index 125dc1d9b..7a01cb843 100644 --- a/server/handlers/cms/importArchive.ts +++ b/server/handlers/cms/importArchive.ts @@ -39,6 +39,7 @@ import { } from '@core/data/bundleArchive' import { createCrc32 } from '../../archive/storedZip' import { CMS_API_PREFIX, type CmsHandlerOptions } from './shared' +import type { BranchScope } from '../../branches/scope' import { handleImportRoute } from './import' const IMPORT_ARCHIVE_PATH = `${CMS_API_PREFIX}/import/archive` @@ -77,6 +78,7 @@ interface StagedArchiveMediaEntry { export async function handleImportArchiveRoute( req: Request, db: DbClient, + scope: BranchScope, options: CmsHandlerOptions = {}, ): Promise { const url = new URL(req.url) @@ -120,7 +122,7 @@ export async function handleImportArchiveRoute( try { const dataBundle = siteBundleWithoutMediaBytes(selectedManifest) const dataImportReq = makeInternalImportRequest(req, strategy, dataBundle) - const dataImportRes = await handleImportRoute(dataImportReq, db, options) + const dataImportRes = await handleImportRoute(dataImportReq, db, scope, options) if (!dataImportRes || !dataImportRes.ok) { return dataImportRes ?? jsonResponse({ error: 'Import route did not handle archive manifest' }, { status: 500 }) } diff --git a/server/handlers/cms/importPreview.ts b/server/handlers/cms/importPreview.ts index 8ce24f676..922774749 100644 --- a/server/handlers/cms/importPreview.ts +++ b/server/handlers/cms/importPreview.ts @@ -31,10 +31,12 @@ import { } from '@core/data/bundleSchema' import type { DataRow, DataTable } from '@core/data/schemas' import { CMS_API_PREFIX } from './shared' +import type { BranchScope } from '../../branches/scope' export async function handleImportPreviewRoute( req: Request, db: DbClient, + scope: BranchScope, ): Promise { const url = new URL(req.url) if (url.pathname !== `${CMS_API_PREFIX}/import/preview`) return null @@ -49,7 +51,7 @@ export async function handleImportPreviewRoute( } // Fetch current local tables to know which ones exist - const localTables = await listDataTables(db) + const localTables = await listDataTables(db, scope) const localTableIds = new Set(localTables.map((t) => t.id)) const rowConflicts: BundleRowConflict[] = [] @@ -65,7 +67,7 @@ export async function handleImportPreviewRoute( // Local rows for this table (0 if the table doesn't exist locally yet) let localRows: DataRow[] if (localTableIds.has(table.id)) { - localRows = await listDataRows(db, table.id) + localRows = await listDataRows(db, scope, table.id) } else { localRows = [] } diff --git a/server/handlers/cms/index.ts b/server/handlers/cms/index.ts index 145868b4a..c91c57137 100644 --- a/server/handlers/cms/index.ts +++ b/server/handlers/cms/index.ts @@ -29,7 +29,9 @@ import type { DbClient } from '../../db/client' import { jsonResponse } from '../../http' import { isStateChangingMethod, originAllowed } from '../../auth/security' +import { resolveBranchScope } from '../../branches/scope' import type { CmsHandlerOptions } from './shared' +import { handleBranchesRoutes } from './branches' import { handleSetupRoutes } from './setup' import { handleAuthRoutes } from './auth' import { handleMeRoutes } from './me' @@ -73,6 +75,13 @@ export async function handleCmsRequest( return jsonResponse({ error: 'Forbidden: invalid origin' }, { status: 403 }) } + // Branch scope — resolved ONCE per request from the `X-Instatic-Branch` + // header and handed to every content-shaped route group. Groups that only + // ever address the live site (dashboard, publish, plugins) pin MAIN_SCOPE + // themselves; user / media / auth groups have no branched data at all. + const scope = await resolveBranchScope(req, db) + if (scope instanceof Response) return scope + // Try each route group in order. The first to return a non-null // Response handled the request; null means "this group didn't match, // try the next one". @@ -87,14 +96,15 @@ export async function handleCmsRequest( ?? (await handleUsersRoutes(req, db)) ?? (await handleRolesRoutes(req, db)) ?? (await handleAuditRoutes(req, db)) - ?? (await handleSiteRoutes(req, db)) + ?? (await handleBranchesRoutes(req, db, scope, options)) + ?? (await handleSiteRoutes(req, db, scope)) // The transactional whole-document save — must run before the pages/ // components/layouts GET handlers only for tidiness; paths are distinct. - ?? (await handleSiteDocumentRoutes(req, db)) - ?? (await handlePagesRoutes(req, db)) - ?? (await handleComponentsRoutes(req, db)) - ?? (await handleLayoutsRoutes(req, db)) - ?? (await handleRuntimeRoutes(req, db)) + ?? (await handleSiteDocumentRoutes(req, db, scope)) + ?? (await handlePagesRoutes(req, db, scope)) + ?? (await handleComponentsRoutes(req, db, scope)) + ?? (await handleLayoutsRoutes(req, db, scope)) + ?? (await handleRuntimeRoutes(req, db, scope)) // The folder routes match `/admin/api/cms/media/folders/...` so they must // run BEFORE the asset routes whose `/admin/api/cms/media/:id` pattern // would otherwise eat them (treating "folders" as an asset id). The @@ -104,21 +114,21 @@ export async function handleCmsRequest( ?? (await handleMediaStorageAdminRoutes(req, db, options)) ?? (await handleMediaRoutes(req, db)) ?? (await handlePluginsRoutes(req, db, options)) - ?? (await handleDataRoutes(req, db, options)) + ?? (await handleDataRoutes(req, db, scope, options)) // Dashboard stats — read-only aggregate counts used by the admin // dashboard widgets. Lives after data routes so future routes // under `/data/...` can never accidentally shadow it. ?? (await handleDashboardRoutes(req, db, options)) ?? (await handleFontsRoutes(req, db, options)) - ?? (await handlePublishRoutes(req, db, options)) + ?? (await handlePublishRoutes(req, db, scope, options)) // Export and import are registered after data routes so their exact paths // `/export` and `/import` cannot conflict with any `/data/...` sub-routes. // Preview must come before import: `/import/preview` is a longer path that // would otherwise be consumed by the `/import` handler first. - ?? (await handleExportRoute(req, db, options)) - ?? (await handleImportPreviewRoute(req, db)) - ?? (await handleImportArchiveRoute(req, db, options)) - ?? (await handleImportRoute(req, db, options)) + ?? (await handleExportRoute(req, db, scope, options)) + ?? (await handleImportPreviewRoute(req, db, scope)) + ?? (await handleImportArchiveRoute(req, db, scope, options)) + ?? (await handleImportRoute(req, db, scope, options)) return response ?? jsonResponse({ error: 'Not found' }, { status: 404 }) } diff --git a/server/handlers/cms/layouts.ts b/server/handlers/cms/layouts.ts index cb42d825e..92482ade6 100644 --- a/server/handlers/cms/layouts.ts +++ b/server/handlers/cms/layouts.ts @@ -19,11 +19,16 @@ * shell + pages + components + layouts atomically. */ import type { DbClient } from '../../db/client' +import type { BranchScope } from '../../branches/scope' import { requireCapability } from '../../auth/authz' import { methodNotAllowed } from '../../http' import { CMS_API_PREFIX, siteCollectionRowsResponse } from './shared' -export async function handleLayoutsRoutes(req: Request, db: DbClient): Promise { +export async function handleLayoutsRoutes( + req: Request, + db: DbClient, + scope: BranchScope, +): Promise { const url = new URL(req.url) if (url.pathname !== `${CMS_API_PREFIX}/layouts`) return null if (req.method !== 'GET') return methodNotAllowed() @@ -31,5 +36,5 @@ export async function handleLayoutsRoutes(req: Request, db: DbClient): Promise { +export async function handlePagesRoutes( + req: Request, + db: DbClient, + scope: BranchScope, +): Promise { const url = new URL(req.url) if (url.pathname !== `${CMS_API_PREFIX}/pages`) return null if (req.method !== 'GET') return methodNotAllowed() @@ -31,5 +36,5 @@ export async function handlePagesRoutes(req: Request, db: DbClient): Promise { @@ -97,16 +98,16 @@ async function installPluginPackToSite( // Extract shell (strip pages, visualComponents, and layouts) and save const { pages: packPages, visualComponents: _vcs, layouts: _layouts, ...nextShell } = nextSiteDoc - await saveDraftSite(db, nextShell, actorUserId) + await saveDraftSite(db, MAIN_SCOPE, nextShell, actorUserId) // Upsert pack pages as data_rows const existingPagesById = new Map(pageRows.map((r) => [r.id, r])) for (const page of packPages) { const cells = pageToCells(page) if (existingPagesById.has(page.id)) { - await saveDataRowDraft(db, page.id, { cells, slug: page.slug }, actorUserId) + await saveDataRowDraft(db, MAIN_SCOPE, page.id, { cells, slug: page.slug }, actorUserId) } else { - await createDataRow(db, { id: page.id, tableId: 'pages', cells, slug: page.slug }, actorUserId) + await createDataRow(db, MAIN_SCOPE, { id: page.id, tableId: 'pages', cells, slug: page.slug }, actorUserId) } } @@ -116,9 +117,9 @@ async function installPluginPackToSite( const cells = visualComponentToCells(vc) const slug = vcSlugFromName(vc.name) if (existingVCsById.has(vc.id)) { - await saveDataRowDraft(db, vc.id, { cells, slug }, actorUserId) + await saveDataRowDraft(db, MAIN_SCOPE, vc.id, { cells, slug }, actorUserId) } else { - await createDataRow(db, { id: vc.id, tableId: 'components', cells, slug }, actorUserId) + await createDataRow(db, MAIN_SCOPE, { id: vc.id, tableId: 'components', cells, slug }, actorUserId) } } @@ -128,9 +129,9 @@ async function installPluginPackToSite( const cells = savedLayoutToCells(layout) const slug = layoutSlugFromName(layout.name) if (existingLayoutRowsById.has(layout.id)) { - await saveDataRowDraft(db, layout.id, { cells, slug }, actorUserId) + await saveDataRowDraft(db, MAIN_SCOPE, layout.id, { cells, slug }, actorUserId) } else { - await createDataRow(db, { id: layout.id, tableId: 'layouts', cells, slug }, actorUserId) + await createDataRow(db, MAIN_SCOPE, { id: layout.id, tableId: 'layouts', cells, slug }, actorUserId) } } diff --git a/server/handlers/cms/publish.ts b/server/handlers/cms/publish.ts index 02b1b4a6b..aa929bbfd 100644 --- a/server/handlers/cms/publish.ts +++ b/server/handlers/cms/publish.ts @@ -19,6 +19,7 @@ * and the `plugins.install` / `plugins.lifecycle` mutation surface. */ import type { DbClient } from '../../db/client' +import { isMainScope, type BranchScope } from '../../branches/scope' import { requireCapability, requireStepUp } from '../../auth/authz' import { createAuditEvent } from '../../repositories/audit' import { getDraftPublishStatus } from '../../repositories/publish' @@ -31,6 +32,7 @@ import { requestAuditContext } from './shared' export async function handlePublishRoutes( req: Request, db: DbClient, + scope: BranchScope, options: CmsHandlerOptions = {}, ): Promise { const url = new URL(req.url) @@ -39,6 +41,14 @@ export async function handlePublishRoutes( const user = await requireCapability(req, db, 'pages.publish') if (user instanceof Response) return user if (req.method !== 'POST') return methodNotAllowed() + // Only main is ever served; a branch reaches the public site by being + // merged into main first. + if (!isMainScope(scope)) { + return jsonResponse( + { error: 'Publishing is only available on main. Merge this branch first.' }, + { status: 409 }, + ) + } const stepUp = await requireStepUp(req, db, user) if (stepUp) return stepUp diff --git a/server/handlers/cms/runtime.ts b/server/handlers/cms/runtime.ts index fd6cdbd19..99fe73d35 100644 --- a/server/handlers/cms/runtime.ts +++ b/server/handlers/cms/runtime.ts @@ -40,6 +40,7 @@ import type { Page, SiteDocument, SiteShell } from '@core/page-tree' import { badRequest, jsonResponse, methodNotAllowed, readValidatedBody } from '../../http' import { Type } from '@core/utils/typeboxHelpers' import { getErrorMessage } from '@core/utils/errorMessage' +import type { BranchScope } from '../../branches/scope' function runtimeDependencyMap(raw: unknown): Record { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {} @@ -109,7 +110,11 @@ async function runtimeDependencyCache(site: SiteDocument) { : undefined } -export async function handleRuntimeRoutes(req: Request, db: DbClient): Promise { +export async function handleRuntimeRoutes( + req: Request, + db: DbClient, + scope: BranchScope, +): Promise { const url = new URL(req.url) if (url.pathname === '/admin/api/cms/runtime/dependencies/resolve') { @@ -225,6 +230,8 @@ export async function handleRuntimeRoutes(req: Request, db: DbClient): Promise { - return jsonResponse({ rows: await listDataRows(db, tableId) }) + return jsonResponse({ rows: await listDataRows(db, scope, tableId) }) } export function mutationErrorResponse(err: unknown): Response { diff --git a/server/handlers/cms/site.ts b/server/handlers/cms/site.ts index b14f7c5ec..36e70d417 100644 --- a/server/handlers/cms/site.ts +++ b/server/handlers/cms/site.ts @@ -12,11 +12,16 @@ * shell + pages + components + layouts atomically. */ import type { DbClient } from '../../db/client' +import type { BranchScope } from '../../branches/scope' import { requireCapability } from '../../auth/authz' import { getDraftSite, getDraftSiteSeq } from '../../repositories/site' import { jsonResponse, methodNotAllowed } from '../../http' -export async function handleSiteRoutes(req: Request, db: DbClient): Promise { +export async function handleSiteRoutes( + req: Request, + db: DbClient, + scope: BranchScope, +): Promise { const url = new URL(req.url) if (url.pathname !== '/admin/api/cms/site') return null if (req.method !== 'GET') return methodNotAllowed() @@ -24,7 +29,7 @@ export async function handleSiteRoutes(req: Request, db: DbClient): Promise { +export async function handleSiteDocumentRoutes( + req: Request, + db: DbClient, + scope: BranchScope, +): Promise { const url = new URL(req.url) if (url.pathname !== `${CMS_API_PREFIX}/site-document`) return null if (req.method !== 'PUT') return methodNotAllowed() @@ -206,7 +212,7 @@ export async function handleSiteDocumentRoutes(req: Request, db: DbClient): Prom // cheap (id, slug) page projection plus the component roster it needs // for ref validation — never all three hydrated collections. - const previousShell = await getDraftSite(db) + const previousShell = await getDraftSite(db, scope) const shell = validateSite(body.site) validateSiteWriteDiff(previousShell, shell, user.capabilities) @@ -232,7 +238,7 @@ export async function handleSiteDocumentRoutes(req: Request, db: DbClient): Prom body.changedPages.length > 0 || body.mode === 'replace' const existingVCs: VisualComponent[] = needsComponentRoster - ? (await listDataRows(db, 'components')).flatMap((r) => { + ? (await listDataRows(db, scope, 'components')).flatMap((r) => { const vc = visualComponentFromRow(r) return vc ? [vc] : [] }) @@ -262,7 +268,7 @@ export async function handleSiteDocumentRoutes(req: Request, db: DbClient): Prom const needsLayoutRoster = body.changedLayouts.length > 0 || body.deletedLayoutIds.length > 0 || body.mode === 'replace' const existingLayouts: SavedLayout[] = needsLayoutRoster - ? (await listDataRows(db, 'layouts')).flatMap((r) => { + ? (await listDataRows(db, scope, 'layouts')).flatMap((r) => { const layout = savedLayoutFromRow(r) return layout ? [layout] : [] }) @@ -296,7 +302,7 @@ export async function handleSiteDocumentRoutes(req: Request, db: DbClient): Prom // loaded only for the per-category diff, which callers holding all three // site-write capabilities skip entirely (its fast path). const needsPageSlugs = body.changedPages.length > 0 || body.mode === 'replace' - const existingPageSlugs = needsPageSlugs ? await listDataRowIdSlugs(db, 'pages') : [] + const existingPageSlugs = needsPageSlugs ? await listDataRowIdSlugs(db, scope, 'pages') : [] const changedPageIdsRaw = new Set( body.changedPages .map((p) => (p && typeof p === 'object' ? (p as { id?: unknown }).id : undefined)) @@ -321,7 +327,7 @@ export async function handleSiteDocumentRoutes(req: Request, db: DbClient): Prom : [] const previousPages: Page[] = pages.length > 0 && !hasAllSiteCaps - ? (await listDataRows(db, 'pages')).map(pageFromRow) + ? (await listDataRows(db, scope, 'pages')).map(pageFromRow) : [] validatePageWriteDiff({ previousPages, @@ -365,9 +371,9 @@ export async function handleSiteDocumentRoutes(req: Request, db: DbClient): Prom if (body.mode === 'incremental') { const conflicts: SaveConflict[] = [] if (shellChanged) { - const storedShellSeq = await getDraftSiteSeq(tx) + const storedShellSeq = await getDraftSiteSeq(tx, scope) if (storedShellSeq > body.shellBaseSeq) { - conflicts.push({ table: 'site', rowId: 'default', seq: storedShellSeq }) + conflicts.push({ table: 'site', rowId: SITE_SHELL_LOGICAL_ID, seq: storedShellSeq }) } } const rowChecks = [ @@ -379,7 +385,7 @@ export async function handleSiteDocumentRoutes(req: Request, db: DbClient): Prom // listDataRowSeqs sees soft-deleted rows too: a remote deletion is // a newer write, not absence. Rows with no stored counterpart are // client creations and pass by construction (absent from the result). - for (const stored of await listDataRowSeqs(tx, table, ids)) { + for (const stored of await listDataRowSeqs(tx, scope, table, ids)) { const base = body.baseSeqs[stored.id] if (base === undefined || stored.seq > base) { conflicts.push({ table, rowId: stored.id, seq: stored.seq }) @@ -393,25 +399,25 @@ export async function handleSiteDocumentRoutes(req: Request, db: DbClient): Prom // — see the shellChanged comment in phase 1. if (shellChanged) { // In-transaction — collab listeners are notified post-commit below. - await saveDraftSite(tx, shell, user.id, { collabInternal: true }) - await stampDraftSiteSeq(tx, seq) + await saveDraftSite(tx, scope, shell, user.id, { collabInternal: true }) + await stampDraftSiteSeq(tx, scope, seq) } // Empty change sets skip their table entirely — a shell-only save // issues no row queries inside the transaction. if (componentWrites.length > 0 || componentDeleteIds.size > 0) { - await applyDataRowChangesInTx(tx, { + await applyDataRowChangesInTx(tx, scope, { tableId: 'components', writes: componentWrites, deleteIds: componentDeleteIds, actorUserId: user.id, seq, }) } if (layoutWrites.length > 0 || layoutDeleteIds.size > 0) { - await applyDataRowChangesInTx(tx, { + await applyDataRowChangesInTx(tx, scope, { tableId: 'layouts', writes: layoutWrites, deleteIds: layoutDeleteIds, actorUserId: user.id, seq, }) } if (pageWrites.length > 0 || pageDeleteIds.size > 0) { - const pagesResult = await applyDataRowChangesInTx(tx, { + const pagesResult = await applyDataRowChangesInTx(tx, scope, { tableId: 'pages', writes: pageWrites, deleteIds: pageDeleteIds, actorUserId: user.id, seq, }) @@ -422,7 +428,7 @@ export async function handleSiteDocumentRoutes(req: Request, db: DbClient): Prom // Collab invalidation — this save wrote rows/shell OUTSIDE the relay, so // affected CRDT documents must reset while this ordered write still owns // the lane (post-commit; see rowWriteEvents). - if (shellChanged) notifyShellWrite() + if (shellChanged) notifyShellWrite(scope.branchId) const writtenGroups: Array<[string, Iterable, RowWriteKind]> = [ ['pages', changedPageIdsRaw, 'update'], ['pages', pageDeleteIds, 'delete'], @@ -433,7 +439,7 @@ export async function handleSiteDocumentRoutes(req: Request, db: DbClient): Prom ] for (const [tableId, ids, kind] of writtenGroups) { const rowIds = [...ids] - if (rowIds.length > 0) notifyRowWrite({ tableId, rowIds, kind }) + if (rowIds.length > 0) notifyRowWrite({ branchId: scope.branchId, tableId, rowIds, kind }) } }) diff --git a/server/index.ts b/server/index.ts index c1d0df6cc..ee915a906 100644 --- a/server/index.ts +++ b/server/index.ts @@ -135,6 +135,7 @@ const server = Bun.serve({ staticDir: config.staticDir, uploadsDir: config.uploadsDir, databaseUrl: config.databaseUrl, + collabRelay, }) for (const [k, v] of Object.entries(cors)) { res.headers.set(k, v) diff --git a/server/plugins/host/contentFieldMapping.ts b/server/plugins/host/contentFieldMapping.ts index 6b5b37b53..073d7f0a5 100644 --- a/server/plugins/host/contentFieldMapping.ts +++ b/server/plugins/host/contentFieldMapping.ts @@ -3,6 +3,7 @@ import type { PluginRepeaterItemField } from '@core/plugin-sdk/types/content' import type { DataField, RepeaterItemField } from '@core/data/schemas' import { listDataTables } from '../../repositories/data' import type { DbClient } from '../../db/client' +import { MAIN_SCOPE } from '../../branches/scope' function pluginFieldCommon(field: { id: string; label: string; required?: boolean }): { id: string @@ -68,7 +69,7 @@ function pluginRepeaterItemFieldToDataField( } export async function buildContentTableIdLookup(db: DbClient): Promise> { - const tables = await listDataTables(db) + const tables = await listDataTables(db, MAIN_SCOPE) return new Map(tables.map((t) => [t.slug, t.id])) } diff --git a/server/plugins/host/handlers/content.ts b/server/plugins/host/handlers/content.ts index f0909c354..93a06ac7b 100644 --- a/server/plugins/host/handlers/content.ts +++ b/server/plugins/host/handlers/content.ts @@ -60,6 +60,7 @@ import { } from './contentProjection' import { replyApiOk } from '../apiReplies' import type { HostPluginRecord } from '../types' +import { MAIN_SCOPE } from '../../../branches/scope' // Projection helpers (DB → wire shapes) live in `contentProjection.ts`. @@ -123,7 +124,7 @@ export async function handleContentTablesList( db: DbClient, ): Promise { const allowedSlugs = new Set((entry.manifest.contentAccess ?? []).map((e) => e.table)) - const tables = await listDataTablesWithCounts(db) + const tables = await listDataTablesWithCounts(db, MAIN_SCOPE) const summaries: ContentTableSummary[] = tables .filter((t) => allowedSlugs.has(t.slug)) .map((t) => tableSummary(t, t.rowCount)) @@ -146,7 +147,7 @@ export async function handleContentTablesGet( // projection needs — no per-table COUNT subselects for tables we don't // return. const [rowCount, slugLookup] = await Promise.all([ - countDataRows(db, table.id), + countDataRows(db, MAIN_SCOPE, table.id), buildTableSlugLookup(db), ]) replyApiOk( @@ -171,7 +172,7 @@ export async function handleContentTablesCreate( ? await buildContentTableIdLookup(db) : new Map() const fields = pluginContentFieldsToDataFields(input.fields ?? [], tableIdBySlug) - const created = await createDataTable(db, { + const created = await createDataTable(db, MAIN_SCOPE, { name: input.name, slug: input.slug, kind: input.kind ?? 'data', @@ -197,7 +198,7 @@ export async function handleContentEntriesList( const [tableSlug, options] = msg.args assertContentTableAccess(entry, tableSlug, 'read') const table = await resolveTableBySlug(db, tableSlug) - const result = await listDataRowsWithFilter(db, table.id, options) + const result = await listDataRowsWithFilter(db, MAIN_SCOPE, table.id, options) replyApiOk(msg.pluginId, msg.correlationId, { entries: result.rows.map((r) => rowToEntry(r, tableSlug)), totalCount: result.totalCount, @@ -212,7 +213,7 @@ export async function handleContentEntriesGet( const [tableSlug, entryId] = msg.args assertContentTableAccess(entry, tableSlug, 'read') const table = await resolveTableBySlug(db, tableSlug) - const row = await getDataRow(db, entryId) + const row = await getDataRow(db, MAIN_SCOPE, entryId) if (!row || row.tableId !== table.id) { replyApiOk(msg.pluginId, msg.correlationId, null) return @@ -228,7 +229,7 @@ export async function handleContentEntriesGetBySlug( const [tableSlug, slug] = msg.args assertContentTableAccess(entry, tableSlug, 'read') const table = await resolveTableBySlug(db, tableSlug) - const row = await getDataRowBySlug(db, table.id, slug) + const row = await getDataRowBySlug(db, MAIN_SCOPE, table.id, slug) replyApiOk(msg.pluginId, msg.correlationId, row ? rowToEntry(row, tableSlug) : null) } @@ -249,6 +250,7 @@ export async function handleContentEntriesCreate( const slug = input.slug ?? denormalizeSlug(table, cells) const created = await createDataRow( db, + MAIN_SCOPE, { tableId: table.id, cells, slug }, null, msg.pluginId, @@ -265,7 +267,7 @@ export async function handleContentEntriesUpdate( const [tableSlug, entryId, patch] = msg.args assertContentTableAccess(entry, tableSlug, 'write') const table = await resolveTableBySlug(db, tableSlug) - const existing = await getDataRow(db, entryId) + const existing = await getDataRow(db, MAIN_SCOPE, entryId) if (!existing || existing.tableId !== table.id) { throw new Error(`Entry "${entryId}" not found in table "${tableSlug}"`) } @@ -283,6 +285,7 @@ export async function handleContentEntriesUpdate( const nextSlug = patch.slug ?? denormalizeSlug(table, filteredCells) const updated = await saveDataRowDraft( db, + MAIN_SCOPE, entryId, { cells: filteredCells, slug: nextSlug || existing.slug }, null, @@ -303,11 +306,11 @@ export async function handleContentEntriesDelete( const [tableSlug, entryId] = msg.args assertContentTableAccess(entry, tableSlug, 'delete') const table = await resolveTableBySlug(db, tableSlug) - const existing = await getDataRow(db, entryId) + const existing = await getDataRow(db, MAIN_SCOPE, entryId) if (!existing || existing.tableId !== table.id) { throw new Error(`Entry "${entryId}" not found in table "${tableSlug}"`) } - const deleted = await softDeleteDataRow(db, entryId) + const deleted = await softDeleteDataRow(db, MAIN_SCOPE, entryId) if (deleted) { // A published row's route is retracted — invalidate the render cache. if (deleted.status === 'published') await bumpPublishVersionSerialized() @@ -324,14 +327,14 @@ export async function handleContentEntriesPublish( const [tableSlug, entryId, options] = msg.args assertContentTableAccess(entry, tableSlug, 'publish') const table = await resolveTableBySlug(db, tableSlug) - const existing = await getDataRow(db, entryId) + const existing = await getDataRow(db, MAIN_SCOPE, entryId) if (!existing || existing.tableId !== table.id) { throw new Error(`Entry "${entryId}" not found in table "${tableSlug}"`) } const actor: PluginActor = { kind: 'plugin', pluginId: msg.pluginId } if (options.scheduledFor) { - const scheduled = await scheduleDataRowPublish(db, entryId, options.scheduledFor, null) + const scheduled = await scheduleDataRowPublish(db, MAIN_SCOPE, entryId, options.scheduledFor, null) if (!scheduled) throw new Error(`Entry "${entryId}" could not be scheduled`) await emitEntryUpdated(tableSlug, entryId, ['status'], actor) replyApiOk(msg.pluginId, msg.correlationId, rowToEntry(scheduled, tableSlug)) @@ -353,11 +356,11 @@ export async function handleContentEntriesMoveTable( assertContentTableAccess(entry, targetSlug, 'write') const source = await resolveTableBySlug(db, tableSlug) const target = await resolveTableBySlug(db, targetSlug) - const existing = await getDataRow(db, entryId) + const existing = await getDataRow(db, MAIN_SCOPE, entryId) if (!existing || existing.tableId !== source.id) { throw new Error(`Entry "${entryId}" not found in table "${tableSlug}"`) } - const result = await updateDataRowTable(db, entryId, target.id, null) + const result = await updateDataRowTable(db, MAIN_SCOPE, entryId, target.id, null) if (!result.ok) throw new Error(`moveToTable failed: ${result.reason}`) const actor: PluginActor = { kind: 'plugin', pluginId: msg.pluginId } await emitEntryUpdated(tableSlug, entryId, ['tableId'], actor) @@ -383,7 +386,7 @@ export async function handleContentEntriesCreateMany( const slug = input.slug ?? denormalizeSlug(table, cells) return { tableId: table.id, cells, slug } })) - const created = await createDataRowMany(db, prepared, null, msg.pluginId) + const created = await createDataRowMany(db, MAIN_SCOPE, prepared, null, msg.pluginId) for (const row of created) { await emitEntryCreated(tableSlug, row.id, actor) } @@ -403,7 +406,7 @@ export async function handleContentEntriesUpdateMany( // Read every targeted row in ONE IN-list query, then apply filter + diff // per-row before the transaction. Iterating `updates` in input order // preserves the first-bad-id error semantics of the old per-row reads. - const existingRows = await getDataRowMany(db, updates.map((u) => u.id)) + const existingRows = await getDataRowMany(db, MAIN_SCOPE, updates.map((u) => u.id)) const existingById = new Map(existingRows.map((row) => [row.id, row])) const prepared: Array<{ id: string; input: { cells: Record; slug: string }; changedIds: string[] }> = [] for (const { id, patch } of updates) { @@ -427,6 +430,7 @@ export async function handleContentEntriesUpdateMany( } const updated = await saveDataRowDraftMany( db, + MAIN_SCOPE, prepared.map((p) => ({ id: p.id, input: p.input })), null, msg.pluginId, @@ -450,7 +454,7 @@ export async function handleContentEntriesDeleteMany( // Validate every id belongs to this table BEFORE the transaction so a // bad id aborts cleanly without partially-applied deletes. One IN-list // read for the whole batch; input order preserves first-bad-id errors. - const rows = await getDataRowMany(db, ids) + const rows = await getDataRowMany(db, MAIN_SCOPE, ids) const rowsById = new Map(rows.map((row) => [row.id, row])) for (const id of ids) { const row = rowsById.get(id) @@ -458,7 +462,7 @@ export async function handleContentEntriesDeleteMany( throw new Error(`Entry "${id}" not found in table "${tableSlug}"`) } } - const result = await softDeleteDataRowMany(db, ids, null) + const result = await softDeleteDataRowMany(db, MAIN_SCOPE, ids, null) // Published rows' routes were retracted — one cache invalidation per batch. if (result.publishedDeleted > 0) await bumpPublishVersionSerialized() const actor: PluginActor = { kind: 'plugin', pluginId: msg.pluginId } @@ -483,9 +487,9 @@ async function resolvePageTreeField( entryId: string, fieldId: string, ): Promise<{ row: DataRow; table: DataTable }> { - const row = await getDataRow(db, entryId) + const row = await getDataRow(db, MAIN_SCOPE, entryId) if (!row) throw new Error(`Entry "${entryId}" not found`) - const table = await getDataTable(db, row.tableId) + const table = await getDataTable(db, MAIN_SCOPE, row.tableId) if (!table) throw new Error(`Table for entry "${entryId}" missing`) const field = table.fields.find((f) => f.id === fieldId) if (!field) throw new Error(`Field "${fieldId}" not found on table "${table.slug}"`) @@ -501,7 +505,7 @@ export async function handleContentTreeRead( db: DbClient, ): Promise { const [entryId, fieldId] = msg.args - const tree = await readPageTree(db, entryId, fieldId, { + const tree = await readPageTree(db, MAIN_SCOPE, entryId, fieldId, { assertAccess: (table) => assertContentTableAccess(entry, table.slug, 'read'), }) replyApiOk(msg.pluginId, msg.correlationId, tree) @@ -518,6 +522,7 @@ export async function handleContentTreeMutate( // via `assertAccess`. const { tree, affectedNodeIds } = await mutatePageTree( db, + MAIN_SCOPE, entryId, fieldId, operations, @@ -548,6 +553,7 @@ export async function handleContentTreeReplace( ) const updated = await saveDataRowDraft( db, + MAIN_SCOPE, entryId, { cells: nextCells, slug: row.slug }, null, @@ -569,7 +575,7 @@ export async function handleContentSearch( ): Promise { const [query, limit] = msg.args const allowedSlugs = new Set((entry.manifest.contentAccess ?? []).map((e) => e.table)) - const all = await searchDataRows(db, query, limit) + const all = await searchDataRows(db, MAIN_SCOPE, query, limit) const filtered = all .filter((r) => allowedSlugs.has(r.tableSlug)) .map((r) => ({ @@ -589,12 +595,12 @@ export async function handleContentSnapshot( db: DbClient, ): Promise { const [entryId] = msg.args - const row = await getDataRow(db, entryId) + const row = await getDataRow(db, MAIN_SCOPE, entryId) if (!row) { replyApiOk(msg.pluginId, msg.correlationId, null) return } - const table = await getDataTable(db, row.tableId) + const table = await getDataTable(db, MAIN_SCOPE, row.tableId) if (!table) { replyApiOk(msg.pluginId, msg.correlationId, null) return @@ -614,6 +620,7 @@ export async function handleContentSnapshot( from data_rows join data_row_versions on data_row_versions.id = data_rows.active_version_id where data_rows.id = ${entryId} + and data_rows.branch_id = 'main' and data_rows.deleted_at is null limit 1 ` diff --git a/server/plugins/host/handlers/contentProjection.ts b/server/plugins/host/handlers/contentProjection.ts index 7e619a790..b822f3ca0 100644 --- a/server/plugins/host/handlers/contentProjection.ts +++ b/server/plugins/host/handlers/contentProjection.ts @@ -20,6 +20,7 @@ import type { } from '@core/data/schemas' import type { DbClient } from '../../../db/client' import { getDataTableBySlug, listDataTables } from '../../../repositories/data' +import { MAIN_SCOPE } from '../../../branches/scope' /** * Project the host's full `DataField` union onto the narrowed @@ -180,7 +181,7 @@ export function tableSchema( } export async function buildTableSlugLookup(db: DbClient): Promise> { - const tables = await listDataTables(db) + const tables = await listDataTables(db, MAIN_SCOPE) return new Map(tables.map((t) => [t.id, t.slug])) } @@ -208,7 +209,7 @@ export async function resolveTableBySlug( db: DbClient, slug: string, ): Promise { - const found = await getDataTableBySlug(db, slug) + const found = await getDataTableBySlug(db, MAIN_SCOPE, slug) if (!found) throw new Error(`Content table "${slug}" not found`) return found } diff --git a/server/publish/branchPreview.ts b/server/publish/branchPreview.ts new file mode 100644 index 000000000..f6dd205ea --- /dev/null +++ b/server/publish/branchPreview.ts @@ -0,0 +1,233 @@ +/** + * Branch preview rendering — the public site as a branch's DRAFT would show + * it, for visitors carrying a valid preview cookie. + * + * Mirrors the editor's own runtime preview rather than the publish path: the + * page (or entry template) is composed from the branch's draft rows, loops + * read the branch, runtime scripts are bundled on demand and served from + * memory, CSS is inlined, and no publish hook fires. Nothing here touches + * the published snapshots, the render caches, or the disk slots — a preview + * is a render, never a publish. + * + * Every response is `no-store` and `noindex`, and carries a banner naming + * the branch with an exit link. + */ +import '../../src/modules/base' +import '@core/loops/sources' +import { createHash } from 'node:crypto' +import { registry } from '@core/module-engine' +import { escapeHtml } from '@core/html-sanitize' +import { publishPage, type PublishedRuntimePackageImportmap } from '@core/publisher' +import { composeTemplateChain, isTemplatePage, resolveTemplateChain } from '@core/templates' +import { buildRouteFrame } from '@core/templates/contextFrames' +import type { TemplateRenderDataContext } from '@core/templates/dynamicBindings' +import type { SourceRequestContext } from '@core/loops/types' +import { normalizeRouteBase } from '@core/templates/templateMatching' +import { normalizeSiteRuntimeConfig } from '@core/site-runtime' +import { readFeaturedMediaCell } from '@core/data/cells' +import type { DataRow, DataTable, PublishedDataRow } from '@core/data/schemas' +import type { Page, SiteDocument } from '@core/page-tree' +import { canonicalJson } from '@core/utils/canonicalJson' +import type { DbClient } from '../db/client' +import type { BranchScope } from '../branches/scope' +import { BRANCH_PREVIEW_EXIT_PATH } from '../branches/previewLinks' +import { getBranch } from '../repositories/branches' +import { getDataRowBySlug, listDataTables } from '../repositories/data' +import { getDraftSiteDocument } from '../repositories/publish' +import { collectFrontendInjections, injectFrontendAssets } from './frontendInjections' +import { prefetchLoopData, publishedDataRowToLoopItem } from './loopPrefetch' +import { prefetchMediaAssets } from './mediaPrefetch' +import { contentRouteFromPath, publicSlugFromPath } from './publicRouter' +import { getPublishVersion } from './publishState' +import { buildSiteRuntimeScripts } from './runtime/bundleScripts' +import { ensureRuntimeDependencyCache } from './runtime/dependencyCache' +import { buildRuntimePackageImportmap, serializeImportmapForCsp } from './runtime/packageImportmap' +import { hasPreviewBuild, previewAssetBasePath, rememberPreviewBuild } from './branchPreviewAssets' + +interface ResolvedPreview { + merged: Page + /** The page whose scripts run — the routed page, or the entry template. */ + scriptPage: Page + templateContext: TemplateRenderDataContext +} + +/** + * A draft row in the shape the entry-template renderer expects. Draft rows + * have no published version: the version number is 0 and the publish + * timestamps fall back to the row's own. + */ +async function draftRowAsPublished(db: DbClient, row: DataRow, table: DataTable): Promise { + const featuredMediaId = readFeaturedMediaCell(row.cells) + let featuredMediaPath: string | null = null + if (featuredMediaId) { + const { rows } = await db<{ public_path: string }>` + select public_path from media_assets where id = ${featuredMediaId} limit 1 + ` + featuredMediaPath = rows[0]?.public_path ?? null + } + const publisher = row.publishedBy ?? row.updatedBy + return { + id: row.id, + rowId: row.id, + tableId: table.id, + tableSlug: table.slug, + tableKind: table.kind, + tableRouteBase: table.routeBase, + versionNumber: 0, + cells: row.cells, + slug: row.slug, + featuredMediaId, + featuredMediaPath, + authorUserId: row.authorUserId, + authorName: row.author?.displayName ?? null, + authorRoleSlug: row.author?.roleSlug ?? null, + authorRoleName: row.author?.roleName ?? null, + publishedByUserId: publisher?.id ?? null, + publishedByName: publisher?.displayName ?? null, + publishedByRoleSlug: publisher?.roleSlug ?? null, + publishedByRoleName: publisher?.roleName ?? null, + publishedAt: row.publishedAt ?? row.updatedAt, + createdAt: row.createdAt, + } +} + +async function resolvePreview( + db: DbClient, + scope: BranchScope, + site: SiteDocument, + url: URL, +): Promise { + const slug = publicSlugFromPath(url.pathname) + const page = site.pages.find((candidate) => candidate.slug === slug && !isTemplatePage(candidate)) + if (page) { + const chain = resolveTemplateChain(site, { kind: 'page' }) + return { + merged: composeTemplateChain(chain, { kind: 'page', page }), + scriptPage: page, + templateContext: { entryStack: [], route: buildRouteFrame(url.toString()) }, + } + } + + const route = contentRouteFromPath(url.pathname) + if (!route) return null + const routeBase = normalizeRouteBase(route.tableRouteBase) + const tables = await listDataTables(db, scope) + const table = tables.find((candidate) => normalizeRouteBase(candidate.routeBase) === routeBase) + if (!table) return null + const row = await getDataRowBySlug(db, scope, table.id, route.rowSlug) + if (!row) return null + + const chain = resolveTemplateChain(site, { kind: 'entry', tableSlug: table.slug }) + if (chain.length === 0) return null + const merged = composeTemplateChain(chain, { kind: 'entry' }) + if (typeof row.cells.title === 'string') merged.title = row.cells.title + const published = await draftRowAsPublished(db, row, table) + return { + merged, + scriptPage: chain[chain.length - 1] ?? merged, + templateContext: { + entryStack: [publishedDataRowToLoopItem(published)], + route: buildRouteFrame(url.toString()), + }, + } +} + +/** + * Bundle the page's runtime scripts, reusing a build whose inputs are + * unchanged. The build id hashes the site runtime config plus the page, so + * editing a script on the branch mints a new build on the next view. + */ +async function buildPreviewRuntime(site: SiteDocument, page: Page) { + const runtime = normalizeSiteRuntimeConfig(site.runtime) + const dependencyCache = Object.keys(runtime.dependencyLock.packages).length > 0 + ? await ensureRuntimeDependencyCache(runtime.dependencyLock) + : undefined + const buildId = createHash('sha256') + .update(canonicalJson({ runtime: site.runtime, page, lock: dependencyCache?.hash ?? null })) + .digest('hex') + .slice(0, 24) + const assetBasePath = previewAssetBasePath(buildId) + const build = await buildSiteRuntimeScripts({ + site, + page, + target: 'publish', + assetBasePath, + dependencyCache, + }) + if (!hasPreviewBuild(buildId)) rememberPreviewBuild(buildId, build.files) + + let runtimePackageImportmap: PublishedRuntimePackageImportmap | undefined + if (dependencyCache) { + const built = await buildRuntimePackageImportmap(runtime.dependencyLock, dependencyCache) + if (built) { + const serialized = await serializeImportmapForCsp(built.importmap) + runtimePackageImportmap = { body: serialized.body, sha256: serialized.sha256 } + } + } + return { runtimeAssets: build.runtimeAssets, runtimePackageImportmap } +} + +function previewBanner(branchName: string): string { + return ( + `
` + + `Previewing branch ${escapeHtml(branchName)} — not live` + + `Exit preview` + + `
` + ) +} + +/** + * Render `url` from the branch's draft, or null when the branch has nothing + * at that path (the dispatcher then serves the 404 page). + */ +export async function renderBranchPreview( + db: DbClient, + branchId: string, + url: URL, +): Promise { + const scope: BranchScope = { branchId } + const [branch, site] = await Promise.all([getBranch(db, branchId), getDraftSiteDocument(db, scope)]) + if (!branch || !site) return null + const resolved = await resolvePreview(db, scope, site, url) + if (!resolved) return null + + const { merged, scriptPage, templateContext } = resolved + const { runtimeAssets, runtimePackageImportmap } = await buildPreviewRuntime(site, scriptPage) + // A preview is one uncached render with the request in hand, so + // request-dependent nodes resolve inline instead of becoming holes that + // the hole endpoint would hydrate from main's published data. + const segments = url.pathname.split('/').filter(Boolean) + const request: SourceRequestContext = { + query: Object.fromEntries(url.searchParams), + path: url.pathname, + slug: segments.length > 0 ? decodeURIComponent(segments[segments.length - 1]!) : null, + cookies: {}, + } + const loopData = await prefetchLoopData(merged, site, db, url, { branchId, request }) + const mediaAssets = await prefetchMediaAssets(merged, site, registry, db, { templateContext, loopData }) + const rendered = publishPage(merged, site, registry, { + templateContext, + runtimeAssets, + runtimePackageImportmap, + loopData, + mediaAssets, + dynamicNodes: 'inline', + publishVersion: getPublishVersion(), + }) + const withFrontend = injectFrontendAssets(rendered.html, await collectFrontendInjections(db)) + const banner = previewBanner(branch.name) + const html = withFrontend.includes('') + ? withFrontend.replace('', `${banner}`) + : `${withFrontend}${banner}` + + return new Response(html, { + headers: { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'no-store', + 'x-robots-tag': 'noindex', + }, + }) +} diff --git a/server/publish/branchPreviewAssets.ts b/server/publish/branchPreviewAssets.ts new file mode 100644 index 000000000..6fc887016 --- /dev/null +++ b/server/publish/branchPreviewAssets.ts @@ -0,0 +1,54 @@ +/** + * Runtime script bundles built for branch previews. + * + * A published page's scripts live on disk (the active slot) or in the + * database; a branch preview builds its bundles on demand and keeps them + * here, in memory, under `/_instatic/assets/preview//…`. The build id + * is a hash of everything the bundle depends on, so repeated views of the + * same branch state reuse one build, and a changed script mints a new one. + * Bounded: the oldest builds are dropped past the cap. + */ +import type { BuiltRuntimeAssetFile } from './runtime/bundleScripts' + +export const BRANCH_PREVIEW_ASSET_PREFIX = '/_instatic/assets/preview/' +const MAX_BUILDS = 32 + +interface PreviewAsset { + contentType: string + bytes: Uint8Array +} + +/** Insertion-ordered: the first key is the oldest build. */ +const builds = new Map>() + +export function previewAssetBasePath(buildId: string): string { + return `${BRANCH_PREVIEW_ASSET_PREFIX}${buildId}/` +} + +export function hasPreviewBuild(buildId: string): boolean { + return builds.has(buildId) +} + +export function rememberPreviewBuild(buildId: string, files: readonly BuiltRuntimeAssetFile[]): void { + const assets = new Map() + for (const file of files) assets.set(file.publicPath, { contentType: file.contentType, bytes: file.bytes }) + builds.delete(buildId) + builds.set(buildId, assets) + while (builds.size > MAX_BUILDS) { + const oldest = builds.keys().next().value + if (oldest === undefined) break + builds.delete(oldest) + } +} + +/** A preview asset by its public path, or null when no live build serves it. */ +export function readPreviewAsset(publicPath: string): PreviewAsset | null { + if (!publicPath.startsWith(BRANCH_PREVIEW_ASSET_PREFIX)) return null + const buildId = publicPath.slice(BRANCH_PREVIEW_ASSET_PREFIX.length).split('/')[0] + return builds.get(buildId ?? '')?.get(publicPath) ?? null +} + +/** Test seam. */ +export function resetPreviewBuilds(): void { + builds.clear() +} diff --git a/server/publish/contentEvents.ts b/server/publish/contentEvents.ts index 551a062c9..c0d2c4231 100644 --- a/server/publish/contentEvents.ts +++ b/server/publish/contentEvents.ts @@ -1,27 +1,27 @@ /** - * Centralised emit helpers for the `content.entry.*` plugin event channel. + * Content entry lifecycle events — the plugin-facing `content.entry.*` + * hooks, emitted by every admin/plugin surface that creates, updates, or + * deletes a data row, plus the `content.entry.cells` filter that lets + * plugins normalize cells before persistence. * - * Every code path that creates / updates / deletes a `data_row` should fire - * the matching event so plugin listeners (SEO assistants, translators, search - * indexers, etc.) can react. The payload carries an `actor` field so plugins - * can avoid feedback loops on their own writes. - * - * Repository functions stay pure (no hook bus coupling); call sites in - * `server/handlers/cms/*` and the publish-scheduler call these helpers - * immediately after a successful mutation. + * Hooks describe the LIVE site: a write on a branch is invisible to plugins + * until it is merged into main, so every emitter is a no-op off `main`. The + * lookups below therefore only ever address main rows, whose physical and + * logical ids coincide. */ - import type { ContentEntryActor } from '@core/plugin-sdk' import { hookBus } from '@core/plugins/hookBus' import type { DbClient } from '../db/client' +import { isMainScope, type BranchScope } from '../branches/scope' -/** Look up the table slug for a row id — needed to populate the event payload. */ +/** Look up the table slug for a main row id — needed to populate the event payload. */ async function resolveTableSlug(db: DbClient, rowId: string): Promise { const { rows } = await db<{ slug: string }>` select data_tables.slug from data_rows join data_tables on data_tables.id = data_rows.table_id where data_rows.id = ${rowId} + and data_rows.branch_id = 'main' limit 1 ` return rows[0]?.slug ?? null @@ -29,9 +29,11 @@ async function resolveTableSlug(db: DbClient, rowId: string): Promise { + if (!isMainScope(scope)) return const tableSlug = await resolveTableSlug(db, rowId) if (!tableSlug) return await hookBus.emit('content.entry.created', { tableSlug, entryId: rowId, actor }) @@ -39,10 +41,12 @@ export async function emitContentEntryCreated( export async function emitContentEntryUpdated( db: DbClient, + scope: BranchScope, rowId: string, changedFieldIds: string[], actor: ContentEntryActor, ): Promise { + if (!isMainScope(scope)) return const tableSlug = await resolveTableSlug(db, rowId) if (!tableSlug) return await hookBus.emit('content.entry.updated', { @@ -55,9 +59,11 @@ export async function emitContentEntryUpdated( export async function emitContentEntryDeleted( db: DbClient, + scope: BranchScope, rowId: string, actor: ContentEntryActor, ): Promise { + if (!isMainScope(scope)) return const tableSlug = await resolveTableSlug(db, rowId) if (!tableSlug) return await hookBus.emit('content.entry.deleted', { tableSlug, entryId: rowId, actor }) diff --git a/server/publish/loopPrefetch.ts b/server/publish/loopPrefetch.ts index f40a66039..53a22de2f 100644 --- a/server/publish/loopPrefetch.ts +++ b/server/publish/loopPrefetch.ts @@ -223,7 +223,13 @@ function readPageNumber(url: URL | undefined, loopNodeId: string): number { async function resolveOneLoop( node: PageNode, source: PrefetchedLoopEntitySource, - ctx: { db: DbClient; site: SiteDocument; url?: URL; request?: SourceRequestContext }, + ctx: { + db: DbClient + site: SiteDocument + url?: URL + request?: SourceRequestContext + branchId?: string + }, ): Promise { const props = readLoopProps(node) const pageNumber = props.pagination === 'infinite' ? readPageNumber(ctx.url, node.id) : 1 @@ -246,6 +252,7 @@ async function resolveOneLoop( // Request context — present only when rendering inside a Layer C hole. // Built-in publish-time sources ignore it. request: ctx.request, + branchId: ctx.branchId, } try { @@ -282,6 +289,8 @@ export async function prefetchLoopData( request?: SourceRequestContext /** Limit the walk to a subtree (the hole node id). Defaults to page root. */ rootNodeId?: string + /** Branch whose rows loops read; absent means main (publishing, public routes). */ + branchId?: string }, ): Promise { const nodes = collectLoopNodes(page, site, options?.rootNodeId) @@ -302,6 +311,7 @@ export async function prefetchLoopData( site, url, request: options?.request, + branchId: options?.branchId, }) return [node.id, data] as [string, ResolvedLoopData] }), diff --git a/server/publish/publicRouter.ts b/server/publish/publicRouter.ts index de02b0339..15655dcc0 100644 --- a/server/publish/publicRouter.ts +++ b/server/publish/publicRouter.ts @@ -90,7 +90,7 @@ import { canonicalRenderQuery } from './loopPrefetch' * * Shared with the loop runtime so per-page slug resolution stays consistent. */ -function publicSlugFromPath(pathname: string): string { +export function publicSlugFromPath(pathname: string): string { const trimmed = pathname.replace(/^\/+|\/+$/g, '') return trimmed === '' ? 'index' : trimmed } @@ -101,7 +101,7 @@ function publicSlugFromPath(pathname: string): string { * have at least two segments — the caller should treat those as * "not a content-row URL" and move on. */ -function contentRouteFromPath(pathname: string): { tableRouteBase: string; rowSlug: string } | null { +export function contentRouteFromPath(pathname: string): { tableRouteBase: string; rowSlug: string } | null { const parts = pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean) if (parts.length < 2) return null return { diff --git a/server/publish/publicRoutes.ts b/server/publish/publicRoutes.ts new file mode 100644 index 000000000..aedc60a77 --- /dev/null +++ b/server/publish/publicRoutes.ts @@ -0,0 +1,85 @@ +/** + * Public site routes — the tail of the dispatcher: the published page (or a + * branch preview of it), the first-run redirect, and the designed 404 page. + * Split out of `server/router.ts` so the dispatcher stays a route table. + */ +import type { ServerRuntime } from '../serverRuntime' +import { + BRANCH_PREVIEW_EXIT_PATH, + clearPreviewCookie, + previewCookie, + previewTokenFromPath, + resolvePreviewCookie, + resolvePreviewToken, +} from '../branches/previewLinks' +import { getSetupStatusCached } from '../repositories/setup' +import { setCookieHeader } from '../http' +import { renderBranchPreview } from './branchPreview' +import { renderNotFoundResponse, renderPublicResolution } from './publicRouter' + +/** + * Single entry for every visitor-facing HTML URL — stand-alone published + * pages (`/about`), content rows rendered through their postType's entry + * template (`/posts/hello-world`), and row-slug redirects. + * + * Resolution + render live in `server/publish/publicRouter.ts`. + * `renderPublicResolution` handles the full request: Layer A disk + * fast-path (pre-rendered static artefacts via `readArtefact`), then + * `resolvePublicRoute`, then the live renderer + `applyPublishedHtmlPipeline`. + */ +export async function tryServePublicRoute(req: Request, runtime: ServerRuntime, url: URL, _pathname: string): Promise { + if (req.method !== 'GET') return null + // A visitor holding a live preview cookie sees the branch's draft instead + // of the published site. A missing route on the branch falls through to + // the 404 page, never to main's published page at that path. + const previewBranchId = await resolvePreviewCookie(req, runtime.db) + if (previewBranchId) return await renderBranchPreview(runtime.db, previewBranchId, url) + return await renderPublicResolution(runtime.db, url, runtime.uploadsDir) +} + +/** + * Enter or leave a branch preview. Entering validates the token, sets the + * cookie, and lands on the site root; a dead token clears any stale cookie + * and lands on the root as an ordinary visitor. Leaving clears the cookie. + */ +export async function tryServeBranchPreviewLink(req: Request, runtime: ServerRuntime, _url: URL, pathname: string): Promise { + if (req.method !== 'GET') return null + if (pathname === BRANCH_PREVIEW_EXIT_PATH) { + return setCookieHeader(redirectToRoot(), clearPreviewCookie(req)) + } + const token = previewTokenFromPath(pathname) + if (!token) return null + const branchId = await resolvePreviewToken(runtime.db, token) + return setCookieHeader(redirectToRoot(), branchId ? previewCookie(req, token) : clearPreviewCookie(req)) +} + +function redirectToRoot(): Response { + return new Response(null, { status: 302, headers: { location: '/', 'cache-control': 'no-store' } }) +} + +/** + * On a fresh install with no admin user yet, bounce the visitor to /admin so + * they land in the setup wizard instead of seeing a confusing 404. Returns + * null when the install is already past setup. + */ +export async function trySetupRedirect(req: Request, runtime: ServerRuntime, _url: URL, _pathname: string): Promise { + if (req.method !== 'GET') return null + // Sticky memo: once setup completes, this stops querying. Without it every + // unmatched GET (bot probes, 404s) paid two COUNT queries forever. + const setupStatus = await getSetupStatusCached(runtime.db) + return setupStatus.needsSetup + ? new Response(null, { status: 302, headers: { location: '/admin' } }) + : null +} + +/** + * Last route before the dispatcher's bare JSON 404: serve the site's designed + * 404 page (the `notFound` template) for any GET no other route claimed. + * Namespaced prefixes (`/admin/api/*`, `/_instatic/*`, `/uploads/*`) never + * reach here — they absorb their namespace and emit their own 404s. Returns + * null (→ JSON 404) when the published site has no notFound template. + */ +export async function tryServeNotFoundPage(req: Request, runtime: ServerRuntime, url: URL, _pathname: string): Promise { + if (req.method !== 'GET') return null + return await renderNotFoundResponse(runtime.db, url, runtime.uploadsDir) +} diff --git a/server/publish/publishScheduler.ts b/server/publish/publishScheduler.ts index f1998a7b0..28435b056 100644 --- a/server/publish/publishScheduler.ts +++ b/server/publish/publishScheduler.ts @@ -37,6 +37,7 @@ import { cancelScheduledPublish, listDuePublishSchedules, } from '../repositories/data/rows' +import { MAIN_SCOPE } from '../branches/scope' // --------------------------------------------------------------------------- // Tunables @@ -115,12 +116,12 @@ async function fireOne(db: DbClient, rowId: string, uploadsDir?: string): Promis // The `published_by_user_id` column lands as null which downstream // UI renders as "Scheduled publish" instead of a user attribution. await publishDataRow(db, rowId, null, uploadsDir) - await emitContentEntryUpdated(db, rowId, ['status'], { kind: 'system' }) + await emitContentEntryUpdated(db, MAIN_SCOPE, rowId, ['status'], { kind: 'system' }) } catch (err) { console.error(`[publish-scheduler] failed to publish row ${rowId}:`, err) // Revert to draft so the row stops being selected on subsequent // ticks. Operator sees it back in drafts and retries manually. - await cancelScheduledPublish(db, rowId, null).catch((cancelErr) => { + await cancelScheduledPublish(db, MAIN_SCOPE, rowId, null).catch((cancelErr) => { console.error(`[publish-scheduler] failed to revert row ${rowId} after publish error:`, cancelErr) }) } diff --git a/server/publish/publishSite.ts b/server/publish/publishSite.ts index cf4901610..646bffd53 100644 --- a/server/publish/publishSite.ts +++ b/server/publish/publishSite.ts @@ -52,6 +52,7 @@ import { buildPublishedSiteCssBundle } from './siteCssBundle' import { bakePublishedDataRowArtefacts } from './bakeDataRows' import { bumpPublishVersion, getPublishVersion, withPublishLock } from './publishState' import { runPublishFlush } from './publishFlush' +import { MAIN_SCOPE } from '../branches/scope' interface PublishResult { publishedPages: number @@ -104,7 +105,7 @@ async function publishDraftSiteLocked( // write (autosaves, row publishes) behind it. `withPublishLock` already // serializes publishes, and version numbers are only allocated by publish // paths under that same lock, so reading outside the transaction is stable. - const site = await getDraftSiteDocument(db) + const site = await getDraftSiteDocument(db, MAIN_SCOPE) if (!site) throw new Error('draft site not found') const runtime = normalizeSiteRuntimeConfig(site.runtime) diff --git a/server/publish/republish.ts b/server/publish/republish.ts index 93262d76e..8d429b27c 100644 --- a/server/publish/republish.ts +++ b/server/publish/republish.ts @@ -80,7 +80,8 @@ export async function republishAllPages(db: DbClient): Promise { const { rows } = await db<{ id: string }>` select id from data_rows - where table_id = 'pages' + where branch_id = 'main' + and table_id = 'pages' and status = 'published' and deleted_at is null order by created_at asc diff --git a/server/publish/runtime/previewRuntime.ts b/server/publish/runtime/previewRuntime.ts index 1a06292bb..ca77e9836 100644 --- a/server/publish/runtime/previewRuntime.ts +++ b/server/publish/runtime/previewRuntime.ts @@ -39,6 +39,8 @@ interface RuntimePreviewDocumentInput { * loops emit a "no resolved data" comment. */ db?: DbClient + /** Branch whose rows loops read (see `@core/branches`); absent means main. */ + branchId?: string } interface RuntimePreviewDocumentResult extends SiteRuntimeBuildResult { @@ -77,7 +79,7 @@ export async function buildRuntimePreviewDocument( } } const loopData = input.db - ? await prefetchLoopData(input.page, input.site, input.db) + ? await prefetchLoopData(input.page, input.site, input.db, undefined, { branchId: input.branchId }) : undefined const mediaAssets = input.db ? await prefetchMediaAssets(input.page, input.site, input.registry, input.db, { diff --git a/server/repositories/audit.ts b/server/repositories/audit.ts index f56a9a4b6..53bbb5ed9 100644 --- a/server/repositories/audit.ts +++ b/server/repositories/audit.ts @@ -32,6 +32,15 @@ const AuditActionSchema = Type.Union([ Type.Literal('data.row.move'), Type.Literal('data.author.assign'), Type.Literal('publish'), + // Branches — see `docs/features/branches.md`. + Type.Literal('branch.create'), + Type.Literal('branch.rename'), + Type.Literal('branch.delete'), + Type.Literal('branch.merge'), + Type.Literal('branch.update'), + Type.Literal('branch.preview.share'), + Type.Literal('branch.preview.revoke'), + Type.Literal('version.restore'), Type.Literal('plugin.install'), Type.Literal('plugin.update'), Type.Literal('plugin.enable'), diff --git a/server/repositories/branchBases.ts b/server/repositories/branchBases.ts new file mode 100644 index 000000000..847ef5a54 --- /dev/null +++ b/server/repositories/branchBases.ts @@ -0,0 +1,69 @@ +/** + * Branch bases — `site_branch_bases`: for every entity a branch shares with + * its base, what that entity looked like when the two last agreed (fork, or + * the latest merge/update). A base is the third side of the three-way merge; + * the hash gives cheap "did this side move" checks, the content gives the + * field-level merge. + */ +import type { DbClient } from '../db/client' +import type { BranchEntityKind } from '../branches/contentHash' + +export interface BranchBase { + kind: BranchEntityKind + logicalId: string + contentHash: string + content: unknown +} + +interface BranchBaseRow { + kind: BranchEntityKind + logical_id: string + content_hash: string + content_json: unknown +} + +export async function listBranchBases(db: DbClient, branchId: string): Promise { + const { rows } = await db` + select kind, logical_id, content_hash, content_json + from site_branch_bases + where branch_id = ${branchId} + ` + return rows.map((row) => ({ + kind: row.kind, + logicalId: row.logical_id, + contentHash: row.content_hash, + content: row.content_json, + })) +} + +/** Insert or replace the bases of the given entities. */ +export async function upsertBranchBases( + db: DbClient, + branchId: string, + bases: readonly BranchBase[], +): Promise { + for (const base of bases) { + await db` + insert into site_branch_bases (branch_id, kind, logical_id, content_hash, content_json) + values (${branchId}, ${base.kind}, ${base.logicalId}, ${base.contentHash}, ${base.content}) + on conflict (branch_id, kind, logical_id) do update + set content_hash = excluded.content_hash, + content_json = excluded.content_json + ` + } +} + +export async function deleteBranchBases( + db: DbClient, + branchId: string, + entries: ReadonlyArray<{ kind: BranchEntityKind; logicalId: string }>, +): Promise { + for (const entry of entries) { + await db` + delete from site_branch_bases + where branch_id = ${branchId} + and kind = ${entry.kind} + and logical_id = ${entry.logicalId} + ` + } +} diff --git a/server/repositories/branchPreviews.ts b/server/repositories/branchPreviews.ts new file mode 100644 index 000000000..3d1efb91b --- /dev/null +++ b/server/repositories/branchPreviews.ts @@ -0,0 +1,87 @@ +/** + * Branch preview links — `site_branch_previews`. + * + * One ACTIVE link per branch: issuing a new one revokes the previous, so a + * leaked link is retired by simply sharing again. Only the token's SHA-256 is + * stored; the token itself is shown once, at creation. Rows cascade away + * with their branch. + */ +import { nanoid } from 'nanoid' +import type { DbClient } from '../db/client' +import { isoDate } from '@core/utils/isoDate' + +export interface BranchPreview { + id: string + branchId: string + createdByUserId: string | null + createdAt: string +} + +interface BranchPreviewRow { + id: string + branch_id: string + created_by_user_id: string | null + created_at: string | Date +} + +function mapPreview(row: BranchPreviewRow): BranchPreview { + return { + id: row.id, + branchId: row.branch_id, + createdByUserId: row.created_by_user_id, + createdAt: isoDate(row.created_at), + } +} + +/** Issue a link for a branch, retiring any earlier active one. */ +export async function createBranchPreview( + db: DbClient, + input: { branchId: string; tokenHash: string; createdByUserId: string | null }, +): Promise { + return db.transaction(async (tx) => { + await revokeBranchPreviews(tx, input.branchId) + const { rows } = await tx` + insert into site_branch_previews (id, branch_id, token_hash, created_by_user_id) + values (${nanoid()}, ${input.branchId}, ${input.tokenHash}, ${input.createdByUserId}) + returning id, branch_id, created_by_user_id, created_at + ` + return mapPreview(rows[0]) + }) +} + +/** The branch's active link, or null when none is active. */ +export async function getActiveBranchPreview(db: DbClient, branchId: string): Promise { + const { rows } = await db` + select id, branch_id, created_by_user_id, created_at + from site_branch_previews + where branch_id = ${branchId} + and revoked_at is null + order by created_at desc + limit 1 + ` + return rows[0] ? mapPreview(rows[0]) : null +} + +/** The branch an active token grants access to, or null. */ +export async function resolveBranchPreviewToken(db: DbClient, tokenHash: string): Promise { + const { rows } = await db<{ branch_id: string }>` + select branch_id + from site_branch_previews + where token_hash = ${tokenHash} + and revoked_at is null + limit 1 + ` + return rows[0]?.branch_id ?? null +} + +/** Retire every active link of a branch; returns how many were active. */ +export async function revokeBranchPreviews(db: DbClient, branchId: string): Promise { + const { rows } = await db<{ id: string }>` + update site_branch_previews + set revoked_at = current_timestamp + where branch_id = ${branchId} + and revoked_at is null + returning id + ` + return rows.length +} diff --git a/server/repositories/branches.ts b/server/repositories/branches.ts new file mode 100644 index 000000000..9f7b2b41b --- /dev/null +++ b/server/repositories/branches.ts @@ -0,0 +1,105 @@ +/** + * Site branches repository — the `site_branches` registry. + * + * Branch CONTENT lives in the branched tables (`site`, `data_tables`, + * `data_rows`) addressed through `BranchScope`; this module only owns the + * registry rows. Forking, deleting, and merging content is orchestrated by + * `server/branches/`. + */ +import { MAIN_BRANCH_ID, type SiteBranch } from '@core/branches' +import { isoDate } from '@core/utils/isoDate' +import type { DbClient } from '../db/client' + +interface SiteBranchRow { + id: string + name: string + base_branch_id: string | null + created_by_user_id: string | null + created_at: string | Date + updated_at: string | Date +} + +function mapBranch(row: SiteBranchRow): SiteBranch { + return { + id: row.id, + name: row.name, + baseBranchId: row.base_branch_id ?? null, + createdByUserId: row.created_by_user_id ?? null, + createdAt: isoDate(row.created_at), + updatedAt: isoDate(row.updated_at), + } +} + +/** Main first, then newest fork first. */ +export async function listBranches(db: DbClient): Promise { + const { rows } = await db` + select id, name, base_branch_id, created_by_user_id, created_at, updated_at + from site_branches + order by case when id = ${MAIN_BRANCH_ID} then 0 else 1 end, created_at desc + ` + return rows.map(mapBranch) +} + +export async function getBranch(db: DbClient, id: string): Promise { + const { rows } = await db` + select id, name, base_branch_id, created_by_user_id, created_at, updated_at + from site_branches + where id = ${id} + limit 1 + ` + return rows[0] ? mapBranch(rows[0]) : null +} + +export async function branchExists(db: DbClient, id: string): Promise { + if (id === MAIN_BRANCH_ID) return true + const { rows } = await db<{ id: string }>` + select id from site_branches where id = ${id} limit 1 + ` + return rows.length > 0 +} + +export async function insertBranch( + db: DbClient, + input: { id: string; name: string; baseBranchId: string; createdByUserId: string | null }, +): Promise { + const { rows } = await db` + insert into site_branches (id, name, base_branch_id, created_by_user_id) + values (${input.id}, ${input.name}, ${input.baseBranchId}, ${input.createdByUserId}) + returning id, name, base_branch_id, created_by_user_id, created_at, updated_at + ` + return mapBranch(rows[0]) +} + +export async function renameBranch( + db: DbClient, + id: string, + name: string, +): Promise { + const { rows } = await db` + update site_branches + set name = ${name}, + updated_at = current_timestamp + where id = ${id} + and id <> ${MAIN_BRANCH_ID} + returning id, name, base_branch_id, created_by_user_id, created_at, updated_at + ` + return rows[0] ? mapBranch(rows[0]) : null +} + +export async function touchBranch(db: DbClient, id: string): Promise { + await db` + update site_branches + set updated_at = current_timestamp + where id = ${id} + ` +} + +export async function deleteBranchRow(db: DbClient, id: string): Promise { + const { rows } = await db<{ id: string }>` + delete from site_branches + where id = ${id} + and id <> ${MAIN_BRANCH_ID} + returning id + ` + return rows.length > 0 +} diff --git a/server/repositories/collabDocuments.ts b/server/repositories/collabDocuments.ts index 804d97e99..0a786ed70 100644 --- a/server/repositories/collabDocuments.ts +++ b/server/repositories/collabDocuments.ts @@ -9,6 +9,7 @@ * reset deletes the row, so the next open mints a fresh one — which is exactly * what lets both ends refuse a frame from a dead lineage. */ +import { encodeCollabDocId, siteDocId } from '@core/collab' import { placeholder, type DbClient } from '../db/client' export interface StoredCollabDocument { @@ -62,3 +63,21 @@ export async function deleteCollabDocuments( [...docIds], ) } + +/** Every stored doc id of a branch: its shell doc plus one per row doc. */ +export async function listCollabDocumentIdsForBranch( + db: DbClient, + branchId: string, +): Promise { + const rowDocPrefixes = (['page', 'component', 'layout'] as const).map( + (kind) => `${encodeCollabDocId({ kind, branchId, rowId: '' })}%`, + ) + const { rows } = await db<{ doc_id: string }>` + select doc_id from collab_documents + where doc_id = ${siteDocId(branchId)} + or doc_id like ${rowDocPrefixes[0]} + or doc_id like ${rowDocPrefixes[1]} + or doc_id like ${rowDocPrefixes[2]} + ` + return rows.map((row) => row.doc_id) +} diff --git a/server/repositories/data/__tests__/tables.test.ts b/server/repositories/data/__tests__/tables.test.ts index 60fa408bc..219f88d8f 100644 --- a/server/repositories/data/__tests__/tables.test.ts +++ b/server/repositories/data/__tests__/tables.test.ts @@ -10,6 +10,7 @@ import { listDataTables, updateDataTable, } from '../tables' +import { MAIN_SCOPE } from '../../../branches/scope' async function freshDb(): Promise { const db = createSqliteClient(':memory:') @@ -26,7 +27,7 @@ describe('data_tables.system column', () => { it('is not-null with a false default — a custom table reads system=false', async () => { // createDataTable never sets `system`, so it relies on the column default. - const table = await createDataTable(db, { + const table = await createDataTable(db, MAIN_SCOPE, { name: 'Products', slug: 'products', kind: 'data', @@ -35,13 +36,13 @@ describe('data_tables.system column', () => { }) expect(table.system).toBe(false) - const reread = await getDataTable(db, table.id) + const reread = await getDataTable(db, MAIN_SCOPE, table.id) expect(reread?.system).toBe(false) }) it('reads system=true for the seeded system tables', async () => { for (const id of ['pages', 'posts', 'components', 'layouts']) { - const table = await getDataTable(db, id) + const table = await getDataTable(db, MAIN_SCOPE, id) expect(table).not.toBeNull() expect(table?.system).toBe(true) } @@ -58,7 +59,7 @@ describe('data_tables.system column', () => { }) it('list and read agree on system flags', async () => { - const tables = await listDataTables(db) + const tables = await listDataTables(db, MAIN_SCOPE) const systemSlugs = tables.filter((t) => t.system).map((t) => t.slug).sort() expect(systemSlugs).toEqual(['components', 'layouts', 'pages', 'posts']) }) @@ -72,7 +73,7 @@ describe('data_tables.route_base persistence', () => { }) it('preserves an explicitly blank route base on create and read', async () => { - const table = await createDataTable(db, { + const table = await createDataTable(db, MAIN_SCOPE, { name: 'Fee lines', slug: 'fee-lines', kind: 'data', @@ -82,7 +83,7 @@ describe('data_tables.route_base persistence', () => { }) expect(table.routeBase).toBe('') - expect((await getDataTable(db, table.id))?.routeBase).toBe('') + expect((await getDataTable(db, MAIN_SCOPE, table.id))?.routeBase).toBe('') const { rows } = await db<{ route_base: string }>` select route_base from data_tables where id = ${table.id} @@ -91,14 +92,14 @@ describe('data_tables.route_base persistence', () => { }) it('uses the slug fallback only when routeBase is omitted', async () => { - const fallback = await createDataTable(db, { + const fallback = await createDataTable(db, MAIN_SCOPE, { name: 'Work orders', slug: 'work-orders', kind: 'data', singularLabel: 'Work order', pluralLabel: 'Work orders', }) - const explicit = await createDataTable(db, { + const explicit = await createDataTable(db, MAIN_SCOPE, { name: 'Bench notes', slug: 'bench-notes', kind: 'data', @@ -112,7 +113,7 @@ describe('data_tables.route_base persistence', () => { }) it('preserves an explicitly blank route base on update', async () => { - const table = await createDataTable(db, { + const table = await createDataTable(db, MAIN_SCOPE, { name: 'Questions', slug: 'questions', kind: 'data', @@ -121,14 +122,14 @@ describe('data_tables.route_base persistence', () => { pluralLabel: 'Questions', }) - const updated = await updateDataTable(db, table.id, { routeBase: ' ' }) + const updated = await updateDataTable(db, MAIN_SCOPE, table.id, { routeBase: ' ' }) expect(updated?.routeBase).toBe('') - expect((await getDataTable(db, table.id))?.routeBase).toBe('') + expect((await getDataTable(db, MAIN_SCOPE, table.id))?.routeBase).toBe('') }) it('preserves an explicitly blank route base on the import insertion path', async () => { - const inserted = await insertDataTableIfAbsent(db, { + const inserted = await insertDataTableIfAbsent(db, MAIN_SCOPE, { id: 'imported-custody-stages', name: 'Custody stages', slug: 'custody-stages', @@ -139,6 +140,6 @@ describe('data_tables.route_base persistence', () => { }) expect(inserted).toBe(true) - expect((await getDataTable(db, 'imported-custody-stages'))?.routeBase).toBe('') + expect((await getDataTable(db, MAIN_SCOPE, 'imported-custody-stages'))?.routeBase).toBe('') }) }) diff --git a/server/repositories/data/index.ts b/server/repositories/data/index.ts index 3e55ba96c..ff1c48004 100644 --- a/server/repositories/data/index.ts +++ b/server/repositories/data/index.ts @@ -26,6 +26,7 @@ export { createDataTable, updateDataTable, softDeleteDataTable, + restoreDataTable, } from './tables' export { @@ -65,4 +66,9 @@ export { getDataRowRedirectByRoute, } from './publish' -export { nextDataRowVersionNumber } from './versions' +export { + nextDataRowVersionNumber, + listDataRowVersions, + getDataRowVersion, + type DataRowVersionSummary, +} from './versions' diff --git a/server/repositories/data/publish.ts b/server/repositories/data/publish.ts index 9575e9566..1e58879f1 100644 --- a/server/repositories/data/publish.ts +++ b/server/repositories/data/publish.ts @@ -25,6 +25,7 @@ */ import { nanoid } from 'nanoid' import { placeholder, type DbClient } from '../../db/client' +import { MAIN_SCOPE } from '../../branches/scope' import { userRefColumns, userRefJoin } from './shared' import type { DataRow, DataRowVersion, DataRowRedirect, PublishedDataRow } from '@core/data/schemas' import { normalizeRouteBase } from '@core/templates/templateMatching' @@ -142,8 +143,10 @@ export async function persistDataRowPublish( */ publisherUserId: string | null, ): Promise { + // Publishing only exists on `main`: versions and redirects reference main + // rows, whose physical and logical ids coincide. return db.transaction(async (tx) => { - const row = await getDataRow(tx, rowId) + const row = await getDataRow(tx, MAIN_SCOPE, rowId) if (!row) throw new Error('data row not found') const previousRoute = await readPreviousPublishedRoute(tx, rowId) @@ -193,7 +196,7 @@ export async function persistDataRowPublish( ` } - const publishedRow = await getDataRow(tx, row.id) + const publishedRow = await getDataRow(tx, MAIN_SCOPE, row.id) if (!publishedRow) throw new Error('data row could not be re-read after publish') const publishedAt = publishedRow.publishedAt ?? new Date().toISOString() diff --git a/server/repositories/data/rows/__tests__/apply.test.ts b/server/repositories/data/rows/__tests__/apply.test.ts index a7dfc328f..271c6d947 100644 --- a/server/repositories/data/rows/__tests__/apply.test.ts +++ b/server/repositories/data/rows/__tests__/apply.test.ts @@ -5,6 +5,7 @@ import { runMigrations } from '../../../../db/runMigrations' import type { DbClient } from '../../../../db/client' import { applyDataRowChanges } from '../apply' import { allocateSiteSeq } from '../../../syncSequence' +import { MAIN_SCOPE } from '../../../../branches/scope' const USER_ID = 'user-owner' @@ -51,7 +52,7 @@ describe('applyDataRowChanges', () => { // The components scenario: delete VC "Button", create a new VC also named // "Button" — same derived slug — in one save. await seedRow(db, 'vc-old', 'button') - const { deletedPublished } = await applyDataRowChanges(db, { + const { deletedPublished } = await applyDataRowChanges(db, MAIN_SCOPE, { tableId: 'components', writes: [{ id: 'vc-new', cells: { name: 'Button' }, slug: 'button' }], deleteIds: new Set(['vc-old']), @@ -70,7 +71,7 @@ describe('applyDataRowChanges', () => { await seedRow(db, 'a', 'one') await seedRow(db, 'b', 'two') await seedRow(db, 'c', 'three') - await applyDataRowChanges(db, { + await applyDataRowChanges(db, MAIN_SCOPE, { tableId: 'components', writes: [ { id: 'a', cells: { name: 'a' }, slug: 'two' }, @@ -92,7 +93,7 @@ describe('applyDataRowChanges', () => { await seedRow(db, 'pub', 'pub-slug', 'published') await seedRow(db, 'draft', 'draft-slug', 'draft') - const first = await applyDataRowChanges(db, { + const first = await applyDataRowChanges(db, MAIN_SCOPE, { tableId: 'components', writes: [], deleteIds: new Set(['draft']), @@ -101,7 +102,7 @@ describe('applyDataRowChanges', () => { }) expect(first.deletedPublished).toBe(false) - const second = await applyDataRowChanges(db, { + const second = await applyDataRowChanges(db, MAIN_SCOPE, { tableId: 'components', writes: [], deleteIds: new Set(['pub']), @@ -115,7 +116,7 @@ describe('applyDataRowChanges', () => { it('revives a soft-deleted row when its id is re-submitted (undo of a delete)', async () => { await seedRow(db, 'vc-a', 'card') - await applyDataRowChanges(db, { + await applyDataRowChanges(db, MAIN_SCOPE, { tableId: 'components', writes: [], deleteIds: new Set(['vc-a']), @@ -126,7 +127,7 @@ describe('applyDataRowChanges', () => { // …then the client undoes the delete and saves the same id again. A // plain insert would hit the soft-deleted row's primary key. - await applyDataRowChanges(db, { + await applyDataRowChanges(db, MAIN_SCOPE, { tableId: 'components', writes: [{ id: 'vc-a', cells: { name: 'Card v2' }, slug: 'card-v2' }], deleteIds: new Set(), @@ -145,7 +146,7 @@ describe('applyDataRowChanges', () => { await seedRow(db, 'known', 'known') await seedRow(db, 'sibling-created', 'sibling') - await applyDataRowChanges(db, { + await applyDataRowChanges(db, MAIN_SCOPE, { tableId: 'components', writes: [{ id: 'known', cells: { name: 'known v2' }, slug: 'known' }], deleteIds: new Set(), @@ -160,7 +161,7 @@ describe('applyDataRowChanges', () => { it('deleting an unknown or already-deleted id is an idempotent no-op', async () => { await seedRow(db, 'vc-a', 'card') - const first = await applyDataRowChanges(db, { + const first = await applyDataRowChanges(db, MAIN_SCOPE, { tableId: 'components', writes: [], deleteIds: new Set(['vc-a', 'never-existed']), @@ -170,7 +171,7 @@ describe('applyDataRowChanges', () => { expect(first.deletedPublished).toBe(false) // Re-shipping the same delete (retry after a failed save) is harmless. - const second = await applyDataRowChanges(db, { + const second = await applyDataRowChanges(db, MAIN_SCOPE, { tableId: 'components', writes: [], deleteIds: new Set(['vc-a']), @@ -184,7 +185,7 @@ describe('applyDataRowChanges', () => { await seedRow(db, 'keep', 'keep') await seedRow(db, 'gone', 'gone') - await applyDataRowChanges(db, { + await applyDataRowChanges(db, MAIN_SCOPE, { tableId: 'components', writes: [ { id: 'keep', cells: { name: 'keep v2' }, slug: 'keep' }, @@ -224,7 +225,7 @@ describe('applyDataRowChanges — table scoping', () => { ` await seedRow(db, 'vc-a', 'card') - const { deletedPublished } = await applyDataRowChanges(db, { + const { deletedPublished } = await applyDataRowChanges(db, MAIN_SCOPE, { tableId: 'components', writes: [], deleteIds: new Set(['post-row', 'vc-a']), diff --git a/server/repositories/data/rows/__tests__/filter.test.ts b/server/repositories/data/rows/__tests__/filter.test.ts index f5b418c75..b25b92bcc 100644 --- a/server/repositories/data/rows/__tests__/filter.test.ts +++ b/server/repositories/data/rows/__tests__/filter.test.ts @@ -4,6 +4,7 @@ import { sqliteMigrations } from '../../../../db/migrations-sqlite' import { runMigrations } from '../../../../db/runMigrations' import type { DbClient } from '../../../../db/client' import { listDataRowsWithFilter } from '../filter' +import { MAIN_SCOPE } from '../../../../branches/scope' /** * Wrap a DbClient so every `db.unsafe()` call is counted. The hydrated SELECT @@ -77,13 +78,13 @@ describe('listDataRowsWithFilter', () => { }) it('returns live rows in default updated_at-desc order, excluding soft-deleted', async () => { - const { rows, totalCount } = await listDataRowsWithFilter(db, 'posts') + const { rows, totalCount } = await listDataRowsWithFilter(db, MAIN_SCOPE, 'posts') expect(rows.map((r) => r.id)).toEqual(['delta', 'gamma', 'beta', 'alpha']) expect(totalCount).toBe(4) }) it('hydrates the author user reference', async () => { - const { rows } = await listDataRowsWithFilter(db, 'posts', { filter: { title: 'Alpha' } }) + const { rows } = await listDataRowsWithFilter(db, MAIN_SCOPE, 'posts', { filter: { title: 'Alpha' } }) expect(rows).toHaveLength(1) expect(rows[0].id).toBe('alpha') expect(rows[0].authorUserId).toBe(USER_ID) @@ -93,38 +94,38 @@ describe('listDataRowsWithFilter', () => { }) it('paginates with limit + offset while preserving order', async () => { - const { rows, totalCount } = await listDataRowsWithFilter(db, 'posts', { limit: 2, offset: 1 }) + const { rows, totalCount } = await listDataRowsWithFilter(db, MAIN_SCOPE, 'posts', { limit: 2, offset: 1 }) expect(rows.map((r) => r.id)).toEqual(['gamma', 'beta']) expect(totalCount).toBe(4) }) it('filters by status', async () => { - const { rows, totalCount } = await listDataRowsWithFilter(db, 'posts', { status: 'published' }) + const { rows, totalCount } = await listDataRowsWithFilter(db, MAIN_SCOPE, 'posts', { status: 'published' }) expect(rows.map((r) => r.id)).toEqual(['delta', 'gamma', 'alpha']) expect(totalCount).toBe(3) }) it('filters by a cells_json field (where condition)', async () => { - const { rows, totalCount } = await listDataRowsWithFilter(db, 'posts', { filter: { title: 'Gamma' } }) + const { rows, totalCount } = await listDataRowsWithFilter(db, MAIN_SCOPE, 'posts', { filter: { title: 'Gamma' } }) expect(rows.map((r) => r.id)).toEqual(['gamma']) expect(totalCount).toBe(1) }) it('returns an empty result set without error', async () => { - const { rows, totalCount } = await listDataRowsWithFilter(db, 'posts', { filter: { title: 'Nonexistent' } }) + const { rows, totalCount } = await listDataRowsWithFilter(db, MAIN_SCOPE, 'posts', { filter: { title: 'Nonexistent' } }) expect(rows).toEqual([]) expect(totalCount).toBe(0) }) it('honors custom orderBy on row-level columns', async () => { - const { rows } = await listDataRowsWithFilter(db, 'posts', { orderBy: { created_at: 'asc' } }) + const { rows } = await listDataRowsWithFilter(db, MAIN_SCOPE, 'posts', { orderBy: { created_at: 'asc' } }) expect(rows.map((r) => r.id)).toEqual(['alpha', 'beta', 'gamma', 'delta']) }) it('issues a bounded number of queries that does NOT scale with row count', async () => { // Small dataset. const small = countingDb(db) - const smallResult = await listDataRowsWithFilter(small.db, 'posts', { limit: 500 }) + const smallResult = await listDataRowsWithFilter(small.db, MAIN_SCOPE, 'posts', { limit: 500 }) expect(smallResult.rows).toHaveLength(4) // Large dataset — many more matching rows. @@ -138,7 +139,7 @@ describe('listDataRowsWithFilter', () => { }) } const big = countingDb(bigDb) - const bigResult = await listDataRowsWithFilter(big.db, 'posts', { limit: 500 }) + const bigResult = await listDataRowsWithFilter(big.db, MAIN_SCOPE, 'posts', { limit: 500 }) expect(bigResult.rows).toHaveLength(50) // Two queries total: one hydrated data page + one count. Crucially the diff --git a/server/repositories/data/rows/__tests__/mutations.test.ts b/server/repositories/data/rows/__tests__/mutations.test.ts index 756560983..424d1bc90 100644 --- a/server/repositories/data/rows/__tests__/mutations.test.ts +++ b/server/repositories/data/rows/__tests__/mutations.test.ts @@ -5,6 +5,7 @@ import { runMigrations } from '../../../../db/runMigrations' import type { DbClient } from '../../../../db/client' import { softDeleteDataRow, upsertDataRowDraft } from '../mutations' import { getDataRow } from '../read' +import { MAIN_SCOPE } from '../../../../branches/scope' const USER_ID = 'user-author' @@ -38,7 +39,7 @@ describe('softDeleteDataRow', () => { }) it('returns the narrow deleted-row summary', async () => { - const result = await softDeleteDataRow(db, 'post-1', USER_ID) + const result = await softDeleteDataRow(db, MAIN_SCOPE, 'post-1', USER_ID) expect(result).not.toBeNull() if (!result) throw new Error('expected a summary') @@ -61,14 +62,14 @@ describe('softDeleteDataRow', () => { }) it('hides the row from the hydrated read afterwards', async () => { - await softDeleteDataRow(db, 'post-1', USER_ID) - expect(await getDataRow(db, 'post-1')).toBeNull() + await softDeleteDataRow(db, MAIN_SCOPE, 'post-1', USER_ID) + expect(await getDataRow(db, MAIN_SCOPE, 'post-1')).toBeNull() }) it('returns null when the row is already gone', async () => { - await softDeleteDataRow(db, 'post-1', USER_ID) - expect(await softDeleteDataRow(db, 'post-1', USER_ID)).toBeNull() - expect(await softDeleteDataRow(db, 'missing', USER_ID)).toBeNull() + await softDeleteDataRow(db, MAIN_SCOPE, 'post-1', USER_ID) + expect(await softDeleteDataRow(db, MAIN_SCOPE, 'post-1', USER_ID)).toBeNull() + expect(await softDeleteDataRow(db, MAIN_SCOPE, 'missing', USER_ID)).toBeNull() }) }) @@ -82,10 +83,11 @@ describe('upsertDataRowDraft', () => { await seedRow(db, 'post-1') await upsertDataRowDraft( db, + MAIN_SCOPE, { id: 'post-1', tableId: 'posts', cells: { title: 'Updated' }, slug: 'updated' }, USER_ID, ) - const row = await getDataRow(db, 'post-1') + const row = await getDataRow(db, MAIN_SCOPE, 'post-1') expect(row?.cells.title).toBe('Updated') expect(row?.slug).toBe('updated') }) @@ -93,10 +95,11 @@ describe('upsertDataRowDraft', () => { it('creates a fresh row when the id is unknown', async () => { await upsertDataRowDraft( db, + MAIN_SCOPE, { id: 'post-new', tableId: 'posts', cells: { title: 'Fresh' }, slug: 'fresh' }, USER_ID, ) - expect((await getDataRow(db, 'post-new'))?.cells.title).toBe('Fresh') + expect((await getDataRow(db, MAIN_SCOPE, 'post-new'))?.cells.title).toBe('Fresh') }) it('RESURRECTS a soft-deleted row instead of hitting its primary key', async () => { @@ -104,16 +107,17 @@ describe('upsertDataRowDraft', () => { // peer restored. getDataRow filters soft-deleted rows, so a plain insert // would conflict on the still-present primary key forever. await seedRow(db, 'post-1') - await softDeleteDataRow(db, 'post-1', USER_ID) - expect(await getDataRow(db, 'post-1')).toBeNull() // soft-deleted, hidden + await softDeleteDataRow(db, MAIN_SCOPE, 'post-1', USER_ID) + expect(await getDataRow(db, MAIN_SCOPE, 'post-1')).toBeNull() // soft-deleted, hidden await upsertDataRowDraft( db, + MAIN_SCOPE, { id: 'post-1', tableId: 'posts', cells: { title: 'Revived' }, slug: 'revived' }, USER_ID, ) - const revived = await getDataRow(db, 'post-1') + const revived = await getDataRow(db, MAIN_SCOPE, 'post-1') expect(revived).not.toBeNull() expect(revived?.cells.title).toBe('Revived') expect(revived?.deletedAt).toBeNull() diff --git a/server/repositories/data/rows/__tests__/read.test.ts b/server/repositories/data/rows/__tests__/read.test.ts index 67b11b125..afe2948a0 100644 --- a/server/repositories/data/rows/__tests__/read.test.ts +++ b/server/repositories/data/rows/__tests__/read.test.ts @@ -6,6 +6,7 @@ import type { DbClient } from '../../../../db/client' import { countDataRows, getDataRow, getDataRowMany } from '../read' import { softDeleteDataRow } from '../mutations' import { getDataTableBySlug } from '../../tables' +import { MAIN_SCOPE } from '../../../../branches/scope' async function freshDb(): Promise { const db = createSqliteClient(':memory:') @@ -34,21 +35,21 @@ describe('getDataRowMany', () => { }) it('returns the same hydrated rows as per-id getDataRow, in one query', async () => { - const many = await getDataRowMany(db, ['post-1', 'post-3']) + const many = await getDataRowMany(db, MAIN_SCOPE, ['post-1', 'post-3']) const byId = new Map(many.map((row) => [row.id, row])) expect(byId.size).toBe(2) - expect(byId.get('post-1')).toEqual((await getDataRow(db, 'post-1')) ?? undefined) - expect(byId.get('post-3')).toEqual((await getDataRow(db, 'post-3')) ?? undefined) + expect(byId.get('post-1')).toEqual((await getDataRow(db, MAIN_SCOPE, 'post-1')) ?? undefined) + expect(byId.get('post-3')).toEqual((await getDataRow(db, MAIN_SCOPE, 'post-3')) ?? undefined) }) it('omits missing and soft-deleted ids instead of throwing', async () => { - await softDeleteDataRow(db, 'post-2') - const many = await getDataRowMany(db, ['post-1', 'post-2', 'nope']) + await softDeleteDataRow(db, MAIN_SCOPE, 'post-2') + const many = await getDataRowMany(db, MAIN_SCOPE, ['post-1', 'post-2', 'nope']) expect(many.map((row) => row.id)).toEqual(['post-1']) }) it('returns [] for an empty id list without touching the db', async () => { - expect(await getDataRowMany(db, [])).toEqual([]) + expect(await getDataRowMany(db, MAIN_SCOPE, [])).toEqual([]) }) }) @@ -57,19 +58,19 @@ describe('countDataRows', () => { const db = await freshDb() await seedRow(db, 'post-1') await seedRow(db, 'post-2') - expect(await countDataRows(db, 'posts')).toBe(2) - await softDeleteDataRow(db, 'post-1') - expect(await countDataRows(db, 'posts')).toBe(1) - expect(await countDataRows(db, 'pages')).toBe(0) + expect(await countDataRows(db, MAIN_SCOPE, 'posts')).toBe(2) + await softDeleteDataRow(db, MAIN_SCOPE, 'post-1') + expect(await countDataRows(db, MAIN_SCOPE, 'posts')).toBe(1) + expect(await countDataRows(db, MAIN_SCOPE, 'pages')).toBe(0) }) }) describe('getDataTableBySlug', () => { it('resolves a seeded system table by slug and misses unknown slugs', async () => { const db = await freshDb() - const posts = await getDataTableBySlug(db, 'posts') + const posts = await getDataTableBySlug(db, MAIN_SCOPE, 'posts') expect(posts?.id).toBe('posts') expect(posts?.system).toBe(true) - expect(await getDataTableBySlug(db, 'no-such-table')).toBeNull() + expect(await getDataTableBySlug(db, MAIN_SCOPE, 'no-such-table')).toBeNull() }) }) diff --git a/server/repositories/data/rows/apply.ts b/server/repositories/data/rows/apply.ts index 31fd29aa5..5dd7f38db 100644 --- a/server/repositories/data/rows/apply.ts +++ b/server/repositories/data/rows/apply.ts @@ -40,7 +40,9 @@ * (the 2026-06-12 audit's critical finding; do not reintroduce). * `applyDataRowChanges` is the standalone wrapper that opens one. */ +import { physicalId } from '@core/branches' import type { DbClient } from '../../../db/client' +import type { BranchScope } from '../../../branches/scope' import { createDataRow, updateDataRowDraftCells, @@ -74,11 +76,12 @@ export interface ApplyDataRowChangesResult { } /** Stamp the sync seq on a row. Deliberately no `deleted_at` filter — soft-deleted rows are stamped too. */ -async function stampDataRowSeq(db: DbClient, rowId: string, seq: number): Promise { +async function stampDataRowSeq(db: DbClient, scope: BranchScope, rowId: string, seq: number): Promise { await db` update data_rows set seq = ${seq} - where id = ${rowId} + where id = ${physicalId(scope.branchId, rowId)} + and branch_id = ${scope.branchId} ` } @@ -89,13 +92,14 @@ async function stampDataRowSeq(db: DbClient, rowId: string, seq: number): Promis */ export async function applyDataRowChangesInTx( tx: DbClient, + scope: BranchScope, { tableId, writes, deleteIds, actorUserId, seq }: ApplyDataRowChangesInput, ): Promise { let deletedPublished = false - const existing = await listDataRowIdSlugs(tx, tableId) + const existing = await listDataRowIdSlugs(tx, scope, tableId) const existingSlugById = new Map(existing.map((r) => [r.id, r.slug])) - const softDeletedIds = new Set(await listSoftDeletedDataRowIds(tx, tableId)) + const softDeletedIds = new Set(await listSoftDeletedDataRowIds(tx, scope, tableId)) // 1. Explicit deletes first — frees the slugs of deleted rows for the // writes below. Deletes are SCOPED TO THIS TABLE: an id that doesn't @@ -104,9 +108,9 @@ export async function applyDataRowChangesInTx( // Already-deleted / unknown ids no-op for the same reason (idempotent). for (const rowId of deleteIds) { if (!existingSlugById.has(rowId)) continue - const deleted = await softDeleteDataRow(tx, rowId, actorUserId, { collabInternal: true }) + const deleted = await softDeleteDataRow(tx, scope, rowId, actorUserId, { collabInternal: true }) if (!deleted) continue - await stampDataRowSeq(tx, rowId, seq) + await stampDataRowSeq(tx, scope, rowId, seq) if (deleted.status === 'published') deletedPublished = true } @@ -120,21 +124,22 @@ export async function applyDataRowChangesInTx( const storedSlug = existingSlugById.get(write.id) if (storedSlug === undefined) continue // created or revived below if (storedSlug === write.slug) { - await updateDataRowDraftCells(tx, write.id, { cells: write.cells, slug: write.slug }, actorUserId) + await updateDataRowDraftCells(tx, scope, write.id, { cells: write.cells, slug: write.slug }, actorUserId) } else { - await updateDataRowDraftCells(tx, write.id, { cells: write.cells, slug: '' }, actorUserId) + await updateDataRowDraftCells(tx, scope, write.id, { cells: write.cells, slug: '' }, actorUserId) parked.push(write) } - await stampDataRowSeq(tx, write.id, seq) + await stampDataRowSeq(tx, scope, write.id, seq) } for (const write of writes) { if (existingSlugById.has(write.id)) continue if (softDeletedIds.has(write.id)) { - await resurrectDataRow(tx, write.id, { cells: write.cells, slug: '' }, actorUserId) + await resurrectDataRow(tx, scope, write.id, { cells: write.cells, slug: '' }, actorUserId) parked.push(write) } else { await createDataRow( tx, + scope, { id: write.id, tableId, cells: write.cells, slug: write.slug }, actorUserId, null, @@ -143,12 +148,12 @@ export async function applyDataRowChangesInTx( { collabInternal: true }, ) } - await stampDataRowSeq(tx, write.id, seq) + await stampDataRowSeq(tx, scope, write.id, seq) } // 3. Final slugs for the parked rows — every old slug is free by now. for (const write of parked) { - await updateDataRowSlug(tx, write.id, write.slug) + await updateDataRowSlug(tx, scope, write.id, write.slug) } return { deletedPublished } @@ -161,15 +166,17 @@ export async function applyDataRowChangesInTx( */ export async function applyDataRowChanges( db: DbClient, + scope: BranchScope, input: ApplyDataRowChangesInput, ): Promise { return serializeCollabAwareWrite(async () => { let result: ApplyDataRowChangesResult = { deletedPublished: false } await db.transaction(async (tx) => { - result = await applyDataRowChangesInTx(tx, input) + result = await applyDataRowChangesInTx(tx, scope, input) }) if (input.writes.length > 0) { notifyRowWrite({ + branchId: scope.branchId, tableId: input.tableId, rowIds: input.writes.map((write) => write.id), kind: 'update', @@ -177,6 +184,7 @@ export async function applyDataRowChanges( } if (input.deleteIds.size > 0) { notifyRowWrite({ + branchId: scope.branchId, tableId: input.tableId, rowIds: [...input.deleteIds], kind: 'delete', diff --git a/server/repositories/data/rows/bulk.ts b/server/repositories/data/rows/bulk.ts index e8022207f..85edd68dd 100644 --- a/server/repositories/data/rows/bulk.ts +++ b/server/repositories/data/rows/bulk.ts @@ -8,6 +8,7 @@ * softDeleteDataRowMany — bulk-soft-delete N rows */ import type { DbClient } from '../../../db/client' +import type { BranchScope } from '../../../branches/scope' import type { DataRow } from '@core/data/schemas' import type { InsertDataRowInput, UpdateDataRowDraftInput } from './mapper' import { createDataRow, saveDataRowDraft, softDeleteDataRow } from './mutations' @@ -21,6 +22,7 @@ import { notifyRowWrite, serializeCollabAwareWrite } from '../../rowWriteEvents' */ export async function createDataRowMany( db: DbClient, + scope: BranchScope, inputs: ReadonlyArray, actorUserId: string | null = null, pluginActorId: string | null = null, @@ -31,6 +33,7 @@ export async function createDataRowMany( for (const input of inputs) { rows.push(await createDataRow( tx, + scope, input, actorUserId, pluginActorId, @@ -40,7 +43,7 @@ export async function createDataRowMany( return rows }) for (const row of created) { - notifyRowWrite({ tableId: row.tableId, rowIds: [row.id], kind: 'create' }) + notifyRowWrite({ branchId: scope.branchId, tableId: row.tableId, rowIds: [row.id], kind: 'create' }) } return created }) @@ -53,6 +56,7 @@ export async function createDataRowMany( */ export async function saveDataRowDraftMany( db: DbClient, + scope: BranchScope, updates: ReadonlyArray<{ id: string; input: UpdateDataRowDraftInput }>, actorUserId: string | null = null, pluginActorId: string | null = null, @@ -63,6 +67,7 @@ export async function saveDataRowDraftMany( for (const { id, input } of updates) { const result = await saveDataRowDraft( tx, + scope, id, input, actorUserId, @@ -74,7 +79,7 @@ export async function saveDataRowDraftMany( return rows }) for (const row of updated) { - notifyRowWrite({ tableId: row.tableId, rowIds: [row.id], kind: 'update' }) + notifyRowWrite({ branchId: scope.branchId, tableId: row.tableId, rowIds: [row.id], kind: 'update' }) } return updated }) @@ -90,6 +95,7 @@ export async function saveDataRowDraftMany( */ export async function softDeleteDataRowMany( db: DbClient, + scope: BranchScope, rowIds: ReadonlyArray, actorUserId: string | null = null, ): Promise<{ deleted: number; publishedDeleted: number }> { @@ -99,6 +105,7 @@ export async function softDeleteDataRowMany( for (const id of rowIds) { const result = await softDeleteDataRow( tx, + scope, id, actorUserId, { collabInternal: true }, @@ -108,7 +115,7 @@ export async function softDeleteDataRowMany( return rows }) for (const row of deletedRows) { - notifyRowWrite({ tableId: row.tableId, rowIds: [row.id], kind: 'delete' }) + notifyRowWrite({ branchId: scope.branchId, tableId: row.tableId, rowIds: [row.id], kind: 'delete' }) } return { deleted: deletedRows.length, diff --git a/server/repositories/data/rows/filter.ts b/server/repositories/data/rows/filter.ts index b998be7d1..2036e3398 100644 --- a/server/repositories/data/rows/filter.ts +++ b/server/repositories/data/rows/filter.ts @@ -7,7 +7,9 @@ * The filter SQL is dialect-naive (ANSI lower/like, the `jsonField()` helper * for cells_json paths) — `db-postgres-isms.test.ts` gates against drift. */ +import { physicalId } from '@core/branches' import type { DbClient } from '../../../db/client' +import type { BranchScope } from '../../../branches/scope' import type { DataRow } from '@core/data/schemas' import type { StorageFilterOperator, StorageFilterValue } from '@core/plugin-sdk/storageSchemas' import { jsonField } from '../../../db/jsonExtract' @@ -62,12 +64,13 @@ const ROW_LEVEL_ORDER_KEYS = new Set([ */ export async function listDataRowsWithFilter( db: DbClient, + scope: BranchScope, tableId: string, options: ListDataRowsFilterOptions = {}, ): Promise { const { filter, orderBy, status = 'any', limit = 100, offset = 0 } = options - const params: unknown[] = [tableId] + const params: unknown[] = [physicalId(scope.branchId, tableId)] let paramIdx = 1 function addParam(value: unknown): string { params.push(value) @@ -76,6 +79,7 @@ export async function listDataRowsWithFilter( } let whereSql = `data_rows.table_id = ${placeholder(db.dialect, 1)} and data_rows.deleted_at is null` + whereSql += ` and data_rows.branch_id = ${addParam(scope.branchId)}` if (status !== 'any') { whereSql += ` and data_rows.status = ${addParam(status)}` @@ -157,7 +161,7 @@ export async function listDataRowsWithFilter( const countParams = params.slice(0, countParamCount) const [rows, countResult] = await Promise.all([ - selectHydratedDataRows(db, { + selectHydratedDataRows(db, scope, { cte, join: 'join filtered_ids on filtered_ids.id = data_rows.id', tail: `order by ${orderBySql}`, diff --git a/server/repositories/data/rows/import.ts b/server/repositories/data/rows/import.ts index 3a2850f9b..b674fdcf6 100644 --- a/server/repositories/data/rows/import.ts +++ b/server/repositories/data/rows/import.ts @@ -8,8 +8,13 @@ * * User reference columns (author, createdBy, etc.) are intentionally dropped * on import: the user ids from the source instance will not exist in the target. + * + * Bundle ids are LOGICAL ids; the branch being imported into supplies the + * physical key (see `@core/branches`). */ +import { physicalId } from '@core/branches' import type { DbClient } from '../../../db/client' +import type { BranchScope } from '../../../branches/scope' import type { DataRowCells, DataRowStatus } from '@core/data/schemas' export interface DataRowImportInput { @@ -29,17 +34,19 @@ export interface DataRowImportInput { */ export async function upsertDataRow( db: DbClient, + scope: BranchScope, input: DataRowImportInput, ): Promise { const createdAt = input.createdAt ?? new Date().toISOString() const updatedAt = input.updatedAt ?? new Date().toISOString() await db` insert into data_rows ( - id, table_id, cells_json, slug, status, + id, branch_id, table_id, cells_json, slug, status, published_at, created_at, updated_at ) values ( - ${input.id}, ${input.tableId}, ${input.cells}, ${input.slug}, ${input.status}, + ${physicalId(scope.branchId, input.id)}, ${scope.branchId}, + ${physicalId(scope.branchId, input.tableId)}, ${input.cells}, ${input.slug}, ${input.status}, ${input.publishedAt}, ${createdAt}, ${updatedAt} ) on conflict (id) do update @@ -63,17 +70,19 @@ export async function upsertDataRow( */ export async function insertDataRowIfAbsent( db: DbClient, + scope: BranchScope, input: DataRowImportInput, ): Promise { const createdAt = input.createdAt ?? new Date().toISOString() const updatedAt = input.updatedAt ?? new Date().toISOString() const { rows } = await db<{ id: string }>` insert into data_rows ( - id, table_id, cells_json, slug, status, + id, branch_id, table_id, cells_json, slug, status, published_at, created_at, updated_at ) values ( - ${input.id}, ${input.tableId}, ${input.cells}, ${input.slug}, ${input.status}, + ${physicalId(scope.branchId, input.id)}, ${scope.branchId}, + ${physicalId(scope.branchId, input.tableId)}, ${input.cells}, ${input.slug}, ${input.status}, ${input.publishedAt}, ${createdAt}, ${updatedAt} ) on conflict do nothing @@ -84,22 +93,24 @@ export async function insertDataRowIfAbsent( /** * Plain INSERT with no conflict handling. Assumes the caller has already wiped - * the table (as the `replace` strategy does). Returns void — the caller does - * not need the inserted row shape. + * the branch's rows (as the `replace` strategy does). Returns void — the + * caller does not need the inserted row shape. */ export async function replaceDataRow( db: DbClient, + scope: BranchScope, input: DataRowImportInput, ): Promise { const createdAt = input.createdAt ?? new Date().toISOString() const updatedAt = input.updatedAt ?? new Date().toISOString() await db` insert into data_rows ( - id, table_id, cells_json, slug, status, + id, branch_id, table_id, cells_json, slug, status, published_at, created_at, updated_at ) values ( - ${input.id}, ${input.tableId}, ${input.cells}, ${input.slug}, ${input.status}, + ${physicalId(scope.branchId, input.id)}, ${scope.branchId}, + ${physicalId(scope.branchId, input.tableId)}, ${input.cells}, ${input.slug}, ${input.status}, ${input.publishedAt}, ${createdAt}, ${updatedAt} ) ` diff --git a/server/repositories/data/rows/mapper.ts b/server/repositories/data/rows/mapper.ts index 910dcf71e..b63ff610a 100644 --- a/server/repositories/data/rows/mapper.ts +++ b/server/repositories/data/rows/mapper.ts @@ -3,19 +3,26 @@ * query modules. * * DataRowRow — the raw row shape produced by the user-ref joins - * selectHydratedDataRows — runs the canonical "row + four user-ref joins" - * SELECT (the only place that column list lives) - * and maps each row through `mapRow` + * selectHydratedDataRows — runs the canonical "row + table + four user-ref + * joins" SELECT (the only place that column list + * lives) and maps each row through `mapRow` * mapRow — DataRowRow → DataRow domain shape * isOwnedByUser — effective-owner predicate for visibility filters * placeholder — re-exported from db/client (the single home) for * the `db.unsafe()` paths (filter + hydrated select) * + * Every id that leaves this module is LOGICAL (see `@core/branches`): the + * hydrated select projects `data_rows.logical_id` as the row id and derives + * `tableId` from the physical table key. Physical primary keys never reach + * callers. + * * Nothing here is part of the repository's public surface — the barrel * (`./index`) does not re-export this module. Sibling query modules import * these helpers directly. */ +import { logicalIdOf } from '@core/branches' import { placeholder, type DbClient } from '../../../db/client' +import type { BranchScope } from '../../../branches/scope' import type { DataRow, DataRowCells, DataRowStatus } from '@core/data/schemas' import { userRefAt, userRefColumns, userRefJoin, type UserJoinColumns } from '../shared' import { isoDate, isoDateOrNull } from '@core/utils/isoDate' @@ -30,7 +37,9 @@ export { placeholder } // --------------------------------------------------------------------------- export interface InsertDataRowInput { + /** Logical row id; generated when omitted. */ id?: string + /** Logical table id. */ tableId: string cells: DataRowCells /** @@ -51,7 +60,7 @@ export interface UpdateDataRowDraftInput { // --------------------------------------------------------------------------- interface DataRowRow extends UserJoinColumns { - id: string + logical_id: string table_id: string cells_json: Record slug: string @@ -72,10 +81,15 @@ interface DataRowRow extends UserJoinColumns { // Mapper // --------------------------------------------------------------------------- -function mapRow(row: DataRowRow): DataRow { +/** + * `logical_id` is a generated column (derived from the physical key and + * the branch), so it is always populated. The table's logical id is derived + * the same way in code: a row's table lives on the row's own branch. + */ +function mapRow(row: DataRowRow, scope: BranchScope): DataRow { return { - id: row.id, - tableId: row.table_id, + id: row.logical_id, + tableId: logicalIdOf(scope.branchId, row.table_id), cells: row.cells_json, slug: row.slug, status: row.status, @@ -107,11 +121,11 @@ export function isOwnedByUser(row: DataRow, ownerUserId: string): boolean { // --------------------------------------------------------------------------- /** - * The full hydrated column list, including the four user-ref joins. The - * `_*` alias groups are built from the shared `userRefColumns` fragment - * (the single source for the user-ref alias set, also spliced by `publish.ts`). + * The full hydrated column list, including the four user-ref joins. The `_*` alias groups are built from the + * shared `userRefColumns` fragment (the single source for the user-ref alias + * set, also spliced by `publish.ts`). */ -const DATA_ROW_COLUMNS = `data_rows.id, +const DATA_ROW_COLUMNS = `data_rows.logical_id, data_rows.table_id, data_rows.cells_json, data_rows.slug, @@ -144,6 +158,10 @@ const DATA_ROW_JOINS = `from data_rows * only and bind values through positional placeholders (see `placeholder`), * with the matching values supplied in `params`. The SQL stays dialect-naive * (ANSI joins + CTE, no Postgres-isms). + * + * Callers bind PHYSICAL ids (row or table) in their clauses; the select + * additionally pins `data_rows.branch_id` to the scope, so an id shaped like + * another branch's physical key can never resolve from the wrong scope. */ interface HydratedDataRowsQuery { /** @@ -153,7 +171,7 @@ interface HydratedDataRowsQuery { */ cte?: string /** - * Optional extra JOIN appended after the canonical user-ref joins — e.g. + * Optional extra JOIN appended after the canonical joins — e.g. * `join filtered_ids on filtered_ids.id = data_rows.id` to restrict the * hydrated rows to a CTE's id set. */ @@ -171,16 +189,20 @@ interface HydratedDataRowsQuery { */ export async function selectHydratedDataRows( db: DbClient, + scope: BranchScope, query: HydratedDataRowsQuery, ): Promise { + // The branch predicate is appended LAST so its placeholder follows every + // caller-supplied one (SQLite binds `?` by position in the text). + const branchPredicate = `data_rows.branch_id = ${placeholder(db.dialect, query.params.length + 1)}` const sql = ` ${query.cte ? `with ${query.cte}` : ''} select ${DATA_ROW_COLUMNS} ${DATA_ROW_JOINS} ${query.join ?? ''} - ${query.where ? `where ${query.where}` : ''} + where ${query.where ? `${query.where} and ` : ''}${branchPredicate} ${query.tail ?? ''} ` - const { rows } = await db.unsafe(sql, query.params) - return rows.map(mapRow) + const { rows } = await db.unsafe(sql, [...query.params, scope.branchId]) + return rows.map((row) => mapRow(row, scope)) } diff --git a/server/repositories/data/rows/mutations.ts b/server/repositories/data/rows/mutations.ts index e0117dd15..9e2e32905 100644 --- a/server/repositories/data/rows/mutations.ts +++ b/server/repositories/data/rows/mutations.ts @@ -15,9 +15,14 @@ * directly from RETURNING. Because RETURNING carries no user-ref joins, the * result is a narrow `DeletedRowSummary` (not a `DataRow`) — the delete callers * only consume id / tableId / slug / status / deletedAt. + * + * Ids in and out are LOGICAL; every statement binds the physical id for the + * given `scope` (see `@core/branches`). */ import { nanoid } from 'nanoid' +import { logicalIdOf, physicalId } from '@core/branches' import type { DbClient } from '../../../db/client' +import type { BranchScope } from '../../../branches/scope' import type { DataRow, DataRowStatus, DeletedRowSummary } from '@core/data/schemas' import { bumpPublishVersionSerialized } from '../../../publish/publishState' import { type InsertDataRowInput, type UpdateDataRowDraftInput } from './mapper' @@ -31,6 +36,7 @@ type UpdateDataRowTableResult = export async function createDataRow( db: DbClient, + scope: BranchScope, input: InsertDataRowInput, actorUserId: string | null = null, pluginActorId: string | null = null, @@ -40,18 +46,26 @@ export async function createDataRow( return serializeCollabAwareWrite(async () => { const created = await createDataRow( db, + scope, input, actorUserId, pluginActorId, { collabInternal: true }, ) - notifyRowWrite({ tableId: created.tableId, rowIds: [created.id], kind: 'create' }) + notifyRowWrite({ + branchId: scope.branchId, + tableId: created.tableId, + rowIds: [created.id], + kind: 'create', + }) return created }) } - const { rows } = await db<{ id: string }>` + const logicalId = input.id ?? nanoid() + const { rows } = await db<{ logical_id: string }>` insert into data_rows ( id, + branch_id, table_id, cells_json, slug, @@ -62,8 +76,9 @@ export async function createDataRow( plugin_actor_id ) values ( - ${input.id ?? nanoid()}, - ${input.tableId}, + ${physicalId(scope.branchId, logicalId)}, + ${scope.branchId}, + ${physicalId(scope.branchId, input.tableId)}, ${input.cells}, ${input.slug}, ${'draft'}, @@ -72,15 +87,16 @@ export async function createDataRow( ${actorUserId}, ${pluginActorId} ) - returning id + returning logical_id ` - const created = await getDataRow(db, rows[0].id) + const created = await getDataRow(db, scope, rows[0].logical_id) if (!created) throw new Error('data row was created but could not be re-read') return created } export async function saveDataRowDraft( db: DbClient, + scope: BranchScope, rowId: string, input: UpdateDataRowDraftInput, actorUserId: string | null = null, @@ -91,18 +107,21 @@ export async function saveDataRowDraft( return serializeCollabAwareWrite(async () => { const row = await saveDataRowDraft( db, + scope, rowId, input, actorUserId, pluginActorId, { collabInternal: true }, ) - if (row) notifyRowWrite({ tableId: row.tableId, rowIds: [row.id], kind: 'update' }) + if (row) { + notifyRowWrite({ branchId: scope.branchId, tableId: row.tableId, rowIds: [row.id], kind: 'update' }) + } return row }) } - const updated = await updateDataRowDraftCells(db, rowId, input, actorUserId, pluginActorId) - return updated ? getDataRow(db, rowId) : null + const updated = await updateDataRowDraftCells(db, scope, rowId, input, actorUserId, pluginActorId) + return updated ? getDataRow(db, scope, rowId) : null } /** @@ -113,6 +132,7 @@ export async function saveDataRowDraft( */ export async function updateDataRowDraftCells( db: DbClient, + scope: BranchScope, rowId: string, input: UpdateDataRowDraftInput, actorUserId: string | null = null, @@ -125,7 +145,8 @@ export async function updateDataRowDraftCells( updated_by_user_id = ${actorUserId}, plugin_actor_id = ${pluginActorId}, updated_at = current_timestamp - where id = ${rowId} + where id = ${physicalId(scope.branchId, rowId)} + and branch_id = ${scope.branchId} and deleted_at is null returning id ` @@ -140,6 +161,7 @@ export async function updateDataRowDraftCells( */ export async function resurrectDataRow( db: DbClient, + scope: BranchScope, rowId: string, input: UpdateDataRowDraftInput, actorUserId: string | null = null, @@ -151,7 +173,8 @@ export async function resurrectDataRow( slug = ${input.slug}, updated_by_user_id = ${actorUserId}, updated_at = current_timestamp - where id = ${rowId} + where id = ${physicalId(scope.branchId, rowId)} + and branch_id = ${scope.branchId} and deleted_at is not null ` } @@ -165,26 +188,30 @@ export async function resurrectDataRow( */ export async function upsertDataRowDraft( db: DbClient, + scope: BranchScope, input: InsertDataRowInput & { id: string }, actorUserId: string | null = null, opts: { collabInternal?: boolean } = {}, ): Promise { if (!opts.collabInternal) { return serializeCollabAwareWrite(async () => { - await upsertDataRowDraft(db, input, actorUserId, { collabInternal: true }) - notifyRowWrite({ tableId: input.tableId, rowIds: [input.id], kind: 'update' }) + await upsertDataRowDraft(db, scope, input, actorUserId, { collabInternal: true }) + notifyRowWrite({ branchId: scope.branchId, tableId: input.tableId, rowIds: [input.id], kind: 'update' }) }) } const draft = { cells: input.cells, slug: input.slug } - const updated = await updateDataRowDraftCells(db, input.id, draft, actorUserId) + const updated = await updateDataRowDraftCells(db, scope, input.id, draft, actorUserId) if (updated) return const { rows } = await db<{ id: string }>` - select id from data_rows where id = ${input.id} and deleted_at is not null + select id from data_rows + where id = ${physicalId(scope.branchId, input.id)} + and branch_id = ${scope.branchId} + and deleted_at is not null ` if (rows.length > 0) { - await resurrectDataRow(db, input.id, draft, actorUserId) + await resurrectDataRow(db, scope, input.id, draft, actorUserId) } else { - await createDataRow(db, input, actorUserId, null, { collabInternal: true }) + await createDataRow(db, scope, input, actorUserId, null, { collabInternal: true }) } } @@ -196,13 +223,15 @@ export async function upsertDataRowDraft( */ export async function updateDataRowSlug( db: DbClient, + scope: BranchScope, rowId: string, slug: string, ): Promise { await db` update data_rows set slug = ${slug} - where id = ${rowId} + where id = ${physicalId(scope.branchId, rowId)} + and branch_id = ${scope.branchId} and deleted_at is null ` } @@ -218,19 +247,22 @@ export async function updateDataRowSlug( */ export async function softDeleteDataRow( db: DbClient, + scope: BranchScope, rowId: string, actorUserId: string | null = null, opts: { collabInternal?: boolean } = {}, ): Promise { if (!opts.collabInternal) { return serializeCollabAwareWrite(async () => { - const row = await softDeleteDataRow(db, rowId, actorUserId, { collabInternal: true }) - if (row) notifyRowWrite({ tableId: row.tableId, rowIds: [row.id], kind: 'delete' }) + const row = await softDeleteDataRow(db, scope, rowId, actorUserId, { collabInternal: true }) + if (row) { + notifyRowWrite({ branchId: scope.branchId, tableId: row.tableId, rowIds: [row.id], kind: 'delete' }) + } return row }) } const { rows } = await db<{ - id: string + logical_id: string table_id: string slug: string status: DataRowStatus @@ -240,15 +272,16 @@ export async function softDeleteDataRow( set deleted_at = current_timestamp, updated_by_user_id = ${actorUserId}, updated_at = current_timestamp - where id = ${rowId} + where id = ${physicalId(scope.branchId, rowId)} + and branch_id = ${scope.branchId} and deleted_at is null - returning id, table_id, slug, status, deleted_at + returning logical_id, table_id, slug, status, deleted_at ` const row = rows[0] if (!row) return null return { - id: row.id, - tableId: row.table_id, + id: row.logical_id, + tableId: logicalIdOf(scope.branchId, row.table_id), slug: row.slug, status: row.status, deletedAt: isoDateOrNull(row.deleted_at), @@ -263,6 +296,7 @@ export async function softDeleteDataRow( */ export async function updateDataRowTable( db: DbClient, + scope: BranchScope, rowId: string, tableId: string, actorUserId: string | null = null, @@ -270,9 +304,10 @@ export async function updateDataRowTable( ): Promise { if (!opts.collabInternal) { const moved = await serializeCollabAwareWrite(async () => { - const before = await getDataRow(db, rowId) + const before = await getDataRow(db, scope, rowId) const result = await updateDataRowTable( db, + scope, rowId, tableId, actorUserId, @@ -283,8 +318,8 @@ export async function updateDataRowTable( // A table move changes both collection rosters. Emit the pair while // still holding the collab-aware write lane so a dirty old row doc // cannot land between the move and its synchronous invalidation. - notifyRowWrite({ tableId: before.tableId, rowIds: [rowId], kind: 'delete' }) - notifyRowWrite({ tableId: result.row.tableId, rowIds: [rowId], kind: 'create' }) + notifyRowWrite({ branchId: scope.branchId, tableId: before.tableId, rowIds: [rowId], kind: 'delete' }) + notifyRowWrite({ branchId: scope.branchId, tableId: result.row.tableId, rowIds: [rowId], kind: 'create' }) bumpPublishVersion = before.status === 'published' } return { result, bumpPublishVersion } @@ -295,25 +330,29 @@ export async function updateDataRowTable( return moved.result } - const row = await getDataRow(db, rowId) + const row = await getDataRow(db, scope, rowId) if (!row) return { ok: false, reason: 'row_not_found' } if (row.tableId === tableId) return { ok: true, row } + const targetTableId = physicalId(scope.branchId, tableId) const { rows: tableRows } = await db<{ id: string }>` select id from data_tables - where id = ${tableId} + where id = ${targetTableId} + and branch_id = ${scope.branchId} and deleted_at is null limit 1 ` if (!tableRows[0]) return { ok: false, reason: 'table_not_found' } + const physicalRowId = physicalId(scope.branchId, rowId) // Only check for slug conflicts when the row has a non-empty slug. if (row.slug) { const { rows: conflictRows } = await db<{ id: string }>` select id from data_rows - where table_id = ${tableId} + where table_id = ${targetTableId} + and branch_id = ${scope.branchId} and slug = ${row.slug} - and id <> ${rowId} + and id <> ${physicalRowId} and deleted_at is null limit 1 ` @@ -322,15 +361,16 @@ export async function updateDataRowTable( const { rows } = await db<{ id: string }>` update data_rows - set table_id = ${tableId}, + set table_id = ${targetTableId}, updated_by_user_id = ${actorUserId}, updated_at = current_timestamp - where id = ${rowId} + where id = ${physicalRowId} + and branch_id = ${scope.branchId} and deleted_at is null returning id ` if (!rows[0]) return { ok: false, reason: 'row_not_found' } - const updated = await getDataRow(db, rows[0].id) + const updated = await getDataRow(db, scope, rowId) if (!updated) return { ok: false, reason: 'row_not_found' } return { ok: true, row: updated } } @@ -343,6 +383,7 @@ export async function updateDataRowTable( */ export async function updateDataRowStatus( db: DbClient, + scope: BranchScope, rowId: string, status: 'draft' | 'unpublished', actorUserId: string | null = null, @@ -355,18 +396,20 @@ export async function updateDataRowStatus( scheduled_publish_at = null, updated_by_user_id = ${actorUserId}, updated_at = current_timestamp - where id = ${rowId} + where id = ${physicalId(scope.branchId, rowId)} + and branch_id = ${scope.branchId} and deleted_at is null returning id ` if (!rows[0]) return null // Invalidate the render cache — the route's published state changed. await bumpPublishVersionSerialized() - return getDataRow(db, rows[0].id) + return getDataRow(db, scope, rowId) } export async function updateDataRowAuthor( db: DbClient, + scope: BranchScope, rowId: string, authorUserId: string, actorUserId: string | null = null, @@ -376,9 +419,10 @@ export async function updateDataRowAuthor( set author_user_id = ${authorUserId}, updated_by_user_id = ${actorUserId}, updated_at = current_timestamp - where id = ${rowId} + where id = ${physicalId(scope.branchId, rowId)} + and branch_id = ${scope.branchId} and deleted_at is null returning id ` - return rows[0] ? getDataRow(db, rows[0].id) : null + return rows[0] ? getDataRow(db, scope, rowId) : null } diff --git a/server/repositories/data/rows/read.ts b/server/repositories/data/rows/read.ts index d0a9cb407..62f6ea3e4 100644 --- a/server/repositories/data/rows/read.ts +++ b/server/repositories/data/rows/read.ts @@ -9,8 +9,13 @@ * getDataRowBySlug — a single row by its denormalized slug * countDataRows — non-deleted row count for a table * listDataAuthorOptions — active users for the author picker + * + * Every id in and out is LOGICAL; the branch comes from `scope` and is + * folded into the physical ids the SQL binds (see `@core/branches`). */ +import { logicalIdOf, physicalId } from '@core/branches' import type { DbClient } from '../../../db/client' +import type { BranchScope } from '../../../branches/scope' import type { DataRow } from '@core/data/schemas' import { selectHydratedDataRows, isOwnedByUser, placeholder } from './mapper' @@ -33,12 +38,13 @@ interface DataAuthorRow { export async function listDataRows( db: DbClient, + scope: BranchScope, tableId: string, visibility: ListDataRowsVisibility = {}, ): Promise { - const dataRows = await selectHydratedDataRows(db, { + const dataRows = await selectHydratedDataRows(db, scope, { where: `data_rows.table_id = ${placeholder(db.dialect, 1)} and data_rows.deleted_at is null`, - params: [tableId], + params: [physicalId(scope.branchId, tableId)], tail: 'order by data_rows.updated_at desc, data_rows.created_at desc', }) if (visibility.ownerUserId) { @@ -61,11 +67,13 @@ interface DataRowIdSlug { */ export async function listDataRowIdSlugs( db: DbClient, + scope: BranchScope, tableId: string, ): Promise { const { rows } = await db` - select id, slug from data_rows - where table_id = ${tableId} + select logical_id as id, slug from data_rows + where table_id = ${physicalId(scope.branchId, tableId)} + and branch_id = ${scope.branchId} and deleted_at is null ` return rows @@ -79,11 +87,13 @@ export async function listDataRowIdSlugs( */ export async function listSoftDeletedDataRowIds( db: DbClient, + scope: BranchScope, tableId: string, ): Promise { const { rows } = await db<{ id: string }>` - select id from data_rows - where table_id = ${tableId} + select logical_id as id from data_rows + where table_id = ${physicalId(scope.branchId, tableId)} + and branch_id = ${scope.branchId} and deleted_at is not null ` return rows.map((r) => r.id) @@ -104,15 +114,21 @@ export interface DataRowSeq { */ export async function listDataRowSeqs( db: DbClient, + scope: BranchScope, tableId: string, rowIds: ReadonlyArray, ): Promise { if (rowIds.length === 0) return [] const placeholders = rowIds.map((_, i) => placeholder(db.dialect, i + 2)).join(', ') const { rows } = await db.unsafe( - `select id, seq from data_rows - where table_id = ${placeholder(db.dialect, 1)} and id in (${placeholders})`, - [tableId, ...rowIds], + `select logical_id as id, seq from data_rows + where table_id = ${placeholder(db.dialect, 1)} and id in (${placeholders}) + and branch_id = ${placeholder(db.dialect, rowIds.length + 2)}`, + [ + physicalId(scope.branchId, tableId), + ...rowIds.map((rowId) => physicalId(scope.branchId, rowId)), + scope.branchId, + ], ) return rows.map((r) => ({ id: r.id, seq: Number(r.seq) })) } @@ -132,20 +148,22 @@ export interface ChangedDataRowRef { */ export async function listChangedDataRowRefsSince( db: DbClient, + scope: BranchScope, tableIds: ReadonlyArray, cursor: number, ): Promise { if (tableIds.length === 0) return [] const placeholders = tableIds.map((_, i) => placeholder(db.dialect, i + 2)).join(', ') const { rows } = await db.unsafe<{ id: string; table_id: string; seq: number; deleted_at: string | null }>( - `select id, table_id, seq, deleted_at from data_rows + `select logical_id as id, table_id, seq, deleted_at from data_rows where seq > ${placeholder(db.dialect, 1)} and table_id in (${placeholders}) + and branch_id = ${placeholder(db.dialect, tableIds.length + 2)} order by seq asc`, - [cursor, ...tableIds], + [cursor, ...tableIds.map((tableId) => physicalId(scope.branchId, tableId)), scope.branchId], ) return rows.map((row) => ({ id: row.id, - tableId: row.table_id, + tableId: logicalIdOf(scope.branchId, row.table_id), seq: Number(row.seq), deleted: row.deleted_at !== null, })) @@ -153,11 +171,12 @@ export async function listChangedDataRowRefsSince( export async function getDataRow( db: DbClient, + scope: BranchScope, rowId: string, ): Promise { - const rows = await selectHydratedDataRows(db, { + const rows = await selectHydratedDataRows(db, scope, { where: `data_rows.id = ${placeholder(db.dialect, 1)} and data_rows.deleted_at is null`, - params: [rowId], + params: [physicalId(scope.branchId, rowId)], tail: 'limit 1', }) return rows[0] ?? null @@ -171,13 +190,14 @@ export async function getDataRow( */ export async function getDataRowMany( db: DbClient, + scope: BranchScope, rowIds: ReadonlyArray, ): Promise { if (rowIds.length === 0) return [] const placeholders = rowIds.map((_, i) => placeholder(db.dialect, i + 1)).join(', ') - return selectHydratedDataRows(db, { + return selectHydratedDataRows(db, scope, { where: `data_rows.id in (${placeholders}) and data_rows.deleted_at is null`, - params: [...rowIds], + params: rowIds.map((rowId) => physicalId(scope.branchId, rowId)), }) } @@ -189,25 +209,28 @@ export async function getDataRowMany( */ export async function getDataRowBySlug( db: DbClient, + scope: BranchScope, tableId: string, slug: string, ): Promise { const { rows } = await db<{ id: string }>` - select id from data_rows - where table_id = ${tableId} + select logical_id as id from data_rows + where table_id = ${physicalId(scope.branchId, tableId)} + and branch_id = ${scope.branchId} and slug = ${slug} and deleted_at is null limit 1 ` - return rows[0] ? getDataRow(db, rows[0].id) : null + return rows[0] ? getDataRow(db, scope, rows[0].id) : null } /** Count non-deleted rows in a table — one indexed COUNT. */ -export async function countDataRows(db: DbClient, tableId: string): Promise { +export async function countDataRows(db: DbClient, scope: BranchScope, tableId: string): Promise { const { rows } = await db<{ count: number | string }>` select count(*) as count from data_rows - where table_id = ${tableId} + where table_id = ${physicalId(scope.branchId, tableId)} + and branch_id = ${scope.branchId} and deleted_at is null ` return Number(rows[0]?.count ?? 0) diff --git a/server/repositories/data/rows/schedule.ts b/server/repositories/data/rows/schedule.ts index 084230a45..e43d3daf1 100644 --- a/server/repositories/data/rows/schedule.ts +++ b/server/repositories/data/rows/schedule.ts @@ -7,8 +7,12 @@ * * The publish-scheduler tick (`server/publish/publishScheduler.ts`) polls * `listDuePublishSchedules` and calls the regular publish path on each result. + * Scheduling only exists on `main` — publishing is a main-only operation — + * so the due query is pinned to the main branch. */ +import { MAIN_BRANCH_ID, physicalId } from '@core/branches' import type { DbClient } from '../../../db/client' +import type { BranchScope } from '../../../branches/scope' import type { DataRow } from '@core/data/schemas' import { isoDate } from '@core/utils/isoDate' import { getDataRow } from './read' @@ -33,6 +37,7 @@ import { getDataRow } from './read' */ export async function scheduleDataRowPublish( db: DbClient, + scope: BranchScope, rowId: string, whenIso: string, actorUserId: string | null = null, @@ -45,11 +50,12 @@ export async function scheduleDataRowPublish( published_by_user_id = null, updated_by_user_id = ${actorUserId}, updated_at = current_timestamp - where id = ${rowId} + where id = ${physicalId(scope.branchId, rowId)} + and branch_id = ${scope.branchId} and deleted_at is null returning id ` - return rows[0] ? getDataRow(db, rows[0].id) : null + return rows[0] ? getDataRow(db, scope, rowId) : null } /** @@ -60,6 +66,7 @@ export async function scheduleDataRowPublish( */ export async function cancelScheduledPublish( db: DbClient, + scope: BranchScope, rowId: string, actorUserId: string | null = null, ): Promise { @@ -69,12 +76,13 @@ export async function cancelScheduledPublish( scheduled_publish_at = null, updated_by_user_id = ${actorUserId}, updated_at = current_timestamp - where id = ${rowId} + where id = ${physicalId(scope.branchId, rowId)} + and branch_id = ${scope.branchId} and deleted_at is null and status = 'scheduled' returning id ` - return rows[0] ? getDataRow(db, rows[0].id) : null + return rows[0] ? getDataRow(db, scope, rowId) : null } /** @@ -90,11 +98,12 @@ interface DueScheduledRow { } /** - * List scheduled rows whose target time has passed and that aren't + * List `main` rows whose scheduled publish time has passed and that aren't * already deleted. Returns up to `limit` rows ordered by their target * time (oldest first — back-pressure favours the rows that have been * waiting longest). The scheduler tick calls this, then calls - * `publishDataRow(...)` on each result. + * `publishDataRow(...)` on each result. On main, physical and logical ids + * coincide, so the projected ids are directly usable. * * NOT atomic — two concurrent leader instances could read the same * batch. The publish-scheduler tick relies on the host-level leader @@ -111,9 +120,10 @@ export async function listDuePublishSchedules( table_id: string scheduled_publish_at: string | Date }>` - select id, table_id, scheduled_publish_at + select logical_id as id, table_id, scheduled_publish_at from data_rows - where status = 'scheduled' + where branch_id = ${MAIN_BRANCH_ID} + and status = 'scheduled' and deleted_at is null and scheduled_publish_at is not null and scheduled_publish_at <= ${nowIso} diff --git a/server/repositories/data/rows/search.ts b/server/repositories/data/rows/search.ts index 412d6a5a9..b9864c745 100644 --- a/server/repositories/data/rows/search.ts +++ b/server/repositories/data/rows/search.ts @@ -2,9 +2,11 @@ * Cross-table content search (spotlight content provider). * * searchDataRows — search non-deleted rows across all non-deleted data - * tables by slug, returning a lightweight summary + * tables of one branch by slug, returning a lightweight + * summary */ import type { DbClient } from '../../../db/client' +import type { BranchScope } from '../../../branches/scope' import type { DataRowStatus } from '@core/data/schemas' import { isoDate } from '@core/utils/isoDate' @@ -63,14 +65,15 @@ interface SearchDataRowsVisibility { */ export async function searchDataRows( db: DbClient, + scope: BranchScope, query: string, limit: number, visibility: SearchDataRowsVisibility = {}, ): Promise { const likePattern = `%${query.toLowerCase()}%` const { rows } = await db` - select data_rows.id, - data_rows.table_id, + select data_rows.logical_id as id, + data_tables.logical_id as table_id, data_rows.slug, data_rows.status, data_rows.author_user_id, @@ -81,7 +84,8 @@ export async function searchDataRows( data_tables.system as table_system from data_rows join data_tables on data_tables.id = data_rows.table_id - where data_rows.deleted_at is null + where data_rows.branch_id = ${scope.branchId} + and data_rows.deleted_at is null and data_tables.deleted_at is null and lower(data_rows.slug) like ${likePattern} order by data_rows.updated_at desc diff --git a/server/repositories/data/tables.ts b/server/repositories/data/tables.ts index 89177dbbc..0984a6e43 100644 --- a/server/repositories/data/tables.ts +++ b/server/repositories/data/tables.ts @@ -1,9 +1,10 @@ /** * CRUD for data tables. * - * listDataTables — read every non-deleted table. System tables sort - * first in a fixed order (pages, posts, components, - * layouts); custom tables follow, ordered by created_at. + * listDataTables — read every non-deleted table of a branch. System + * tables sort first in a fixed order (pages, posts, + * components, layouts); custom tables follow, ordered + * by created_at. * getDataTable — read a single table by id (or null) * getDataTableBySlug — read a single table by slug (indexed; or null) * createDataTable — insert a new table @@ -11,9 +12,15 @@ * softDeleteDataTable — set deleted_at; refuses if rows exist or if the * table is the seeded `posts` post-type * insertDataTableIfAbsent — insert only if id absent; used by merge-add / merge-overwrite + * + * Ids in and out are LOGICAL; the branch comes from `scope` (see + * `@core/branches`). System tables keep their well-known logical ids + * (`pages`, `posts`, `components`, `layouts`) on every branch. */ import { nanoid } from 'nanoid' +import { physicalId } from '@core/branches' import type { DbClient } from '../../db/client' +import type { BranchScope } from '../../branches/scope' import { countDataRows } from './rows/read' import { normalizeRouteBase } from '@core/templates/templateMatching' import { buildPostTypeDefaultFields, normalizeDataTableFields } from '@core/data/fields' @@ -52,7 +59,7 @@ interface UpdateDataTableInput { } interface DataTableRow { - id: string + logical_id: string name: string slug: string kind: DataTableKind @@ -96,7 +103,7 @@ function routeBaseForCreate(routeBase: string | undefined, slug: string): string function mapTable(row: DataTableRow): DataTable { return { - id: row.id, + id: row.logical_id, name: row.name, slug: row.slug, kind: row.kind, @@ -113,13 +120,14 @@ function mapTable(row: DataTableRow): DataTable { } } -export async function listDataTables(db: DbClient): Promise { +export async function listDataTables(db: DbClient, scope: BranchScope): Promise { const { rows } = await db` - select id, name, slug, kind, route_base, singular_label, plural_label, + select logical_id, name, slug, kind, route_base, singular_label, plural_label, primary_field_id, fields_json, system, created_by_user_id, updated_by_user_id, created_at, updated_at from data_tables - where deleted_at is null + where branch_id = ${scope.branchId} + and deleted_at is null order by case kind when 'page' then 0 @@ -141,9 +149,12 @@ export async function listDataTables(db: DbClient): Promise { * SQL is dialect-naive: no Postgres-isms (`::int`, `now()`, `::jsonb`, * `any($N::...)`, `distinct on`) — runs identically on SQLite and Postgres. */ -export async function listDataTablesWithCounts(db: DbClient): Promise { +export async function listDataTablesWithCounts( + db: DbClient, + scope: BranchScope, +): Promise { const { rows } = await db` - select t.id, t.name, t.slug, t.kind, t.route_base, t.singular_label, t.plural_label, + select t.logical_id, t.name, t.slug, t.kind, t.route_base, t.singular_label, t.plural_label, t.primary_field_id, t.fields_json, t.system, t.created_by_user_id, t.updated_by_user_id, t.created_at, t.updated_at, coalesce( @@ -151,7 +162,8 @@ export async function listDataTablesWithCounts(db: DbClient): Promise { +export async function getDataTable( + db: DbClient, + scope: BranchScope, + tableId: string, +): Promise { const { rows } = await db` - select id, name, slug, kind, route_base, singular_label, plural_label, + select logical_id, name, slug, kind, route_base, singular_label, plural_label, primary_field_id, fields_json, system, created_by_user_id, updated_by_user_id, created_at, updated_at from data_tables - where id = ${tableId} + where id = ${physicalId(scope.branchId, tableId)} + and branch_id = ${scope.branchId} and deleted_at is null limit 1 ` @@ -183,17 +200,22 @@ export async function getDataTable(db: DbClient, tableId: string): Promise { +export async function getDataTableBySlug( + db: DbClient, + scope: BranchScope, + slug: string, +): Promise { const { rows } = await db` - select id, name, slug, kind, route_base, singular_label, plural_label, + select logical_id, name, slug, kind, route_base, singular_label, plural_label, primary_field_id, fields_json, system, created_by_user_id, updated_by_user_id, created_at, updated_at from data_tables - where slug = ${slug} + where branch_id = ${scope.branchId} + and slug = ${slug} and deleted_at is null limit 1 ` @@ -280,12 +302,15 @@ function keepPostTypeBuiltIns(existing: DataTable, next: DataField[]): DataField export async function createDataTable( db: DbClient, + scope: BranchScope, input: CreateDataTableInput, ): Promise { const fields = withPostTypeBuiltIns(input.kind, normalizeDataTableFields(input.fields ?? [])) + const logicalId = input.id ?? nanoid() const { rows } = await db` insert into data_tables ( id, + branch_id, name, slug, kind, @@ -298,7 +323,8 @@ export async function createDataTable( updated_by_user_id ) values ( - ${input.id ?? nanoid()}, + ${physicalId(scope.branchId, logicalId)}, + ${scope.branchId}, ${input.name}, ${input.slug}, ${input.kind ?? 'data'}, @@ -310,7 +336,7 @@ export async function createDataTable( ${input.createdByUserId ?? null}, ${input.updatedByUserId ?? input.createdByUserId ?? null} ) - returning id, name, slug, kind, route_base, singular_label, plural_label, + returning logical_id, name, slug, kind, route_base, singular_label, plural_label, primary_field_id, fields_json, system, created_by_user_id, updated_by_user_id, created_at, updated_at ` @@ -321,12 +347,13 @@ export async function createDataTable( export async function updateDataTable( db: DbClient, + scope: BranchScope, tableId: string, input: UpdateDataTableInput, ): Promise { let fields: DataField[] | null = null if (input.fields !== undefined) { - const existing = await getDataTable(db, tableId) + const existing = await getDataTable(db, scope, tableId) if (!existing) return null fields = keepPostTypeBuiltIns(existing, normalizeDataTableFields(input.fields)) } @@ -342,9 +369,10 @@ export async function updateDataTable( fields_json = coalesce(${fields}, fields_json), updated_by_user_id = coalesce(${input.updatedByUserId ?? null}, updated_by_user_id), updated_at = current_timestamp - where id = ${tableId} + where id = ${physicalId(scope.branchId, tableId)} + and branch_id = ${scope.branchId} and deleted_at is null - returning id, name, slug, kind, route_base, singular_label, plural_label, + returning logical_id, name, slug, kind, route_base, singular_label, plural_label, primary_field_id, fields_json, system, created_by_user_id, updated_by_user_id, created_at, updated_at ` @@ -360,14 +388,17 @@ export async function updateDataTable( */ export async function insertDataTableIfAbsent( db: DbClient, + scope: BranchScope, input: CreateDataTableInput, ): Promise { // Same seeding as createDataTable: `merge-add` / `merge-overwrite` is an // import, and an imported post type has to be routable too. const fields = withPostTypeBuiltIns(input.kind, normalizeDataTableFields(input.fields ?? [])) + const logicalId = input.id ?? nanoid() const { rows } = await db<{ id: string }>` insert into data_tables ( id, + branch_id, name, slug, kind, @@ -380,7 +411,8 @@ export async function insertDataTableIfAbsent( updated_by_user_id ) values ( - ${input.id ?? nanoid()}, + ${physicalId(scope.branchId, logicalId)}, + ${scope.branchId}, ${input.name}, ${input.slug}, ${input.kind ?? 'data'}, @@ -408,23 +440,59 @@ export async function insertDataTableIfAbsent( */ export async function softDeleteDataTable( db: DbClient, + scope: BranchScope, tableId: string, actorUserId: string | null = null, ): Promise { - const table = await getDataTable(db, tableId) + const table = await getDataTable(db, scope, tableId) if (!table) return null if (table.system === true) return null - if (await countDataRows(db, tableId) > 0) return null + if (await countDataRows(db, scope, tableId) > 0) return null const { rows } = await db` update data_tables set deleted_at = current_timestamp, updated_by_user_id = ${actorUserId}, updated_at = current_timestamp - where id = ${tableId} + where id = ${physicalId(scope.branchId, tableId)} + and branch_id = ${scope.branchId} and deleted_at is null - returning id, name, slug, kind, route_base, singular_label, plural_label, + returning logical_id, name, slug, kind, route_base, singular_label, plural_label, + primary_field_id, fields_json, system, + created_by_user_id, updated_by_user_id, created_at, updated_at + ` + return rows[0] ? mapTable(rows[0]) : null +} + +/** + * Bring a soft-deleted table back with new settings — the merge engine's + * path when a branch re-creates a table the target side had deleted. Null + * when no soft-deleted table has this id on the branch. + */ +export async function restoreDataTable( + db: DbClient, + scope: BranchScope, + tableId: string, + input: UpdateDataTableInput, +): Promise { + const fields = input.fields !== undefined ? normalizeDataTableFields(input.fields) : null + const { rows } = await db` + update data_tables + set deleted_at = null, + name = coalesce(${input.name ?? null}, name), + slug = coalesce(${input.slug ?? null}, slug), + route_base = coalesce(${input.routeBase ?? null}, route_base), + singular_label = coalesce(${input.singularLabel ?? null}, singular_label), + plural_label = coalesce(${input.pluralLabel ?? null}, plural_label), + primary_field_id = coalesce(${input.primaryFieldId ?? null}, primary_field_id), + fields_json = coalesce(${fields}, fields_json), + updated_by_user_id = coalesce(${input.updatedByUserId ?? null}, updated_by_user_id), + updated_at = current_timestamp + where id = ${physicalId(scope.branchId, tableId)} + and branch_id = ${scope.branchId} + and deleted_at is not null + returning logical_id, name, slug, kind, route_base, singular_label, plural_label, primary_field_id, fields_json, system, created_by_user_id, updated_by_user_id, created_at, updated_at ` diff --git a/server/repositories/data/versions.ts b/server/repositories/data/versions.ts index 6c72743ab..fd09102d3 100644 --- a/server/repositories/data/versions.ts +++ b/server/repositories/data/versions.ts @@ -8,6 +8,7 @@ */ import type { DbClient } from '../../db/client' +import { isoDate } from '@core/utils/isoDate' /** * Next `version_number` for a row: `max(existing) + 1`, or `1` when the row has @@ -21,3 +22,87 @@ export async function nextDataRowVersionNumber(db: DbClient, rowId: string): Pro ` return Number(rows[0]?.next_version ?? 1) } + +export interface DataRowVersionSummary { + id: string + rowId: string + versionNumber: number + slug: string + publishedAt: string + publishedByUserId: string | null + publishedByName: string | null +} + +interface DataRowVersionSummaryRow { + id: string + row_id: string + version_number: number + slug: string + published_at: string | Date + published_by_user_id: string | null + published_by_name: string | null + published_by_email: string | null +} + +/** + * Every published version of a row, newest first. Versions are recorded by + * publishes on main, so `rowId` is the row's logical id — which is also its + * physical id there. + */ +export async function listDataRowVersions(db: DbClient, rowId: string): Promise { + const { rows } = await db` + select data_row_versions.id, + data_row_versions.row_id, + data_row_versions.version_number, + data_row_versions.slug, + data_row_versions.published_at, + data_row_versions.published_by_user_id, + users.display_name as published_by_name, + users.email as published_by_email + from data_row_versions + left join users on users.id = data_row_versions.published_by_user_id + where data_row_versions.row_id = ${rowId} + order by data_row_versions.version_number desc + ` + return rows.map((row) => ({ + id: row.id, + rowId: row.row_id, + versionNumber: Number(row.version_number), + slug: row.slug, + publishedAt: isoDate(row.published_at), + publishedByUserId: row.published_by_user_id, + publishedByName: row.published_by_name || row.published_by_email || null, + })) +} + +export interface DataRowVersionContent { + id: string + rowId: string + versionNumber: number + cells: Record + slug: string +} + +/** One version's stored content, or null when the id is unknown or belongs to another row. */ +export async function getDataRowVersion( + db: DbClient, + rowId: string, + versionId: string, +): Promise { + const { rows } = await db<{ id: string; row_id: string; version_number: number; cells_json: Record; slug: string }>` + select id, row_id, version_number, cells_json, slug + from data_row_versions + where id = ${versionId} + and row_id = ${rowId} + limit 1 + ` + const row = rows[0] + if (!row) return null + return { + id: row.id, + rowId: row.row_id, + versionNumber: Number(row.version_number), + cells: row.cells_json, + slug: row.slug, + } +} diff --git a/server/repositories/publish.ts b/server/repositories/publish.ts index b3de3e6d1..c34960886 100644 --- a/server/repositories/publish.ts +++ b/server/repositories/publish.ts @@ -22,11 +22,13 @@ * getDraftPublishStatus — compare draft vs published state for the UI */ import { createHash } from 'node:crypto' +import { canonicalJson } from '@core/utils/canonicalJson' import type { DataRow } from '@core/data/schemas' import type { SiteDocument } from '@core/page-tree' import type { PublishedPageRuntimeAssets } from '@core/site-runtime' import type { PublishedRuntimePackageImportmap } from '@core/publisher' import type { DbClient } from '../db/client' +import { MAIN_SCOPE, type BranchScope } from '../branches/scope' import type { BuiltRuntimeAssetFile } from '../publish/runtime/bundleScripts' import { getDraftSite } from './site' import { listDataRows } from './data' @@ -101,19 +103,6 @@ export interface PersistSitePublishInput { // Helpers // --------------------------------------------------------------------------- -function canonicalJson(value: unknown): string { - if (Array.isArray(value)) { - return `[${value.map(canonicalJson).join(',')}]` - } - if (value && typeof value === 'object') { - const record = value as Record - return `{${Object.keys(record).sort().map((key) => - `${JSON.stringify(key)}:${canonicalJson(record[key])}` - ).join(',')}}` - } - return JSON.stringify(value) -} - /** * Canonical content hash of a site document, stamped on `site_snapshots` at * publish time. The publish-status check compares the draft's hash against @@ -156,17 +145,20 @@ function snapshotFromQueryRow(row: SnapshotQueryRow): PublishedPageSnapshot { // --------------------------------------------------------------------------- /** - * Assemble the current draft `SiteDocument` from the site shell plus the - * `pages` and `components` data rows. Returns `null` when no draft site + * Assemble a branch's current draft `SiteDocument` from its site shell plus + * the `pages` and `components` data rows. Returns `null` when no draft site * exists yet. Saved layouts are editor-only; publishing ignores them. */ -export async function getDraftSiteDocument(db: DbClient): Promise { - const shell = await getDraftSite(db) +export async function getDraftSiteDocument( + db: DbClient, + scope: BranchScope, +): Promise { + const shell = await getDraftSite(db, scope) if (!shell) return null const [pageRows, vcRows] = await Promise.all([ - listDataRows(db, 'pages'), - listDataRows(db, 'components'), + listDataRows(db, scope, 'pages'), + listDataRows(db, scope, 'components'), ]) const visualComponents = validateVisualComponents( orderSiteDocumentRows(vcRows) @@ -180,8 +172,12 @@ export async function getDraftSiteDocument(db: DbClient): Promise { - const draftSite = await getDraftSiteDocument(db) + const draftSite = await getDraftSiteDocument(db, MAIN_SCOPE) if (!draftSite) { return { hasPublishedVersion: false, diff --git a/server/repositories/rowWriteEvents.ts b/server/repositories/rowWriteEvents.ts index 31177cd6c..5fe7ec735 100644 --- a/server/repositories/rowWriteEvents.ts +++ b/server/repositories/rowWriteEvents.ts @@ -5,6 +5,9 @@ * installs, HTTP site saves, data-workspace edits) WITHOUT repositories * importing upward into server/collab. * + * Every event names the branch it happened on: collab documents are + * per-branch, so a write on one branch must never reset another's docs. + * * The relay's own persistence passes `collabInternal: true` through the * repository write functions, which then skip the notification — otherwise * every relay persist would reset the very documents it just persisted. @@ -13,7 +16,10 @@ export type RowWriteKind = 'create' | 'update' | 'delete' export interface RowWriteEvent { + branchId: string + /** Logical table id (`pages`, `components`, …). */ tableId: string + /** Logical row ids. */ rowIds: readonly string[] kind: RowWriteKind } @@ -59,8 +65,8 @@ export function notifyRowWrite(event: RowWriteEvent): void { } } -/** The shell (site row) equivalent — same seam, no table id. */ -type ShellWriteListener = () => void +/** The shell (site row) equivalent — same seam, keyed by branch. */ +type ShellWriteListener = (branchId: string) => void const shellListeners = new Set() export function registerShellWriteListener(listener: ShellWriteListener): () => void { @@ -68,10 +74,10 @@ export function registerShellWriteListener(listener: ShellWriteListener): () => return () => shellListeners.delete(listener) } -export function notifyShellWrite(): void { +export function notifyShellWrite(branchId: string): void { for (const listener of shellListeners) { try { - listener() + listener(branchId) } catch (err) { console.error('[rowWriteEvents] shell listener failed:', err) } diff --git a/server/repositories/setup.ts b/server/repositories/setup.ts index 4d2e3d0ed..210aa9d94 100644 --- a/server/repositories/setup.ts +++ b/server/repositories/setup.ts @@ -1,3 +1,4 @@ +import { MAIN_BRANCH_ID } from '@core/branches' import type { DbClient } from '../db/client' interface SetupStatus { @@ -62,9 +63,11 @@ export async function createSite( name: string, settings: Record, ): Promise { + // First-run setup creates the `main` shell; every other branch's shell is + // forked from it (see server/branches/fork.ts). await db` - insert into site (id, name, settings_json) - values ('default', ${name}, ${settings}) + insert into site (id, name, settings_json, branch_id) + values ('default', ${name}, ${settings}, ${MAIN_BRANCH_ID}) on conflict (id) do update set name = excluded.name, settings_json = excluded.settings_json, diff --git a/server/repositories/site.ts b/server/repositories/site.ts index 0f82d9bd0..2d130e187 100644 --- a/server/repositories/site.ts +++ b/server/repositories/site.ts @@ -7,6 +7,10 @@ * id, name, breakpoints, settings, styleRules, files, packageJson, runtime, * Site Explorer organization, createdAt, updatedAt. * + * One shell row per branch. Its logical id is always `default` + * (`SITE_SHELL_LOGICAL_ID`); the physical row id follows the branch id + * scheme in `@core/branches`, so `main` keeps the historical `default` key. + * * Storage format inside `settings_json`: * { cmsSiteSchemaVersion: 1, site: } * The `name` is stored in the dedicated `site.name` column. @@ -18,10 +22,12 @@ import { parseConditions, parseSiteExplorerOrganization, } from '@core/page-tree' +import { SITE_SHELL_LOGICAL_ID, physicalId } from '@core/branches' import { validateSite } from '@core/persistence/validate' import { normalizeSitePackageJson } from '@core/site-dependencies/manifest' import { normalizeSiteRuntimeConfig } from '@core/site-runtime' import type { DbClient } from '../db/client' +import type { BranchScope } from '../branches/scope' import { notifyShellWrite, serializeCollabAwareWrite } from './rowWriteEvents' import type { SiteRow } from '../types' @@ -44,12 +50,17 @@ function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value) } +/** The physical primary key of a branch's shell row. */ +export function siteRowId(scope: BranchScope): string { + return physicalId(scope.branchId, SITE_SHELL_LOGICAL_ID) +} + function readStoredShell(row: SiteRow): SiteShell { const stored = row.settings_json const site: Record = isRecord(stored?.site) ? stored.site as Record : {} const conditions = parseConditions(site.conditions) return { - id: typeof site.id === 'string' ? site.id : 'default', + id: typeof site.id === 'string' ? site.id : SITE_SHELL_LOGICAL_ID, name: typeof row.name === 'string' ? row.name : '', files: Array.isArray(site.files) ? site.files as SiteShell['files'] : [], packageJson: normalizeSitePackageJson(site.packageJson), @@ -68,11 +79,11 @@ function readStoredShell(row: SiteRow): SiteShell { } } -export async function getDraftSite(db: DbClient): Promise { +export async function getDraftSite(db: DbClient, scope: BranchScope): Promise { const { rows } = await db` select id, name, settings_json, created_at, updated_at from site - where id = 'default' + where id = ${siteRowId(scope)} limit 1 ` const row = rows[0] @@ -84,19 +95,25 @@ export async function getDraftSite(db: DbClient): Promise { export async function saveDraftSite( db: DbClient, + scope: BranchScope, shell: SiteShell, _actorUserId: string | null = null, opts: { collabInternal?: boolean } = {}, ): Promise { if (!opts.collabInternal) { return serializeCollabAwareWrite(async () => { - await saveDraftSite(db, shell, _actorUserId, { collabInternal: true }) - notifyShellWrite() + await saveDraftSite(db, scope, shell, _actorUserId, { collabInternal: true }) + notifyShellWrite(scope.branchId) }) } await db` - insert into site (id, name, settings_json) - values ('default', ${shell.name}, ${shellToStorage(shell)}) + insert into site (id, name, settings_json, branch_id) + values ( + ${siteRowId(scope)}, + ${shell.name}, + ${shellToStorage(shell)}, + ${scope.branchId} + ) on conflict (id) do update set name = excluded.name, settings_json = excluded.settings_json, @@ -110,10 +127,10 @@ export async function saveDraftSite( * reads this inside the transaction for the shell conflict check; the GET * shell endpoint returns it so clients can seed their base seq. */ -export async function getDraftSiteSeq(db: DbClient): Promise { +export async function getDraftSiteSeq(db: DbClient, scope: BranchScope): Promise { const { rows } = await db<{ seq: number }>` select seq from site - where id = 'default' + where id = ${siteRowId(scope)} limit 1 ` return rows[0] ? Number(rows[0].seq) : 0 @@ -128,10 +145,10 @@ export async function getDraftSiteSeq(db: DbClient): Promise { * conditional stamp keeps the shell seq an honest "shell content changed" * signal (see repositories/syncSequence.ts). */ -export async function stampDraftSiteSeq(db: DbClient, seq: number): Promise { +export async function stampDraftSiteSeq(db: DbClient, scope: BranchScope, seq: number): Promise { await db` update site set seq = ${seq} - where id = 'default' + where id = ${siteRowId(scope)} ` } diff --git a/server/router.ts b/server/router.ts index f2866e123..5da4d651c 100644 --- a/server/router.ts +++ b/server/router.ts @@ -2,13 +2,19 @@ import { tryHandleAi } from './ai/handlers' import { handleMcpHttp, MCP_ENDPOINT_PATH } from './ai/mcp' import { tryHandleMcpOAuth } from './ai/mcp/oauth/handler' import { handleCmsRequest } from './handlers/cms' +import { readPreviewAsset } from './publish/branchPreviewAssets' +import { + tryServeBranchPreviewLink, + tryServeNotFoundPage, + tryServePublicRoute, + trySetupRedirect, +} from './publish/publicRoutes' import type { DbClient } from './db/client' -import { renderNotFoundResponse, renderPublicResolution } from './publish/publicRouter' +import type { RouteHandler, ServerRuntime } from './serverRuntime' import { readStaticAsset } from './publish/staticArtefact' import { getLatestSnapshotForVersion } from './publish/publishedSnapshotCache' import { getPublishVersion, registerVersionedCacheReset } from './publish/publishState' import { prefetchMediaAssets } from './publish/mediaPrefetch' -import { getSetupStatusCached } from './repositories/setup' import { getPublishedRuntimeAsset } from './repositories/runtimeAsset' import { handleLoopRequest, isLoopRuntimeAssetPath, serveLoopRuntimeAsset } from './handlers/cms/loop' import { handleHoleRequest, isHoleRuntimeAssetPath, serveHoleRuntimeAsset } from './handlers/cms/hole' @@ -25,33 +31,6 @@ import { mediaStorageRegistry } from '@core/plugins/mediaStorageRegistry' const VITE_DEV_URL = 'http://localhost:5173' -interface ServerRuntime { - db: DbClient - staticDir?: string - uploadsDir?: string - /** - * The raw `DATABASE_URL` the server booted with — forwarded down to - * CMS handlers that need to resolve the on-disk SQLite file (e.g. the - * storage dashboard widget). - */ - databaseUrl?: string -} - -/** - * A route handler returns a `Response` if it owns the request, or `null` if - * the URL/method doesn't match — the dispatcher walks the `routes` table and - * returns the first non-null response. Prefix-namespaced handlers (e.g. - * `/_instatic/css/`, `/_instatic/runtime/cache/`) absorb their entire namespace and emit - * a 404 themselves rather than falling through, so unknown paths under a - * known prefix can't accidentally match a later route. - */ -type RouteHandler = ( - req: Request, - runtime: ServerRuntime, - url: URL, - pathname: string, -) => Promise | Response | null - // --------------------------------------------------------------------------- // Dispatcher // --------------------------------------------------------------------------- @@ -78,6 +57,10 @@ const routes: readonly RouteHandler[] = [ // runtime rewrite. The site editor now POSTs `/admin/api/ai/chat/site`. tryServeAi, tryServeCmsApi, + // Branch preview links — `/_instatic/preview/` sets the preview + // cookie, `/_instatic/preview/exit` clears it. Public GETs below then + // render the branch's draft while the cookie names a live link. + tryServeBranchPreviewLink, tryServeLoopRuntimeAsset, tryServeLoop, tryServeHoleRuntimeAsset, @@ -191,6 +174,7 @@ function tryServeCmsApi(req: Request, runtime: ServerRuntime, _url: URL, pathnam return handleCmsRequest(req, runtime.db, { uploadsDir: runtime.uploadsDir, databaseUrl: runtime.databaseUrl, + collabRelay: runtime.collabRelay, }) } @@ -258,6 +242,15 @@ async function tryServeRuntimeAsset(req: Request, runtime: ServerRuntime, _url: 'content-security-policy': "default-src 'none'", } + // Branch preview bundles live in memory and are never cached by the + // browser beyond their content-derived build id. + const previewAsset = readPreviewAsset(pathname) + if (previewAsset) { + return binaryResponse(previewAsset.bytes, { + headers: { 'content-type': previewAsset.contentType, 'cache-control': 'no-store', ...hardening }, + }) + } + // Disk-first: a full publish bakes the runtime JS into the active slot, so // published pages serve their scripts straight off disk (no DB round-trip, // no rebuild). Content-hashed filenames keep `immutable` caching correct. @@ -489,48 +482,6 @@ async function tryServeAdminApp( return adminUiNotBuiltResponse(pathname) } -/** - * Single entry for every visitor-facing HTML URL — stand-alone published - * pages (`/about`), content rows rendered through their postType's entry - * template (`/posts/hello-world`), and row-slug redirects. - * - * Resolution + render live in `server/publish/publicRouter.ts`. - * `renderPublicResolution` handles the full request: Layer A disk - * fast-path (pre-rendered static artefacts via `readArtefact`), then - * `resolvePublicRoute`, then the live renderer + `applyPublishedHtmlPipeline`. - */ -async function tryServePublicRoute(req: Request, runtime: ServerRuntime, url: URL, _pathname: string): Promise { - if (req.method !== 'GET') return null - return await renderPublicResolution(runtime.db, url, runtime.uploadsDir) -} - -/** - * On a fresh install with no admin user yet, bounce the visitor to /admin so - * they land in the setup wizard instead of seeing a confusing 404. Returns - * null when the install is already past setup. - */ -async function trySetupRedirect(req: Request, runtime: ServerRuntime, _url: URL, _pathname: string): Promise { - if (req.method !== 'GET') return null - // Sticky memo: once setup completes, this stops querying. Without it every - // unmatched GET (bot probes, 404s) paid two COUNT queries forever. - const setupStatus = await getSetupStatusCached(runtime.db) - return setupStatus.needsSetup - ? new Response(null, { status: 302, headers: { location: '/admin' } }) - : null -} - -/** - * Last route before the dispatcher's bare JSON 404: serve the site's designed - * 404 page (the `notFound` template) for any GET no other route claimed. - * Namespaced prefixes (`/admin/api/*`, `/_instatic/*`, `/uploads/*`) never - * reach here — they absorb their namespace and emit their own 404s. Returns - * null (→ JSON 404) when the published site has no notFound template. - */ -async function tryServeNotFoundPage(req: Request, runtime: ServerRuntime, url: URL, _pathname: string): Promise { - if (req.method !== 'GET') return null - return await renderNotFoundResponse(runtime.db, url, runtime.uploadsDir) -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/server/serverRuntime.ts b/server/serverRuntime.ts new file mode 100644 index 000000000..96a684aad --- /dev/null +++ b/server/serverRuntime.ts @@ -0,0 +1,36 @@ +/** + * The dispatcher's runtime contract — what `server/index.ts` hands every + * route handler. Lives apart from `router.ts` so route modules that the + * dispatcher imports (`server/publish/publicRoutes.ts`) can type themselves + * without importing the dispatcher back. + */ +import type { DbClient } from './db/client' +import type { CollabRelay } from './collab/relay' + +export interface ServerRuntime { + db: DbClient + staticDir?: string + uploadsDir?: string + /** + * The raw `DATABASE_URL` the server booted with — forwarded down to + * CMS handlers that need to resolve the on-disk SQLite file (e.g. the + * storage dashboard widget). + */ + databaseUrl?: string + collabRelay?: CollabRelay +} + +/** + * A route handler returns a `Response` if it owns the request, or `null` if + * the URL/method doesn't match — the dispatcher walks the `routes` table and + * returns the first non-null response. Prefix-namespaced handlers (e.g. + * `/_instatic/css/`, `/_instatic/runtime/cache/`) absorb their entire namespace and emit + * a 404 themselves rather than falling through, so unknown paths under a + * known prefix can't accidentally match a later route. + */ +export type RouteHandler = ( + req: Request, + runtime: ServerRuntime, + url: URL, + pathname: string, +) => Promise | Response | null diff --git a/src/__tests__/agent/agentTools.test.ts b/src/__tests__/agent/agentTools.test.ts index 68247140c..14ed3bb4a 100644 --- a/src/__tests__/agent/agentTools.test.ts +++ b/src/__tests__/agent/agentTools.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, beforeAll } from 'bun:test' import type { SiteAgentSnapshot } from '@site/agent/siteAgentSnapshot' import type { AiTool, ToolContext } from '../../../server/ai/runtime/types' +import { MAIN_SCOPE } from '../../../server/branches/scope' import { makePage, makeSite } from '../publisher/helpers' import type { VisualComponent } from '@core/visualComponents' @@ -104,6 +105,7 @@ describe('site read tools', () => { rows: [ { id: 'tbl_posts', + logical_id: 'tbl_posts', name: 'Posts', slug: 'posts', kind: 'postType', @@ -125,6 +127,7 @@ describe('site read tools', () => { }, ], }), + branch: MAIN_SCOPE, } as unknown as ToolContext const result = (await tool.handler!({}, ctx)) as { diff --git a/src/__tests__/ai/mcpContextTool.test.ts b/src/__tests__/ai/mcpContextTool.test.ts index 74d726d27..b1d15156a 100644 --- a/src/__tests__/ai/mcpContextTool.test.ts +++ b/src/__tests__/ai/mcpContextTool.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test' import { createCapabilityTestHarness, type CapabilityTestHarness } from '../helpers/capabilityHarness' import { contextMcpTools } from '../../../server/ai/mcp/tools/contextTool' import { createEditorBridgeStream } from '../../../server/ai/mcp/editorBridge' +import { MAIN_SCOPE } from '../../../server/branches/scope' import type { ToolContext } from '../../../server/ai/runtime/types' function ctxFor(harness: CapabilityTestHarness): ToolContext { @@ -10,6 +11,7 @@ function ctxFor(harness: CapabilityTestHarness): ToolContext { userId: 'no-editor-user', capabilities: ['site.read'], scope: 'site', + branch: MAIN_SCOPE, conversationId: 'test', snapshot: null, signal: new AbortController().signal, diff --git a/src/__tests__/ai/mcpStyleTool.test.ts b/src/__tests__/ai/mcpStyleTool.test.ts index 905ccfe47..cba5b7e3c 100644 --- a/src/__tests__/ai/mcpStyleTool.test.ts +++ b/src/__tests__/ai/mcpStyleTool.test.ts @@ -3,9 +3,10 @@ import { createCapabilityTestHarness, type CapabilityTestHarness } from '../help import { styleMcpTools } from '../../../server/ai/mcp/tools/styleTools' import { getDraftSite, saveDraftSite } from '../../../server/repositories/site' import type { ToolContext } from '../../../server/ai/runtime/types' +import { MAIN_SCOPE } from '../../../server/branches/scope' async function seedClass(harness: CapabilityTestHarness): Promise { - const site = await getDraftSite(harness.db) + const site = await getDraftSite(harness.db, MAIN_SCOPE) if (!site) throw new Error('no default site') const now = Date.now() site.styleRules['r_testcard'] = { @@ -19,7 +20,7 @@ async function seedClass(harness: CapabilityTestHarness): Promise { createdAt: now, updatedAt: now, } - await saveDraftSite(harness.db, site) + await saveDraftSite(harness.db, MAIN_SCOPE, site) } function ctxFor(harness: CapabilityTestHarness): ToolContext { @@ -28,6 +29,7 @@ function ctxFor(harness: CapabilityTestHarness): ToolContext { userId: 'u1', capabilities: ['site.read'], scope: 'site', + branch: MAIN_SCOPE, conversationId: 'test', snapshot: null, // headless — no browser snapshot, unlike the old list_tokens signal: new AbortController().signal, @@ -65,13 +67,13 @@ describe('read_styles (headless design-system read)', () => { }) it('summary mode returns a compact catalog (selector + token refs, no declarations)', async () => { - const site = await getDraftSite(harness.db) + const site = await getDraftSite(harness.db, MAIN_SCOPE) const now = Date.now() site!.styleRules['r_tok'] = { id: 'r_tok', name: 'tok-card', kind: 'class', selector: '.tok-card', order: 0, styles: { color: 'var(--ist-accent)', padding: '8px' }, contextStyles: {}, createdAt: now, updatedAt: now, } - await saveDraftSite(harness.db, site!) + await saveDraftSite(harness.db, MAIN_SCOPE, site!) const out = (await readStyles.handler!({ format: 'summary' }, ctxFor(harness))) as { classes: Array<{ selector: string; tokens: string[] }> } diff --git a/src/__tests__/architecture/branch-scope-repositories.test.ts b/src/__tests__/architecture/branch-scope-repositories.test.ts new file mode 100644 index 000000000..6dfd21e02 --- /dev/null +++ b/src/__tests__/architecture/branch-scope-repositories.test.ts @@ -0,0 +1,137 @@ +/** + * Architecture Gate — branch scope on the branched tables. + * + * Every row of `site`, `data_tables`, and `data_rows` belongs to a branch + * (see `docs/features/branches.md`). Reading or writing them without saying + * which branch is how one branch's content leaks into another, so: + * + * 1. Every exported function in the repositories that own those tables + * takes a `BranchScope` parameter, except the main-only publish paths + * listed below with their justification. + * 2. Any raw SQL on those tables OUTSIDE the repositories names `branch_id` + * — either bound from a scope or pinned to `'main'` — unless the file is + * allowlisted below because a physical id already carries the branch. + * 3. The physical-id scheme (`:`) is minted in exactly one + * place, `src/core/branches/ids.ts`; nobody else joins ids with a colon. + */ +import { describe, expect, it } from 'bun:test' +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' +import { extname, join, relative } from 'node:path' + +const ROOT = join(import.meta.dir, '../../..') + +const SCOPED_REPOSITORY_FILES = [ + 'server/repositories/site.ts', + 'server/repositories/data/tables.ts', + 'server/repositories/data/rows/read.ts', + 'server/repositories/data/rows/mutations.ts', + 'server/repositories/data/rows/bulk.ts', + 'server/repositories/data/rows/apply.ts', + 'server/repositories/data/rows/filter.ts', + 'server/repositories/data/rows/search.ts', + 'server/repositories/data/rows/schedule.ts', + 'server/repositories/data/rows/import.ts', +] + +/** + * Exported repository functions that legitimately have no scope parameter. + * §1 — main-only by definition: the scheduler only ever publishes main rows. + * §2 — reads users, not a branched table. + */ +const SCOPELESS_REPOSITORY_FUNCTIONS = new Set([ + 'listDuePublishSchedules', // §1 + 'listDataAuthorOptions', // §2 + 'siteRowId', // helper that derives the physical shell key from a scope +]) + +/** + * Files outside the repositories with raw SQL on a branched table where the + * branch is carried by a physical id instead of a `branch_id` predicate. + * §3 — the loop source binds `physicalId(branchId, tableId)` from `@core/branches`. + * §4 — the setup screen reads the main shell by its well-known physical key `default`. + */ +const RAW_SQL_ALLOWLIST = new Set([ + 'src/core/loops/sources/dataRows.ts', // §3 + 'server/handlers/cms/setup.ts', // §4 +]) + +const RAW_SQL_SCAN_ROOTS = ['server', 'src/core'] +const RAW_SQL_EXEMPT_PREFIXES = [ + 'server/repositories/', + 'server/branches/', + 'server/db/', +] + +const BRANCHED_TABLE_SQL = /\b(from|into|update|join)\s+(data_rows|data_tables|site)\b/i + +function read(path: string): string { + return readFileSync(join(ROOT, path), 'utf8') +} + +function collectFiles(dir: string): string[] { + const results: string[] = [] + if (!existsSync(dir)) return results + for (const entry of readdirSync(dir)) { + const full = join(dir, entry) + if (statSync(full).isDirectory()) { + if (entry === '__tests__' || entry === 'node_modules') continue + results.push(...collectFiles(full)) + } else if (extname(entry) === '.ts' && !entry.endsWith('.test.ts')) { + results.push(full) + } + } + return results +} + +function exportedFunctions(source: string): Array<{ name: string; params: string }> { + const out: Array<{ name: string; params: string }> = [] + const re = /export\s+(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)/g + for (const match of source.matchAll(re)) { + out.push({ name: match[1], params: match[2] }) + } + return out +} + +describe('branch-scope-repositories', () => { + it('every scoped repository export takes a BranchScope', () => { + const offenders: string[] = [] + for (const file of SCOPED_REPOSITORY_FILES) { + for (const fn of exportedFunctions(read(file))) { + if (SCOPELESS_REPOSITORY_FUNCTIONS.has(fn.name)) continue + if (!/scope\s*:\s*BranchScope/.test(fn.params)) { + offenders.push(`${file} → ${fn.name}(${fn.params.replace(/\s+/g, ' ').trim()})`) + } + } + } + expect(offenders).toEqual([]) + }) + + it('raw SQL on a branched table outside the repositories names branch_id', () => { + const offenders: string[] = [] + for (const root of RAW_SQL_SCAN_ROOTS) { + for (const full of collectFiles(join(ROOT, root))) { + const rel = relative(ROOT, full) + if (RAW_SQL_EXEMPT_PREFIXES.some((prefix) => rel.startsWith(prefix))) continue + if (RAW_SQL_ALLOWLIST.has(rel)) continue + const source = readFileSync(full, 'utf8') + if (!BRANCHED_TABLE_SQL.test(source)) continue + if (!/\bbranch_id\b/.test(source)) offenders.push(rel) + } + } + expect(offenders).toEqual([]) + }) + + it('the physical id scheme is minted only in @core/branches', () => { + const offenders: string[] = [] + const roots = ['server', 'src'].map((root) => join(ROOT, root)) + for (const root of roots) { + for (const full of collectFiles(root)) { + const rel = relative(ROOT, full) + if (rel === 'src/core/branches/ids.ts') continue + const source = readFileSync(full, 'utf8') + if (/\$\{\s*(?:scope\.)?branchId\s*\}:\$\{/.test(source)) offenders.push(rel) + } + } + expect(offenders).toEqual([]) + }) +}) diff --git a/src/__tests__/architecture/bundle-size-budgets.test.ts b/src/__tests__/architecture/bundle-size-budgets.test.ts index 194aed9cf..8dae203c3 100644 --- a/src/__tests__/architecture/bundle-size-budgets.test.ts +++ b/src/__tests__/architecture/bundle-size-budgets.test.ts @@ -123,9 +123,12 @@ const BUDGETS: ChunkBudget[] = [ // can paint the existing toolbar/chrome before the editor body downloads. { prefix: 'SitePage-', - maxBytes: 30_000, + // Raised from 30 KB when site branches landed: the publish gate (disabled + // with the inline reason on a branch), the version-history entry, and the + // per-branch persistence hook are part of the shell by design (~+1 KB raw). + maxBytes: 32_000, rationale: - 'site route shell (current ~22 KB raw / ~9 KB gzipped). Must not ' + + 'site route shell (current ~31 KB raw / ~10 KB gzipped). Must not ' + 'pull the visual editor body, DnD, canvas, first-party modules, or ' + 'PropertiesPanel back into the active route chunk.', }, diff --git a/src/__tests__/architecture/cmsTransferExport.test.ts b/src/__tests__/architecture/cmsTransferExport.test.ts index d72523272..837c66c1a 100644 --- a/src/__tests__/architecture/cmsTransferExport.test.ts +++ b/src/__tests__/architecture/cmsTransferExport.test.ts @@ -38,6 +38,7 @@ import type { SiteBundle } from '@core/data/bundleSchema' import { BUNDLE_ARCHIVE_MANIFEST_PATH } from '@core/data/bundleArchive' import type { DbClient } from '../../../server/db/client' import type { SiteShell } from '@core/page-tree' +import { MAIN_SCOPE } from '../../../server/branches/scope' // --------------------------------------------------------------------------- // Minimal valid site shell for seeding @@ -64,7 +65,7 @@ const TEST_SHELL: SiteShell = { // --------------------------------------------------------------------------- async function seedAuth(db: DbClient): Promise { - await saveDraftSite(db, TEST_SHELL) + await saveDraftSite(db, MAIN_SCOPE, TEST_SHELL) await createUser(db, { id: 'test-owner', email: 'owner@export.test', @@ -159,7 +160,7 @@ beforeAll(async () => { cookie = await seedAuth(db) // Create 1 custom table ("My Data") - await createDataTable(db, { + await createDataTable(db, MAIN_SCOPE, { id: CUSTOM_TABLE_ID, name: 'My Data', slug: 'my-data-test', @@ -169,12 +170,12 @@ beforeAll(async () => { }) // Seed 2 rows in posts - const p1 = await createDataRow(db, { + const p1 = await createDataRow(db, MAIN_SCOPE, { tableId: 'posts', cells: { title: 'Post One', slug: 'post-one' }, slug: 'post-one', }) - const p2 = await createDataRow(db, { + const p2 = await createDataRow(db, MAIN_SCOPE, { tableId: 'posts', cells: { title: 'Post Two', slug: 'post-two' }, slug: 'post-two', @@ -183,7 +184,7 @@ beforeAll(async () => { post2Id = p2.id // Seed 1 row in pages - const pg = await createDataRow(db, { + const pg = await createDataRow(db, MAIN_SCOPE, { tableId: 'pages', cells: { title: 'Home Page', slug: 'home', body: { nodes: {}, rootNodeId: 'root' } }, slug: 'home', @@ -191,7 +192,7 @@ beforeAll(async () => { pageId = pg.id // Seed 1 row in My Data - const cr = await createDataRow(db, { + const cr = await createDataRow(db, MAIN_SCOPE, { tableId: CUSTOM_TABLE_ID, cells: { name: 'Custom Item One' }, slug: '', @@ -206,7 +207,7 @@ beforeAll(async () => { describe('handleExportRoute — GET no filters', () => { test('returns all 4 rows across all tables', async () => { const req = makeGetRequest('/admin/api/cms/export', cookie) - const res = await handleExportRoute(req, db) + const res = await handleExportRoute(req, db, MAIN_SCOPE) expect(res).not.toBeNull() const { bundle } = await readExportArchive(res!) @@ -216,7 +217,7 @@ describe('handleExportRoute — GET no filters', () => { test('includes all tables (system + custom)', async () => { const req = makeGetRequest('/admin/api/cms/export', cookie) - const res = await handleExportRoute(req, db) + const res = await handleExportRoute(req, db, MAIN_SCOPE) const { bundle } = await readExportArchive(res!) const tableIds = bundle.tables.map((t) => t.id) @@ -228,7 +229,7 @@ describe('handleExportRoute — GET no filters', () => { test('site shell is present by default', async () => { const req = makeGetRequest('/admin/api/cms/export', cookie) - const res = await handleExportRoute(req, db) + const res = await handleExportRoute(req, db, MAIN_SCOPE) const { bundle } = await readExportArchive(res!) expect(bundle.site).toBeDefined() @@ -237,7 +238,7 @@ describe('handleExportRoute — GET no filters', () => { test('sourceSiteName is set from the site shell name', async () => { const req = makeGetRequest('/admin/api/cms/export', cookie) - const res = await handleExportRoute(req, db) + const res = await handleExportRoute(req, db, MAIN_SCOPE) const { bundle } = await readExportArchive(res!) expect(bundle.sourceSiteName).toBe('Transfer Test Site') @@ -245,7 +246,7 @@ describe('handleExportRoute — GET no filters', () => { test('media is absent (not requested)', async () => { const req = makeGetRequest('/admin/api/cms/export', cookie) - const res = await handleExportRoute(req, db) + const res = await handleExportRoute(req, db, MAIN_SCOPE) const { bundle } = await readExportArchive(res!) expect(bundle.media).toBeUndefined() @@ -255,7 +256,7 @@ describe('handleExportRoute — GET no filters', () => { describe('handleExportRoute — GET ?tables=posts', () => { test('returns only the posts table', async () => { const req = makeGetRequest('/admin/api/cms/export?tables=posts', cookie) - const res = await handleExportRoute(req, db) + const res = await handleExportRoute(req, db, MAIN_SCOPE) const { bundle } = await readExportArchive(res!) expect(bundle.tables.length).toBe(1) @@ -264,7 +265,7 @@ describe('handleExportRoute — GET ?tables=posts', () => { test('returns only the 2 posts rows', async () => { const req = makeGetRequest('/admin/api/cms/export?tables=posts', cookie) - const res = await handleExportRoute(req, db) + const res = await handleExportRoute(req, db, MAIN_SCOPE) const { bundle } = await readExportArchive(res!) expect(bundle.rows.length).toBe(2) @@ -273,7 +274,7 @@ describe('handleExportRoute — GET ?tables=posts', () => { test('site is still present when only tables are filtered', async () => { const req = makeGetRequest('/admin/api/cms/export?tables=posts', cookie) - const res = await handleExportRoute(req, db) + const res = await handleExportRoute(req, db, MAIN_SCOPE) const { bundle } = await readExportArchive(res!) expect(bundle.site).toBeDefined() @@ -285,7 +286,7 @@ describe('handleExportRoute — POST { tables: [{ tableId: "posts", rowIds: [id1 const req = makePostRequest('/admin/api/cms/export', cookie, { tables: [{ tableId: 'posts', rowIds: [post1Id, post2Id] }], }) - const res = await handleExportRoute(req, db) + const res = await handleExportRoute(req, db, MAIN_SCOPE) const { bundle } = await readExportArchive(res!) const returnedIds = bundle.rows.map((r) => r.id) @@ -300,7 +301,7 @@ describe('handleExportRoute — POST { tables: [{ tableId: "posts", rowIds: [id1 const req = makePostRequest('/admin/api/cms/export', cookie, { tables: [{ tableId: 'posts', rowIds: [post1Id, post2Id] }], }) - const res = await handleExportRoute(req, db) + const res = await handleExportRoute(req, db, MAIN_SCOPE) const { bundle } = await readExportArchive(res!) const tableIds = bundle.tables.map((t) => t.id) @@ -317,7 +318,7 @@ describe('handleExportRoute — POST { tables: [{ tableId: "posts", rowIds: [id1 includeMediaFolders: false, includeRedirects: false, }) - const res = await handleExportRoute(req, db, { uploadsDir: '/tmp/test-uploads-export' }) + const res = await handleExportRoute(req, db, MAIN_SCOPE, { uploadsDir: '/tmp/test-uploads-export' }) const { bundle } = await readExportArchive(res!) expect(bundle.site).toBeUndefined() @@ -359,7 +360,7 @@ describe('handleExportRoute — GET ?includeMedia=1', () => { }) const req = makeGetRequest('/admin/api/cms/export?includeMedia=1', mediaCookie) - const res = await handleExportRoute(req, mediaDb, { uploadsDir }) + const res = await handleExportRoute(req, mediaDb, MAIN_SCOPE, { uploadsDir }) const { bundle, entries } = await readExportArchive(res!) expect(bundle.media?.map((asset) => asset.id)).toEqual(['asset-logo']) @@ -374,7 +375,7 @@ describe('handleExportRoute — GET ?includeMedia=1', () => { describe('handleExportRoute — GET ?includeSite=0', () => { test('site shell is absent', async () => { const req = makeGetRequest('/admin/api/cms/export?includeSite=0', cookie) - const res = await handleExportRoute(req, db) + const res = await handleExportRoute(req, db, MAIN_SCOPE) const { bundle } = await readExportArchive(res!) expect(bundle.site).toBeUndefined() @@ -382,7 +383,7 @@ describe('handleExportRoute — GET ?includeSite=0', () => { test('sourceSiteName is still set even when includeSite=0', async () => { const req = makeGetRequest('/admin/api/cms/export?includeSite=0', cookie) - const res = await handleExportRoute(req, db) + const res = await handleExportRoute(req, db, MAIN_SCOPE) const { bundle } = await readExportArchive(res!) expect(bundle.sourceSiteName).toBe('Transfer Test Site') @@ -395,7 +396,7 @@ describe('handleExportRoute — POST { tables: ["pages"], includeSite: false }', tables: [{ tableId: 'pages' }], includeSite: false, }) - const res = await handleExportRoute(req, db) + const res = await handleExportRoute(req, db, MAIN_SCOPE) const { bundle } = await readExportArchive(res!) expect(bundle.tables.length).toBe(1) @@ -407,7 +408,7 @@ describe('handleExportRoute — POST { tables: ["pages"], includeSite: false }', tables: [{ tableId: 'pages' }], includeSite: false, }) - const res = await handleExportRoute(req, db) + const res = await handleExportRoute(req, db, MAIN_SCOPE) const { bundle } = await readExportArchive(res!) expect(bundle.rows.length).toBe(1) @@ -420,7 +421,7 @@ describe('handleExportRoute — POST { tables: ["pages"], includeSite: false }', tables: [{ tableId: 'pages' }], includeSite: false, }) - const res = await handleExportRoute(req, db) + const res = await handleExportRoute(req, db, MAIN_SCOPE) const { bundle } = await readExportArchive(res!) expect(bundle.site).toBeUndefined() @@ -432,7 +433,7 @@ describe('handleExportRoute — POST { tables: [{ tableId: "pages", rowIds: [bog const req = makePostRequest('/admin/api/cms/export', cookie, { tables: [{ tableId: 'pages', rowIds: ['completely-bogus-row-id-that-does-not-exist'] }], }) - const res = await handleExportRoute(req, db) + const res = await handleExportRoute(req, db, MAIN_SCOPE) const { bundle } = await readExportArchive(res!) expect(bundle.rows.length).toBe(0) @@ -442,7 +443,7 @@ describe('handleExportRoute — POST { tables: [{ tableId: "pages", rowIds: [bog const req = makePostRequest('/admin/api/cms/export', cookie, { tables: [{ tableId: 'pages', rowIds: ['completely-bogus-row-id-that-does-not-exist'] }], }) - const res = await handleExportRoute(req, db) + const res = await handleExportRoute(req, db, MAIN_SCOPE) const { bundle } = await readExportArchive(res!) // New model: a table named in `tables` is exported (its structure) even @@ -456,7 +457,7 @@ describe('handleExportRoute — auth', () => { test('returns 401 when no session cookie', async () => { // Deliberately no cookie set const req = new Request('http://localhost/admin/api/cms/export', { method: 'GET' }) - const res = await handleExportRoute(req, db) + const res = await handleExportRoute(req, db, MAIN_SCOPE) expect(res!.status).toBe(401) }) }) @@ -468,7 +469,7 @@ describe('handleExportRoute — auth', () => { // --------------------------------------------------------------------------- async function estimateBytes(path: string, body: unknown, cookieStr: string, opts?: { uploadsDir?: string }): Promise { - const res = await handleExportRoute(makePostRequest(path, cookieStr, body), db, opts) + const res = await handleExportRoute(makePostRequest(path, cookieStr, body), db, MAIN_SCOPE, opts) expect(res!.status).toBe(200) const parsed = JSON.parse(await res!.text()) as { bytes: number } return parsed.bytes @@ -476,7 +477,7 @@ async function estimateBytes(path: string, body: unknown, cookieStr: string, opt describe('handleExportRoute — POST /export/estimate', () => { test('estimate equals the real download byte length exactly (no media)', async () => { - const dl = await handleExportRoute(makePostRequest('/admin/api/cms/export', cookie, {}), db) + const dl = await handleExportRoute(makePostRequest('/admin/api/cms/export', cookie, {}), db, MAIN_SCOPE) const realBytes = (await dl!.arrayBuffer()).byteLength const bytes = await estimateBytes('/admin/api/cms/export/estimate', {}, cookie) @@ -491,6 +492,7 @@ describe('handleExportRoute — POST /export/estimate', () => { const realNoSite = await handleExportRoute( makePostRequest('/admin/api/cms/export', cookie, { includeSite: false }), db, + MAIN_SCOPE, ) expect(withoutSite).toBe((await realNoSite!.arrayBuffer()).byteLength) }) @@ -501,7 +503,7 @@ describe('handleExportRoute — POST /export/estimate', () => { headers: { 'content-type': 'application/json' }, body: '{}', }) - const res = await handleExportRoute(req, db) + const res = await handleExportRoute(req, db, MAIN_SCOPE) expect(res!.status).toBe(401) }) }) @@ -533,6 +535,7 @@ describe('handleExportRoute — POST /export/estimate with embedded media', () = const dl = await handleExportRoute( makePostRequest('/admin/api/cms/export', mediaCookie, { includeMedia: true }), mediaDb, + MAIN_SCOPE, { uploadsDir }, ) const realBytes = (await dl!.arrayBuffer()).byteLength @@ -540,6 +543,7 @@ describe('handleExportRoute — POST /export/estimate with embedded media', () = const estRes = await handleExportRoute( makePostRequest('/admin/api/cms/export/estimate', mediaCookie, { includeMedia: true }), mediaDb, + MAIN_SCOPE, { uploadsDir }, ) const { bytes } = JSON.parse(await estRes!.text()) as { bytes: number } diff --git a/src/__tests__/architecture/cmsTransferImport.test.ts b/src/__tests__/architecture/cmsTransferImport.test.ts index 8299459c8..031f876d1 100644 --- a/src/__tests__/architecture/cmsTransferImport.test.ts +++ b/src/__tests__/architecture/cmsTransferImport.test.ts @@ -36,6 +36,7 @@ import { createDefaultSiteExplorerOrganization } from '@core/page-tree' import type { DbClient } from '../../../server/db/client' import type { SiteShell } from '@core/page-tree' import type { DataRow, DataTable } from '@core/data/schemas' +import { MAIN_SCOPE } from '../../../server/branches/scope' // --------------------------------------------------------------------------- // Minimal valid site shell for seeding @@ -72,7 +73,7 @@ const BUNDLE_SHELL: SiteShell = { // --------------------------------------------------------------------------- async function seedAuth(db: DbClient): Promise { - await saveDraftSite(db, TEST_SHELL) + await saveDraftSite(db, MAIN_SCOPE, TEST_SHELL) await createUser(db, { id: 'test-owner', email: 'owner@import.test', @@ -185,12 +186,12 @@ describe('handleImportRoute — strategy: replace', () => { // Seed 2 local posts: one that OVERLAPS with the bundle (overlapId), // one that is LOCAL-ONLY (localOnlyId — should be deleted by replace) - const overlap = await createDataRow(db, { + const overlap = await createDataRow(db, MAIN_SCOPE, { tableId: 'posts', cells: { title: 'Local Overlap', slug: 'overlap' }, slug: 'overlap', }) - const localOnly = await createDataRow(db, { + const localOnly = await createDataRow(db, MAIN_SCOPE, { tableId: 'posts', cells: { title: 'Local Only', slug: 'local-only' }, slug: 'local-only', @@ -213,7 +214,7 @@ describe('handleImportRoute — strategy: replace', () => { } const req = makeImportRequest(cookie, 'replace', bundle) - const res = await handleImportRoute(req, db) + const res = await handleImportRoute(req, db, MAIN_SCOPE) expect(res).not.toBeNull() expect(res!.status).toBe(200) @@ -230,13 +231,13 @@ describe('handleImportRoute — strategy: replace', () => { }) test('local-only row is GONE after replace', async () => { - const allRows = await listDataRows(db, 'posts') + const allRows = await listDataRows(db, MAIN_SCOPE, 'posts') const ids = allRows.map((r) => r.id) expect(ids).not.toContain(localOnlyId) }) test('bundle rows are present in the DB', async () => { - const allRows = await listDataRows(db, 'posts') + const allRows = await listDataRows(db, MAIN_SCOPE, 'posts') const ids = allRows.map((r) => r.id) expect(ids).toContain(overlapId) expect(ids).toContain('bundle-new-a') @@ -244,14 +245,14 @@ describe('handleImportRoute — strategy: replace', () => { }) test('overlap row has bundle cells after replace', async () => { - const allRows = await listDataRows(db, 'posts') + const allRows = await listDataRows(db, MAIN_SCOPE, 'posts') const overlap = allRows.find((r) => r.id === overlapId) expect(overlap).toBeDefined() expect(overlap!.cells['title']).toBe('Bundle Overlap') }) test('site shell is overwritten from the bundle', async () => { - const shell = await getDraftSite(db) + const shell = await getDraftSite(db, MAIN_SCOPE) expect(shell).not.toBeNull() expect(shell!.name).toBe('Bundle Site Name') }) @@ -272,12 +273,12 @@ describe('handleImportRoute — strategy: merge-add', () => { await runMigrations(db, sqliteMigrations) cookie = await seedAuth(db) - const overlap = await createDataRow(db, { + const overlap = await createDataRow(db, MAIN_SCOPE, { tableId: 'posts', cells: { title: 'Local Overlap', slug: 'overlap' }, slug: 'overlap', }) - const localOnly = await createDataRow(db, { + const localOnly = await createDataRow(db, MAIN_SCOPE, { tableId: 'posts', cells: { title: 'Local Only', slug: 'local-only' }, slug: 'local-only', @@ -302,7 +303,7 @@ describe('handleImportRoute — strategy: merge-add', () => { } const req = makeImportRequest(cookie, 'merge-add', bundle) - const res = await handleImportRoute(req, db) + const res = await handleImportRoute(req, db, MAIN_SCOPE) const body = JSON.parse(await res!.text()) const result = parseValue(ImportResultSchema, body) @@ -314,20 +315,20 @@ describe('handleImportRoute — strategy: merge-add', () => { }) test('local-only row is still present after merge-add (untouched)', async () => { - const allRows = await listDataRows(db, 'posts') + const allRows = await listDataRows(db, MAIN_SCOPE, 'posts') const ids = allRows.map((r) => r.id) expect(ids).toContain(localOnlyId) }) test('new bundle rows are added', async () => { - const allRows = await listDataRows(db, 'posts') + const allRows = await listDataRows(db, MAIN_SCOPE, 'posts') const ids = allRows.map((r) => r.id) expect(ids).toContain('merge-add-new-a') expect(ids).toContain('merge-add-new-b') }) test('overlapping row cells are NOT overwritten (local version preserved)', async () => { - const allRows = await listDataRows(db, 'posts') + const allRows = await listDataRows(db, MAIN_SCOPE, 'posts') const overlap = allRows.find((r) => r.id === overlapId) expect(overlap).toBeDefined() // Local version kept — bundle version skipped @@ -335,7 +336,7 @@ describe('handleImportRoute — strategy: merge-add', () => { }) test('site shell is NOT overwritten by merge-add', async () => { - const shell = await getDraftSite(db) + const shell = await getDraftSite(db, MAIN_SCOPE) expect(shell).not.toBeNull() // Local shell name, not the bundle's shell name expect(shell!.name).toBe('Import Test Site') @@ -357,12 +358,12 @@ describe('handleImportRoute — strategy: merge-overwrite', () => { await runMigrations(db, sqliteMigrations) cookie = await seedAuth(db) - const overlap = await createDataRow(db, { + const overlap = await createDataRow(db, MAIN_SCOPE, { tableId: 'posts', cells: { title: 'Local Overlap', slug: 'overlap' }, slug: 'overlap', }) - const localOnly = await createDataRow(db, { + const localOnly = await createDataRow(db, MAIN_SCOPE, { tableId: 'posts', cells: { title: 'Local Only', slug: 'local-only' }, slug: 'local-only', @@ -387,7 +388,7 @@ describe('handleImportRoute — strategy: merge-overwrite', () => { } const req = makeImportRequest(cookie, 'merge-overwrite', bundle) - const res = await handleImportRoute(req, db) + const res = await handleImportRoute(req, db, MAIN_SCOPE) const body = JSON.parse(await res!.text()) const result = parseValue(ImportResultSchema, body) @@ -399,20 +400,20 @@ describe('handleImportRoute — strategy: merge-overwrite', () => { }) test('local-only row is still present after merge-overwrite (untouched)', async () => { - const allRows = await listDataRows(db, 'posts') + const allRows = await listDataRows(db, MAIN_SCOPE, 'posts') const ids = allRows.map((r) => r.id) expect(ids).toContain(localOnlyId) }) test('new bundle rows are added', async () => { - const allRows = await listDataRows(db, 'posts') + const allRows = await listDataRows(db, MAIN_SCOPE, 'posts') const ids = allRows.map((r) => r.id) expect(ids).toContain('mo-new-a') expect(ids).toContain('mo-new-b') }) test('overlapping row cells ARE overwritten (bundle version wins)', async () => { - const allRows = await listDataRows(db, 'posts') + const allRows = await listDataRows(db, MAIN_SCOPE, 'posts') const overlap = allRows.find((r) => r.id === overlapId) expect(overlap).toBeDefined() // Bundle version now present @@ -420,14 +421,14 @@ describe('handleImportRoute — strategy: merge-overwrite', () => { }) test('local-only row cells are unchanged after merge-overwrite', async () => { - const allRows = await listDataRows(db, 'posts') + const allRows = await listDataRows(db, MAIN_SCOPE, 'posts') const localOnly = allRows.find((r) => r.id === localOnlyId) expect(localOnly).toBeDefined() expect(localOnly!.cells['title']).toBe('Local Only') }) test('site shell IS overwritten by merge-overwrite when bundle has one', async () => { - const shell = await getDraftSite(db) + const shell = await getDraftSite(db, MAIN_SCOPE) expect(shell).not.toBeNull() expect(shell!.name).toBe('Bundle Site Name') }) @@ -450,7 +451,7 @@ describe('handleImportRoute — invalid strategy', () => { body: JSON.stringify(bundle), }) req.headers.set('cookie', cookie) - const res = await handleImportRoute(req, db) + const res = await handleImportRoute(req, db, MAIN_SCOPE) expect(res!.status).toBe(400) }) }) @@ -468,7 +469,7 @@ describe('handleImportRoute — auth', () => { headers: { 'content-type': 'application/json' }, body: JSON.stringify(bundle), }) - const res = await handleImportRoute(req, db) + const res = await handleImportRoute(req, db, MAIN_SCOPE) expect(res!.status).toBe(401) }) }) diff --git a/src/__tests__/architecture/cmsTransferPreview.test.ts b/src/__tests__/architecture/cmsTransferPreview.test.ts index d3b5a341b..2fb2acb14 100644 --- a/src/__tests__/architecture/cmsTransferPreview.test.ts +++ b/src/__tests__/architecture/cmsTransferPreview.test.ts @@ -37,6 +37,7 @@ import { BundlePreviewSchema } from '@core/data/bundleSchema' import type { DbClient } from '../../../server/db/client' import type { SiteShell } from '@core/page-tree' import type { DataRow, DataTable } from '@core/data/schemas' +import { MAIN_SCOPE } from '../../../server/branches/scope' // --------------------------------------------------------------------------- // Minimal valid site shell for seeding @@ -63,7 +64,7 @@ const TEST_SHELL: SiteShell = { // --------------------------------------------------------------------------- async function seedAuth(db: DbClient): Promise { - await saveDraftSite(db, TEST_SHELL) + await saveDraftSite(db, MAIN_SCOPE, TEST_SHELL) await createUser(db, { id: 'test-owner', email: 'owner@preview.test', @@ -166,7 +167,7 @@ describe('handleImportPreviewRoute — empty local + non-empty bundle', () => { } const req = makePreviewRequest(cookie, bundle) - const res = await handleImportPreviewRoute(req, db) + const res = await handleImportPreviewRoute(req, db, MAIN_SCOPE) expect(res).not.toBeNull() expect(res!.status).toBe(200) @@ -189,11 +190,11 @@ describe('handleImportPreviewRoute — 2 of 5 local rows overlap with bundle', ( const cookie = await seedAuth(db) // Seed 5 posts locally — 2 of them will share IDs with the bundle - const local1 = await createDataRow(db, { tableId: 'posts', cells: {}, slug: 'l1' }) - const local2 = await createDataRow(db, { tableId: 'posts', cells: {}, slug: 'l2' }) - const overlap1 = await createDataRow(db, { tableId: 'posts', cells: {}, slug: 'o1' }) - const overlap2 = await createDataRow(db, { tableId: 'posts', cells: {}, slug: 'o2' }) - const local5 = await createDataRow(db, { tableId: 'posts', cells: {}, slug: 'l5' }) + const local1 = await createDataRow(db, MAIN_SCOPE, { tableId: 'posts', cells: {}, slug: 'l1' }) + const local2 = await createDataRow(db, MAIN_SCOPE, { tableId: 'posts', cells: {}, slug: 'l2' }) + const overlap1 = await createDataRow(db, MAIN_SCOPE, { tableId: 'posts', cells: {}, slug: 'o1' }) + const overlap2 = await createDataRow(db, MAIN_SCOPE, { tableId: 'posts', cells: {}, slug: 'o2' }) + const local5 = await createDataRow(db, MAIN_SCOPE, { tableId: 'posts', cells: {}, slug: 'l5' }) // Bundle contains 4 rows: 2 overlap with local, 2 are new const bundle = { @@ -212,7 +213,7 @@ describe('handleImportPreviewRoute — 2 of 5 local rows overlap with bundle', ( void local1; void local2; void local5 const req = makePreviewRequest(cookie, bundle) - const res = await handleImportPreviewRoute(req, db) + const res = await handleImportPreviewRoute(req, db, MAIN_SCOPE) const body = JSON.parse(await res!.text()) const preview = parseValue(BundlePreviewSchema, body) @@ -231,7 +232,7 @@ describe('handleImportPreviewRoute — row slug conflicts', () => { await runMigrations(db, sqliteMigrations) const cookie = await seedAuth(db) - await createDataRow(db, { tableId: 'posts', cells: { title: 'Local', slug: 'shared' }, slug: 'shared' }) + await createDataRow(db, MAIN_SCOPE, { tableId: 'posts', cells: { title: 'Local', slug: 'shared' }, slug: 'shared' }) const bundle = { schemaVersion: 1, @@ -244,7 +245,7 @@ describe('handleImportPreviewRoute — row slug conflicts', () => { } const req = makePreviewRequest(cookie, bundle) - const res = await handleImportPreviewRoute(req, db) + const res = await handleImportPreviewRoute(req, db, MAIN_SCOPE) const body = JSON.parse(await res!.text()) const preview = parseValue(BundlePreviewSchema, body) @@ -276,7 +277,7 @@ describe('handleImportPreviewRoute — bundle table not present locally', () => } const req = makePreviewRequest(cookie, bundle) - const res = await handleImportPreviewRoute(req, db) + const res = await handleImportPreviewRoute(req, db, MAIN_SCOPE) const body = JSON.parse(await res!.text()) const preview = parseValue(BundlePreviewSchema, body) @@ -345,7 +346,7 @@ describe('handleImportPreviewRoute — totals.mediaEmbedded', () => { } const req = makePreviewRequest(cookie, bundle) - const res = await handleImportPreviewRoute(req, db) + const res = await handleImportPreviewRoute(req, db, MAIN_SCOPE) const body = JSON.parse(await res!.text()) const preview = parseValue(BundlePreviewSchema, body) @@ -367,7 +368,7 @@ describe('handleImportPreviewRoute — totals.mediaEmbedded', () => { } const req = makePreviewRequest(cookie, bundle) - const res = await handleImportPreviewRoute(req, db) + const res = await handleImportPreviewRoute(req, db, MAIN_SCOPE) const body = JSON.parse(await res!.text()) const preview = parseValue(BundlePreviewSchema, body) @@ -392,7 +393,7 @@ describe('handleImportPreviewRoute — meta fields', () => { } const req = makePreviewRequest(cookie, bundle) - const res = await handleImportPreviewRoute(req, db) + const res = await handleImportPreviewRoute(req, db, MAIN_SCOPE) const body = JSON.parse(await res!.text()) const preview = parseValue(BundlePreviewSchema, body) @@ -415,7 +416,7 @@ describe('handleImportPreviewRoute — meta fields', () => { } const req = makePreviewRequest(cookie, bundle) - const res = await handleImportPreviewRoute(req, db) + const res = await handleImportPreviewRoute(req, db, MAIN_SCOPE) const body = JSON.parse(await res!.text()) const preview = parseValue(BundlePreviewSchema, body) @@ -441,7 +442,7 @@ describe('handleImportPreviewRoute — totals.rows', () => { } const req = makePreviewRequest(cookie, bundle) - const res = await handleImportPreviewRoute(req, db) + const res = await handleImportPreviewRoute(req, db, MAIN_SCOPE) const body = JSON.parse(await res!.text()) const preview = parseValue(BundlePreviewSchema, body) @@ -462,7 +463,7 @@ describe('handleImportPreviewRoute — auth', () => { headers: { 'content-type': 'application/json' }, body: JSON.stringify(bundle), }) - const res = await handleImportPreviewRoute(req, db) + const res = await handleImportPreviewRoute(req, db, MAIN_SCOPE) expect(res!.status).toBe(401) }) }) diff --git a/src/__tests__/architecture/dispatcher-html-pipeline.test.ts b/src/__tests__/architecture/dispatcher-html-pipeline.test.ts index 0d9e2667d..04c61e22d 100644 --- a/src/__tests__/architecture/dispatcher-html-pipeline.test.ts +++ b/src/__tests__/architecture/dispatcher-html-pipeline.test.ts @@ -82,15 +82,24 @@ describe('dispatcher HTML pipeline', () => { }) it('the dispatcher emits public HTML only through applyPublishedHtmlPipeline', () => { - const routerPath = join(ROOT, 'server/router.ts') - const router = readFileSync(routerPath, 'utf-8') + // The dispatcher's public tail lives in publicRoutes.ts; published HTML + // comes from publicRouter.ts (pages + posts), which owns the pipeline + // call. A branch preview (renderBranchPreview) is the one other HTML + // path there — a draft render that mirrors the editor's runtime preview + // and deliberately fires no publish hooks. + const router = readFileSync(join(ROOT, 'server/router.ts'), 'utf-8') + const publicRoutes = readFileSync(join(ROOT, 'server/publish/publicRoutes.ts'), 'utf-8') + const publicRouter = readFileSync(join(ROOT, 'server/publish/publicRouter.ts'), 'utf-8') - // Both content paths (pages + posts) call the pipeline helper. - expect(router).toContain('applyPublishedHtmlPipeline') + expect(publicRoutes).toContain('renderPublicResolution') + expect(publicRoutes).toContain('renderBranchPreview') + expect(publicRouter).toContain('applyPublishedHtmlPipeline') - // Sanity: neither path should call the deprecated direct helpers. - expect(router).not.toContain('injectFrontendAssets(') - expect(router).not.toContain("hookBus.applyFilter('publish.html'") + // Sanity: no path calls the deprecated direct helpers. + for (const src of [router, publicRoutes, publicRouter]) { + expect(src).not.toContain('injectFrontendAssets(') + expect(src).not.toContain("hookBus.applyFilter('publish.html'") + } }) it('the renderer output type stays raw (no injected HTML, no fired hooks)', () => { diff --git a/src/__tests__/architecture/import-export-roundtrip.test.ts b/src/__tests__/architecture/import-export-roundtrip.test.ts index c2475fef3..4b14d05d7 100644 --- a/src/__tests__/architecture/import-export-roundtrip.test.ts +++ b/src/__tests__/architecture/import-export-roundtrip.test.ts @@ -57,6 +57,7 @@ import { parseSiteBundleArchive } from '@core/persistence/cmsTransfer' import type { DataRow, DataTable } from '@core/data/schemas' import type { DbClient } from '../../../server/db/client' import type { SiteShell } from '@core/page-tree' +import { MAIN_SCOPE } from '../../../server/branches/scope' // --------------------------------------------------------------------------- // Helpers @@ -75,7 +76,7 @@ beforeAll(async () => { await runMigrations(db, sqliteMigrations) // Add a few rows to the `pages` system table - await createDataRow(db, { + await createDataRow(db, MAIN_SCOPE, { tableId: 'pages', cells: { title: 'Home', @@ -86,7 +87,7 @@ beforeAll(async () => { slug: 'home', }) - await createDataRow(db, { + await createDataRow(db, MAIN_SCOPE, { tableId: 'pages', cells: { title: 'Blog Post Template', @@ -100,15 +101,15 @@ beforeAll(async () => { }) // Add a row to the `posts` system table - await createDataRow(db, { + await createDataRow(db, MAIN_SCOPE, { tableId: 'posts', cells: { title: 'Hello World', slug: 'hello-world', body: '' }, slug: 'hello-world', }) // --- Capture the "export" snapshot --- - tables = await listDataTables(db) - const rowsPerTable = await Promise.all(tables.map((t) => listDataRows(db, t.id))) + tables = await listDataTables(db, MAIN_SCOPE) + const rowsPerTable = await Promise.all(tables.map((t) => listDataRows(db, MAIN_SCOPE, t.id))) exportedRows = rowsPerTable.flat() // --- Wipe all data rows --- @@ -120,7 +121,7 @@ beforeAll(async () => { // --- Simulate import: re-insert rows preserving ids, status, timestamps --- for (const row of exportedRows) { - await upsertDataRow(db, { + await upsertDataRow(db, MAIN_SCOPE, { id: row.id, tableId: row.tableId, cells: row.cells, @@ -133,7 +134,7 @@ beforeAll(async () => { } // Refresh from DB after import - const reimportedRowsPerTable = await Promise.all(tables.map((t) => listDataRows(db, t.id))) + const reimportedRowsPerTable = await Promise.all(tables.map((t) => listDataRows(db, MAIN_SCOPE, t.id))) const reimportedRows = reimportedRowsPerTable.flat() // Store for assertions @@ -211,7 +212,7 @@ describe('import/export round-trip — site shell', () => { // A separate fresh DB to confirm the null case without touching the seeded one const freshDb = createSqliteClient(':memory:') await runMigrations(freshDb, sqliteMigrations) - const shell = await getDraftSite(freshDb) + const shell = await getDraftSite(freshDb, MAIN_SCOPE) expect(shell).toBeNull() }) @@ -249,8 +250,8 @@ describe('import/export round-trip — site shell', () => { updatedAt: Date.now(), } - await saveDraftSite(db, mockShell as Parameters[1]) - const loaded = await getDraftSite(db) + await saveDraftSite(db, MAIN_SCOPE, mockShell as Parameters[1]) + const loaded = await getDraftSite(db, MAIN_SCOPE) expect(loaded).not.toBeNull() expect(loaded!.name).toBe('Test Site') expect(loaded!.id).toBe('default') @@ -284,7 +285,7 @@ const ROUNDTRIP_SHELL: SiteShell = { * Seed a site + owner user + session into `db`, return the auth cookie. */ async function seedRoundtripAuth(db: DbClient, email: string): Promise { - await saveDraftSite(db, ROUNDTRIP_SHELL) + await saveDraftSite(db, MAIN_SCOPE, ROUNDTRIP_SHELL) await createUser(db, { id: `owner-${email}`, email, @@ -319,7 +320,7 @@ async function exportBundle( ): Promise { const req = new Request('http://localhost/admin/api/cms/export', { method: 'GET' }) req.headers.set('cookie', sourceCookie) - const res = await handleExportRoute(req, sourceDb) + const res = await handleExportRoute(req, sourceDb, MAIN_SCOPE) expect(res).not.toBeNull() expect(res!.status).toBe(200) const bundle = parseSiteBundleArchive(new Uint8Array(await res!.arrayBuffer())) @@ -339,29 +340,29 @@ describe('with strategies — handler-level roundtrip', () => { await runMigrations(sourceDb, sqliteMigrations) const sourceCookie = await seedRoundtripAuth(sourceDb, 'source@roundtrip.test') - await createDataRow(sourceDb, { + await createDataRow(sourceDb, MAIN_SCOPE, { tableId: 'posts', cells: { title: 'Post A', slug: 'post-a' }, slug: 'post-a', }) - await createDataRow(sourceDb, { + await createDataRow(sourceDb, MAIN_SCOPE, { tableId: 'posts', cells: { title: 'Post B', slug: 'post-b' }, slug: 'post-b', }) - await createDataRow(sourceDb, { + await createDataRow(sourceDb, MAIN_SCOPE, { tableId: 'posts', cells: { title: 'Post C', slug: 'post-c' }, slug: 'post-c', }) - await createDataRow(sourceDb, { + await createDataRow(sourceDb, MAIN_SCOPE, { tableId: 'pages', cells: { title: 'Home', slug: 'home', body: { nodes: {}, rootNodeId: 'root' } }, slug: 'home', }) // A saved layout — rides the same generic table/row pipeline; the // replace strategy must restore it into the seeded system table. - await createDataRow(sourceDb, { + await createDataRow(sourceDb, MAIN_SCOPE, { tableId: 'layouts', cells: { name: 'Hero', @@ -396,7 +397,7 @@ describe('with strategies — handler-level roundtrip', () => { body: JSON.stringify(sourceBundle), }) req.headers.set('cookie', targetCookie) - const res = await handleImportRoute(req, targetDb) + const res = await handleImportRoute(req, targetDb, MAIN_SCOPE) expect(res!.status).toBe(200) const body = JSON.parse(await res!.text()) result = parseValue(ImportResultSchema, body) @@ -417,10 +418,10 @@ describe('with strategies — handler-level roundtrip', () => { }) test('target DB has same row ids as source bundle', async () => { - const tables = await listDataTables(targetDb) + const tables = await listDataTables(targetDb, MAIN_SCOPE) const allRows: DataRow[] = [] for (const t of tables) { - const rows = await listDataRows(targetDb, t.id) + const rows = await listDataRows(targetDb, MAIN_SCOPE, t.id) allRows.push(...rows) } const targetIds = new Set(allRows.map((r) => r.id)) @@ -431,10 +432,10 @@ describe('with strategies — handler-level roundtrip', () => { }) test('target DB has no rows beyond the bundle', async () => { - const tables = await listDataTables(targetDb) + const tables = await listDataTables(targetDb, MAIN_SCOPE) const allRows: DataRow[] = [] for (const t of tables) { - const rows = await listDataRows(targetDb, t.id) + const rows = await listDataRows(targetDb, MAIN_SCOPE, t.id) allRows.push(...rows) } const bundleIds = new Set(sourceBundle.rows.map((r) => r.id)) @@ -460,7 +461,7 @@ describe('with strategies — handler-level roundtrip', () => { body: JSON.stringify(sourceBundle), }) req.headers.set('cookie', targetCookie) - const res = await handleImportRoute(req, targetDb) + const res = await handleImportRoute(req, targetDb, MAIN_SCOPE) expect(res!.status).toBe(200) const body = JSON.parse(await res!.text()) result = parseValue(ImportResultSchema, body) @@ -478,10 +479,10 @@ describe('with strategies — handler-level roundtrip', () => { }) test('target DB contains all bundle rows', async () => { - const tables = await listDataTables(targetDb) + const tables = await listDataTables(targetDb, MAIN_SCOPE) const allRows: DataRow[] = [] for (const t of tables) { - const rows = await listDataRows(targetDb, t.id) + const rows = await listDataRows(targetDb, MAIN_SCOPE, t.id) allRows.push(...rows) } const targetIds = new Set(allRows.map((r) => r.id)) @@ -507,7 +508,7 @@ describe('with strategies — handler-level roundtrip', () => { body: JSON.stringify(sourceBundle), }) req.headers.set('cookie', targetCookie) - const res = await handleImportRoute(req, targetDb) + const res = await handleImportRoute(req, targetDb, MAIN_SCOPE) expect(res!.status).toBe(200) const body = JSON.parse(await res!.text()) result = parseValue(ImportResultSchema, body) @@ -526,10 +527,10 @@ describe('with strategies — handler-level roundtrip', () => { }) test('target DB contains all bundle rows', async () => { - const tables = await listDataTables(targetDb) + const tables = await listDataTables(targetDb, MAIN_SCOPE) const allRows: DataRow[] = [] for (const t of tables) { - const rows = await listDataRows(targetDb, t.id) + const rows = await listDataRows(targetDb, MAIN_SCOPE, t.id) allRows.push(...rows) } const targetIds = new Set(allRows.map((r) => r.id)) @@ -551,7 +552,7 @@ describe('with strategies — handler-level roundtrip', () => { targetCookie = await seedRoundtripAuth(targetDb, 'target-mo-collision@roundtrip.test') // Pre-seed: add a local-only row + one row that will collide with bundle - const localOnly = await createDataRow(targetDb, { + const localOnly = await createDataRow(targetDb, MAIN_SCOPE, { tableId: 'posts', cells: { title: 'Local Only Row', slug: 'local-only' }, slug: 'local-only', @@ -559,7 +560,7 @@ describe('with strategies — handler-level roundtrip', () => { localOnlyRowId = localOnly.id // Plant one bundle row already in the target (so it becomes a "replace" hit) - await upsertDataRow(targetDb, { + await upsertDataRow(targetDb, MAIN_SCOPE, { id: sourceBundle.rows[0].id, tableId: sourceBundle.rows[0].tableId, cells: { title: 'Old Local Version' }, @@ -576,7 +577,7 @@ describe('with strategies — handler-level roundtrip', () => { body: JSON.stringify(sourceBundle), }) req.headers.set('cookie', targetCookie) - const res = await handleImportRoute(req, targetDb) + const res = await handleImportRoute(req, targetDb, MAIN_SCOPE) expect(res!.status).toBe(200) const body = JSON.parse(await res!.text()) result = parseValue(ImportResultSchema, body) @@ -591,13 +592,13 @@ describe('with strategies — handler-level roundtrip', () => { }) test('local-only row is still present (merge-overwrite leaves untouched rows)', async () => { - const posts = await listDataRows(targetDb, 'posts') + const posts = await listDataRows(targetDb, MAIN_SCOPE, 'posts') const ids = posts.map((r) => r.id) expect(ids).toContain(localOnlyRowId) }) test('collided row now has the bundle version of its cells', async () => { - const posts = await listDataRows(targetDb, 'posts') + const posts = await listDataRows(targetDb, MAIN_SCOPE, 'posts') const bundleFirst = sourceBundle.rows.find((r) => r.tableId === 'posts') expect(bundleFirst).toBeDefined() const localRow = posts.find((r) => r.id === bundleFirst!.id) @@ -634,7 +635,7 @@ describe('full-site round-trip — folders, membership, redirects', () => { await runMigrations(sourceDb, sqliteMigrations) const sourceCookie = await seedRoundtripAuth(sourceDb, 'fullsite@roundtrip.test') - const targetRow = await createDataRow(sourceDb, { + const targetRow = await createDataRow(sourceDb, MAIN_SCOPE, { tableId: 'posts', cells: { title: 'Renamed Post', slug: 'renamed' }, slug: 'renamed', @@ -683,7 +684,7 @@ describe('full-site round-trip — folders, membership, redirects', () => { // --- Export the full bundle (media included so folderIds travel) --- const exportReq = new Request('http://localhost/admin/api/cms/export?includeMedia=1', { method: 'GET' }) exportReq.headers.set('cookie', sourceCookie) - const exportRes = await handleExportRoute(exportReq, sourceDb, { uploadsDir: sourceDir }) + const exportRes = await handleExportRoute(exportReq, sourceDb, MAIN_SCOPE, { uploadsDir: sourceDir }) expect(exportRes!.status).toBe(200) const archiveBytes = new Uint8Array(await exportRes!.arrayBuffer()) const bundle = parseSiteBundleArchive(archiveBytes) @@ -705,7 +706,7 @@ describe('full-site round-trip — folders, membership, redirects', () => { body: archiveBytes, }) importReq.headers.set('cookie', targetCookie) - const importRes = await handleImportArchiveRoute(importReq, targetDb, { uploadsDir: targetDir }) + const importRes = await handleImportArchiveRoute(importReq, targetDb, MAIN_SCOPE, { uploadsDir: targetDir }) expect(importRes!.status).toBe(200) const result = parseValue(ImportResultSchema, JSON.parse(await importRes!.text())) expect(result.mediaFoldersImported).toBe(1) @@ -738,7 +739,7 @@ describe('full-site round-trip — folders, membership, redirects', () => { expect(redirects[0]?.fromSlug).toBe('old-slug') expect(redirects[0]?.targetRowId).toBe(redirectTargetRowId) // The target row really exists in the fresh instance. - expect(await getDataRow(targetDb, redirectTargetRowId)).not.toBeNull() + expect(await getDataRow(targetDb, MAIN_SCOPE, redirectTargetRowId)).not.toBeNull() }) }) @@ -785,7 +786,7 @@ describe('archive import validation', () => { body: archiveBytes, }) req.headers.set('cookie', cookie) - const res = await handleImportArchiveRoute(req, db, { uploadsDir }) + const res = await handleImportArchiveRoute(req, db, MAIN_SCOPE, { uploadsDir }) expect(res!.status).toBe(400) const body = JSON.parse(await res!.text()) expect(body.error).toBe('Archive is missing media file "media/missing.png"') @@ -800,7 +801,7 @@ describe('archive import validation', () => { const db = createSqliteClient(':memory:') await runMigrations(db, sqliteMigrations) const cookie = await seedRoundtripAuth(db, 'atomic-media@roundtrip.test') - const existingRow = await createDataRow(db, { + const existingRow = await createDataRow(db, MAIN_SCOPE, { tableId: 'posts', cells: { title: 'Keep me', slug: 'keep-me' }, slug: 'keep-me', @@ -841,9 +842,9 @@ describe('archive import validation', () => { body: archiveBytes, }) req.headers.set('cookie', cookie) - const res = await handleImportArchiveRoute(req, db, { uploadsDir }) + const res = await handleImportArchiveRoute(req, db, MAIN_SCOPE, { uploadsDir }) expect(res!.status).toBe(400) - expect(await getDataRow(db, existingRow.id)).not.toBeNull() + expect(await getDataRow(db, MAIN_SCOPE, existingRow.id)).not.toBeNull() } finally { await rm(uploadsDir, { recursive: true, force: true }) } @@ -855,13 +856,13 @@ describe('archive import validation', () => { const db = createSqliteClient(':memory:') await runMigrations(db, sqliteMigrations) const cookie = await seedRoundtripAuth(db, 'slug-conflict@roundtrip.test') - await createDataRow(db, { + await createDataRow(db, MAIN_SCOPE, { id: 'local-existing-row', tableId: 'posts', cells: { title: 'Local row', slug: 'shared-slug' }, slug: 'shared-slug', }) - const postsTable = (await listDataTables(db)).find((table) => table.id === 'posts') + const postsTable = (await listDataTables(db, MAIN_SCOPE)).find((table) => table.id === 'posts') expect(postsTable).toBeDefined() const manifest = { schemaVersion: 1, @@ -900,13 +901,13 @@ describe('archive import validation', () => { body: archiveBytes, }) req.headers.set('cookie', cookie) - const res = await handleImportArchiveRoute(req, db, { uploadsDir }) + const res = await handleImportArchiveRoute(req, db, MAIN_SCOPE, { uploadsDir }) expect(res!.status).toBe(200) const body = parseValue(ImportResultSchema, JSON.parse(await res!.text())) expect(body.rowsInserted).toBe(0) expect(body.rowsSkipped).toBe(1) - expect(await getDataRow(db, 'bundle-conflicting-row')).toBeNull() - expect(await getDataRow(db, 'local-existing-row')).not.toBeNull() + expect(await getDataRow(db, MAIN_SCOPE, 'bundle-conflicting-row')).toBeNull() + expect(await getDataRow(db, MAIN_SCOPE, 'local-existing-row')).not.toBeNull() } finally { await rm(uploadsDir, { recursive: true, force: true }) } @@ -995,7 +996,7 @@ describe('archive import validation', () => { body: archiveBytes, }) req.headers.set('cookie', cookie) - const res = await handleImportArchiveRoute(req, db, { uploadsDir }) + const res = await handleImportArchiveRoute(req, db, MAIN_SCOPE, { uploadsDir }) expect(res!.status).toBe(200) const body = parseValue(ImportResultSchema, JSON.parse(await res!.text())) expect(body.mediaImported).toBe(1) diff --git a/src/__tests__/collab/applyPatches.test.ts b/src/__tests__/collab/applyPatches.test.ts index 29cc682b4..ccd254a82 100644 --- a/src/__tests__/collab/applyPatches.test.ts +++ b/src/__tests__/collab/applyPatches.test.ts @@ -57,8 +57,8 @@ function fixtureSite(): SiteDocument { /** Seed a doc set exactly like the server would, then hand it to the translator. */ function seededDocSet(site: SiteDocument): CollabDocSet { const docs = createCollabDocSet() - seedSiteDoc(docs.ensure('site:default'), site) - for (const page of site.pages) seedPageDoc(docs.ensure(`page:${page.id}`), page) + seedSiteDoc(docs.ensure('site:main'), site) + for (const page of site.pages) seedPageDoc(docs.ensure(`page:main:${page.id}`), page) return docs } @@ -79,7 +79,7 @@ function mutate( function translate(site: SiteDocument, docs: CollabDocSet, recipe: (d: SiteDocument) => void): SiteDocument { const { next, patches } = mutate(site, recipe) - applySitePatchesToDocs(patches, site, next, docs, LOCAL_ORIGIN) + applySitePatchesToDocs(patches, site, next, docs, LOCAL_ORIGIN, 'main') return next } @@ -90,7 +90,7 @@ describe('applySitePatchesToDocs — page tree edits', () => { const next = translate(site, docs, (d) => { updateNodeProps(d.pages[0], 't1', { tag: 'h2' }) }) - const projected = projectPageDoc(docs.ensure('page:p1'), 'p1') + const projected = projectPageDoc(docs.ensure('page:main:p1'), 'p1') expect(projected.nodes.t1.props.tag).toBe('h2') expect(projected.nodes.t1.props.text).toBe('hello world') expect(projected.nodes.root.children).toEqual(next.pages[0].nodes.root.children) @@ -99,7 +99,7 @@ describe('applySitePatchesToDocs — page tree edits', () => { it('inline-text edit splices Y.Text so a concurrent remote insertion survives', () => { const site = fixtureSite() const docs = seededDocSet(site) - const local = docs.ensure('page:p1') + const local = docs.ensure('page:main:p1') // Remote peer shares history and types at the end concurrently. const remote = new Y.Doc() Y.applyUpdate(remote, Y.encodeStateAsUpdate(local)) @@ -125,7 +125,7 @@ describe('applySitePatchesToDocs — page tree edits', () => { it('falls back safely when the projected pre-value drifted from the live Y.Text', () => { const site = fixtureSite() const docs = seededDocSet(site) - const local = docs.ensure('page:p1') + const local = docs.ensure('page:main:p1') // Simulate a caller holding a stale JSON snapshot after a remote update // already landed in the authoritative doc. The stale splice indexes used // to target the wrong characters or throw when the live text was shorter. @@ -146,7 +146,7 @@ describe('applySitePatchesToDocs — page tree edits', () => { const next = translate(site, docs, (d) => { moveNode(d.pages[0], 'c1', 'root', 0) // move c1 before t1 }) - expect(projectPageDoc(docs.ensure('page:p1'), 'p1').nodes.root.children) + expect(projectPageDoc(docs.ensure('page:main:p1'), 'p1').nodes.root.children) .toEqual(next.pages[0].nodes.root.children) const next2 = translate(next, docs, (d) => { @@ -154,7 +154,7 @@ describe('applySitePatchesToDocs — page tree edits', () => { d.pages[0].nodes.root.children = d.pages[0].nodes.root.children.filter((c) => c !== 'c1') delete d.pages[0].nodes.c1 }) - const projected = projectPageDoc(docs.ensure('page:p1'), 'p1') + const projected = projectPageDoc(docs.ensure('page:main:p1'), 'p1') expect(projected.nodes.c1).toBeUndefined() expect(projected.nodes.root.children).toEqual(next2.pages[0].nodes.root.children) }) @@ -165,7 +165,7 @@ describe('applySitePatchesToDocs — page tree edits', () => { translate(site, docs, (d) => { renamePage(d, 'p1', 'Homepage', 'index') }) - expect(projectPageDoc(docs.ensure('page:p1'), 'p1').title).toBe('Homepage') + expect(projectPageDoc(docs.ensure('page:main:p1'), 'p1').title).toBe('Homepage') }) }) @@ -177,9 +177,9 @@ describe('applySitePatchesToDocs — rosters', () => { translate(site, docs, (d) => { newId = addPage(d, 'Fresh', 'fresh').id }) - const projectedSite = projectSiteDoc(docs.ensure('site:default')) + const projectedSite = projectSiteDoc(docs.ensure('site:main')) expect(projectedSite.rosters.pages).toContain(newId) - const projectedPage = projectPageDoc(docs.ensure(`page:${newId}`), newId) + const projectedPage = projectPageDoc(docs.ensure(`page:main:${newId}`), newId) expect(projectedPage.title).toBe('Fresh') expect(Object.keys(projectedPage.nodes).length).toBeGreaterThan(0) }) @@ -190,7 +190,7 @@ describe('applySitePatchesToDocs — rosters', () => { translate(site, docs, (d) => { deletePage(d, 'p2') }) - const projected = projectSiteDoc(docs.ensure('site:default')) + const projected = projectSiteDoc(docs.ensure('site:main')) expect(projected.rosters.pages).not.toContain('p2') expect(projected.rosters.pages).toContain('p1') }) @@ -203,7 +203,7 @@ describe('applySitePatchesToDocs — rosters', () => { d.pages[0] = b d.pages[1] = a }) - expect(projectSiteDoc(docs.ensure('site:default')).rosters.pages).toEqual(['p2', 'p1']) + expect(projectSiteDoc(docs.ensure('site:main')).rosters.pages).toEqual(['p2', 'p1']) }) }) @@ -215,7 +215,7 @@ describe('applySitePatchesToDocs — shell', () => { d.styleRules.r1.styles.color = 'var(--text-muted)' d.settings.metaTitle = 'Acme rules' }) - const projected = projectSiteDoc(docs.ensure('site:default')) + const projected = projectSiteDoc(docs.ensure('site:main')) const rule = projected.shell.styleRules as Record }> expect(rule.r1.styles.color).toBe('var(--text-muted)') expect((projected.shell.settings as Record).metaTitle).toBe('Acme rules') @@ -227,6 +227,6 @@ describe('applySitePatchesToDocs — shell', () => { translate(site, docs, (d) => { d.name = 'Renamed' }) - expect(projectSiteDoc(docs.ensure('site:default')).shell.name).toBe('Renamed') + expect(projectSiteDoc(docs.ensure('site:main')).shell.name).toBe('Renamed') }) }) diff --git a/src/__tests__/collab/awareness.test.tsx b/src/__tests__/collab/awareness.test.tsx index d18b7d76d..ec8aab149 100644 --- a/src/__tests__/collab/awareness.test.tsx +++ b/src/__tests__/collab/awareness.test.tsx @@ -107,8 +107,8 @@ describe('activeEditorDocId', () => { it('routes VC mode to the component doc and page mode to the page doc', () => { expect( activeEditorDocId({ activeDocument: { kind: 'visualComponent', vcId: 'vc-1' }, activePageId: 'p1' }), - ).toBe('component:vc-1') - expect(activeEditorDocId({ activeDocument: null, activePageId: 'p1' })).toBe('page:p1') + ).toBe('component:main:vc-1') + expect(activeEditorDocId({ activeDocument: null, activePageId: 'p1' })).toBe('page:main:p1') expect(activeEditorDocId({ activeDocument: null, activePageId: null })).toBeNull() }) }) @@ -127,10 +127,10 @@ describe('collab reset during inline editing', () => { const provider = fakeProvider() connectCollabProvider(provider) - provider.triggerReset('page:another-page') + provider.triggerReset('page:main:another-page') expect(useEditorStore.getState().activeInlineEdit).not.toBeNull() - provider.triggerReset(`page:${pageId}`) + provider.triggerReset(`page:main:${pageId}`) expect(useEditorStore.getState().activeInlineEdit).toBeNull() }) }) @@ -140,12 +140,12 @@ describe('usePeerPresences', () => { const provider = fakeProvider() connectCollabProvider(provider) - render() + render() await act(async () => { injectPeerState(provider.awareness, { user: { id: 'u2', name: 'Ada', color: peerColor('u2'), avatarUrl: null, gravatarHash: null }, - docId: 'page:p1', + docId: 'page:main:p1', selectedNodeIds: ['n1'], editingNodeId: null, pointer: null, @@ -153,14 +153,14 @@ describe('usePeerPresences', () => { }) injectPeerState(provider.awareness, { user: { id: 'u3', name: 'Grace', color: peerColor('u3'), avatarUrl: null, gravatarHash: null }, - docId: 'page:OTHER', + docId: 'page:main:OTHER', selectedNodeIds: [], editingNodeId: null, pointer: null, textCaret: null, }) // Malformed wire state — must be dropped by validation, not crash. - injectPeerState(provider.awareness, { user: { id: 42 }, docId: 'page:p1' }) + injectPeerState(provider.awareness, { user: { id: 42 }, docId: 'page:main:p1' }) }) expect(screen.getByText('Ada')).toBeTruthy() @@ -200,7 +200,7 @@ describe('PeerPresenceOverlay', () => { await act(async () => { injectPeerState(provider.awareness, { user: { id: 'u9', name: 'Marge', color: peerColor('u9'), avatarUrl: null, gravatarHash: null }, - docId: `page:${pageId}`, + docId: `page:main:${pageId}`, selectedNodeIds: [site.pages[0].rootNodeId], editingNodeId: site.pages[0].rootNodeId, pointer: { x: 10, y: 20, breakpointId: 'bp-desktop' }, diff --git a/src/__tests__/collab/collabNotices.test.ts b/src/__tests__/collab/collabNotices.test.ts index 155428bd1..fdb583aa6 100644 --- a/src/__tests__/collab/collabNotices.test.ts +++ b/src/__tests__/collab/collabNotices.test.ts @@ -60,20 +60,20 @@ describe('collab notices', () => { it('matches resets only to the active page or visual component', () => { expect( - resetTargetsActiveDocument('page:page-1', { kind: 'page', pageId: 'page-1' }, null), + resetTargetsActiveDocument('page:main:page-1', { kind: 'page', pageId: 'page-1' }, null), ).toBe(true) expect( - resetTargetsActiveDocument('page:page-1', { kind: 'visualComponent', vcId: 'vc-1' }, 'page-1'), + resetTargetsActiveDocument('page:main:page-1', { kind: 'visualComponent', vcId: 'vc-1' }, 'page-1'), ).toBe(true) expect( resetTargetsActiveDocument( - 'component:vc-1', + 'component:main:vc-1', { kind: 'visualComponent', vcId: 'vc-1' }, 'page-1', ), ).toBe(true) expect( - resetTargetsActiveDocument('component:vc-2', { kind: 'page', pageId: 'page-1' }, 'page-1'), + resetTargetsActiveDocument('component:main:vc-2', { kind: 'page', pageId: 'page-1' }, 'page-1'), ).toBe(false) }) }) diff --git a/src/__tests__/collab/inlineEditRemoteMerge.test.tsx b/src/__tests__/collab/inlineEditRemoteMerge.test.tsx index 5c2ae07fd..451c3366a 100644 --- a/src/__tests__/collab/inlineEditRemoteMerge.test.tsx +++ b/src/__tests__/collab/inlineEditRemoteMerge.test.tsx @@ -95,7 +95,7 @@ async function setupEditingSession(initialText: string): Promise<{ await waitFor(() => expect(editable.getAttribute('contenteditable')).toBeTruthy()) expect(editable.textContent).toBe(initialText) - const local = collabDocFor(encodeCollabDocId({ kind: 'page', rowId: pageId }))! + const local = collabDocFor(encodeCollabDocId({ kind: 'page', branchId: 'main', rowId: pageId }))! expect(local).toBeTruthy() return { nodeId, pageId, editable, local } } diff --git a/src/__tests__/collab/provider.test.ts b/src/__tests__/collab/provider.test.ts index 5fb763ab0..6c8bbbcce 100644 --- a/src/__tests__/collab/provider.test.ts +++ b/src/__tests__/collab/provider.test.ts @@ -47,6 +47,13 @@ class FakeSocket implements CollabSocketLike { } } +/** A server step2 for an empty doc — the reply to the client's step1. */ +function emptyStep2(): Uint8Array { + const encoder = encoding.createEncoder() + syncProtocol.writeSyncStep2(encoder, new Y.Doc()) + return encoding.toUint8Array(encoder) +} + function lastSyncMessageType(socket: FakeSocket, docId: string): number | null { for (let i = socket.sent.length - 1; i >= 0; i--) { const frame = decodeCollabFrame(socket.sent[i]) @@ -79,11 +86,12 @@ describe('collab provider', () => { provider.destroy() }) - it('sends an update frame immediately on a local transaction; remote-applied updates do not echo', () => { + it('sends an update frame immediately on a local transaction once the lineage is known; remote-applied updates do not echo', () => { const socket = new FakeSocket() const provider = createCollabProvider({ createSocket: () => socket }) socket.open() const { doc } = provider.bind('page:p1') + socket.emit(encodeCollabFrame('page:p1', 'gen-1', FRAME_SYNC, emptyStep2())) const sentBefore = socket.sent.length doc.transact(() => { @@ -91,6 +99,7 @@ describe('collab provider', () => { }, LOCAL_ORIGIN) expect(socket.sent.length).toBe(sentBefore + 1) expect(lastSyncMessageType(socket, 'page:p1')).toBe(2) // update + expect(decodeCollabFrame(socket.sent[socket.sent.length - 1]!).generation).toBe('gen-1') // A remote update applied by the provider must NOT be re-sent. const remote = new Y.Doc() @@ -104,6 +113,38 @@ describe('collab provider', () => { provider.destroy() }) + it('holds local updates until the server names the lineage, then sends them as one update', () => { + const socket = new FakeSocket() + const provider = createCollabProvider({ createSocket: () => socket }) + socket.open() + const { doc } = provider.bind('page:p1') + const sentAfterBind = socket.sent.length + + // Two local transactions inside the bind round trip — a row created and + // placed before the server answered step1. + doc.transact(() => { doc.getMap('meta').set('title', 'New page') }, LOCAL_ORIGIN) + doc.transact(() => { doc.getMap('tree').set('rootNodeId', 'root') }, LOCAL_ORIGIN) + expect(socket.sent.length).toBe(sentAfterBind) + + socket.emit(encodeCollabFrame('page:p1', 'gen-1', FRAME_SYNC, emptyStep2())) + const frames = socket.sent.slice(sentAfterBind).map((raw) => decodeCollabFrame(raw)) + const updates = frames.filter((frame) => frame.docId === 'page:p1' && frame.frameType === FRAME_SYNC + && decoding.readVarUint(decoding.createDecoder(frame.payload)) === 2) + expect(updates).toHaveLength(1) + expect(updates[0]!.generation).toBe('gen-1') + // Nothing ever went out with an empty lineage. + expect(frames.every((frame) => frame.generation === 'gen-1' || frame.frameType !== FRAME_SYNC + || decoding.readVarUint(decoding.createDecoder(frame.payload)) !== 2)).toBe(true) + // Both changes reached the wire. + const mirror = new Y.Doc() + const decoder = decoding.createDecoder(updates[0]!.payload) + decoding.readVarUint(decoder) + Y.applyUpdate(mirror, decoding.readVarUint8Array(decoder)) + expect(mirror.getMap('meta').get('title')).toBe('New page') + expect(mirror.getMap('tree').get('rootNodeId')).toBe('root') + provider.destroy() + }) + it('a reset frame unbinds the doc and notifies listeners', () => { const socket = new FakeSocket() const provider = createCollabProvider({ createSocket: () => socket }) diff --git a/src/__tests__/core/branches/ids.test.ts b/src/__tests__/core/branches/ids.test.ts new file mode 100644 index 000000000..2978a5548 --- /dev/null +++ b/src/__tests__/core/branches/ids.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'bun:test' +import { + BRANCH_ID_PATTERN, + MAIN_BRANCH_ID, + isValidBranchId, + logicalIdOf, + physicalId, + slugifyBranchName, +} from '@core/branches' + +describe('branch ids', () => { + it('keeps main rows on their logical id and prefixes every other branch', () => { + expect(physicalId(MAIN_BRANCH_ID, 'abc')).toBe('abc') + expect(physicalId('spring-redesign', 'abc')).toBe('spring-redesign:abc') + expect(physicalId('spring-redesign', 'pages')).toBe('spring-redesign:pages') + }) + + it('inverts the physical id for the branch that minted it', () => { + expect(logicalIdOf('spring-redesign', 'spring-redesign:abc')).toBe('abc') + expect(logicalIdOf(MAIN_BRANCH_ID, 'abc')).toBe('abc') + // A logical id may itself contain a colon — only the branch prefix is stripped. + expect(logicalIdOf('b1', 'b1:with:colon')).toBe('with:colon') + }) + + it('round-trips ids that contain the separator', () => { + const logical = 'weird:id' + expect(logicalIdOf('b1', physicalId('b1', logical))).toBe(logical) + }) + + it('refuses branch ids that would break the physical id scheme', () => { + expect(isValidBranchId('main')).toBe(true) + expect(isValidBranchId('spring-redesign')).toBe(true) + expect(isValidBranchId('v2.1')).toBe(true) + expect(isValidBranchId('with:colon')).toBe(false) + expect(isValidBranchId('Upper')).toBe(false) + expect(isValidBranchId('')).toBe(false) + expect(isValidBranchId('-leading')).toBe(false) + expect(isValidBranchId('a'.repeat(65))).toBe(false) + expect(BRANCH_ID_PATTERN.test('a'.repeat(64))).toBe(true) + }) + + it('derives ids from human names', () => { + expect(slugifyBranchName('Spring Redesign')).toBe('spring-redesign') + expect(slugifyBranchName(' Pricing: Experiment #2 ')).toBe('pricing-experiment-2') + expect(slugifyBranchName('v2.1 launch')).toBe('v2.1-launch') + expect(slugifyBranchName('---')).toBe('') + expect(isValidBranchId(slugifyBranchName('A'.repeat(100)))).toBe(true) + }) +}) diff --git a/src/__tests__/core/branches/threeWayMerge.test.ts b/src/__tests__/core/branches/threeWayMerge.test.ts new file mode 100644 index 000000000..a2d1d2fe3 --- /dev/null +++ b/src/__tests__/core/branches/threeWayMerge.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'bun:test' +import { mergeJson } from '@core/branches' + +describe('mergeJson', () => { + it('keeps ours when theirs did not move', () => { + const base = { title: 'Home', body: 'a' } + expect(mergeJson(base, { title: 'Home!', body: 'a' }, base)).toEqual({ + value: { title: 'Home!', body: 'a' }, + conflicts: [], + }) + }) + + it('takes theirs when ours did not move', () => { + const base = { title: 'Home', body: 'a' } + expect(mergeJson(base, base, { title: 'Home', body: 'b' })).toEqual({ + value: { title: 'Home', body: 'b' }, + conflicts: [], + }) + }) + + it('merges disjoint object changes and reports nothing', () => { + const base = { seo: { title: 'x', description: 'd' }, slug: 'home' } + const ours = { seo: { title: 'x', description: 'ours' }, slug: 'home' } + const theirs = { seo: { title: 'theirs', description: 'd' }, slug: 'home' } + expect(mergeJson(base, ours, theirs)).toEqual({ + value: { seo: { title: 'theirs', description: 'ours' }, slug: 'home' }, + conflicts: [], + }) + }) + + it('reports a conflict by path and keeps ours there', () => { + const base = { seo: { title: 'x' }, body: [1] } + const ours = { seo: { title: 'ours' }, body: [1, 2] } + const theirs = { seo: { title: 'theirs' }, body: [1, 3] } + expect(mergeJson(base, ours, theirs)).toEqual({ + value: { seo: { title: 'ours' }, body: [1, 2] }, + conflicts: ['seo.title', 'body'], + }) + }) + + it('treats identical changes on both sides as agreement', () => { + const base = { title: 'a' } + expect(mergeJson(base, { title: 'b' }, { title: 'b' })).toEqual({ value: { title: 'b' }, conflicts: [] }) + }) + + it('carries a deletion made on one side when the other side left the key alone', () => { + const base = { a: 1, b: 2 } + expect(mergeJson(base, { a: 1, b: 2 }, { a: 1 })).toEqual({ value: { a: 1 }, conflicts: [] }) + expect(mergeJson(base, { a: 1 }, { a: 1, b: 2 })).toEqual({ value: { a: 1 }, conflicts: [] }) + }) + + it('flags delete-versus-edit as a conflict and keeps ours', () => { + const base = { a: 1, b: 2 } + expect(mergeJson(base, { a: 1, b: 3 }, { a: 1 })).toEqual({ value: { a: 1, b: 3 }, conflicts: ['b'] }) + }) + + it('adds keys new on either side', () => { + const base = { a: 1 } + expect(mergeJson(base, { a: 1, ours: true }, { a: 1, theirs: true })).toEqual({ + value: { a: 1, ours: true, theirs: true }, + conflicts: [], + }) + }) + + it('merges without a base when both sides are new but agree partially', () => { + expect(mergeJson(undefined, { a: 1, b: 2 }, { a: 1, b: 3 })).toEqual({ + value: { a: 1, b: 2 }, + conflicts: ['b'], + }) + }) +}) diff --git a/src/__tests__/data/contentAdmin.test.tsx b/src/__tests__/data/contentAdmin.test.tsx index f1ee06ef3..48f85802b 100644 --- a/src/__tests__/data/contentAdmin.test.tsx +++ b/src/__tests__/data/contentAdmin.test.tsx @@ -1996,8 +1996,8 @@ describe('ContentPage', () => { expect(src).toContain("'Retry publish'") expect(src).toContain("'Published'") - expect(src).toContain('statusLabel={isCleanPublished ? null : statusText}') - expect(src).toContain('publishDisabled={!selectedEntry || !canPublish || isPublishing || isCleanPublished}') + expect(src).toContain('isCleanPublished ? null : statusText}') + expect(src).toContain('publishDisabled={!selectedEntry || !canPublish || isPublishing || isCleanPublished || branchGate.onBranch}') expect(src).not.toContain("'Live'") expect(src).toContain('isCleanPublished ? CheckIcon') expect(src).not.toContain("'Publish failed'") diff --git a/src/__tests__/fixtures/storeIsolation.ts b/src/__tests__/fixtures/storeIsolation.ts index 8b60c7969..cf7b22a8c 100644 --- a/src/__tests__/fixtures/storeIsolation.ts +++ b/src/__tests__/fixtures/storeIsolation.ts @@ -22,6 +22,7 @@ import { useEditorStore } from '@site/store/store' import { useAdminUi } from '@admin/state/adminUi' +import { useBranchStore } from '@admin/state/branchStore' import { useWorkspaceLayout } from '@admin/state/workspaceLayout' interface ResettableStore { @@ -45,6 +46,7 @@ function pristineResetter(store: ResettableStore): () => vo const RESETTERS = [ pristineResetter(useEditorStore), pristineResetter(useAdminUi), + pristineResetter(useBranchStore), pristineResetter(useWorkspaceLayout), ] diff --git a/src/__tests__/server/branchMerge.test.ts b/src/__tests__/server/branchMerge.test.ts new file mode 100644 index 000000000..a0885fd55 --- /dev/null +++ b/src/__tests__/server/branchMerge.test.ts @@ -0,0 +1,257 @@ +/** + * Merging a branch into main and updating a branch from main — plans, + * field-level merges, conflicts and their resolutions, bases moving on, and + * the endpoints' gates. + */ +import { afterEach, describe, expect, it } from 'bun:test' +import { MAIN_SCOPE } from '../../../server/branches/scope' +import { applyBranchMerge, planBranchMerge } from '../../../server/branches/merge' +import { getDataRow, listDataRows, saveDataRowDraft, softDeleteDataRow, upsertDataRowDraft } from '../../../server/repositories/data' +import { getDraftSite, saveDraftSite } from '../../../server/repositories/site' +import { + createCapabilityTestHarness, + expectStepUpRequired, + readJson, + type CapabilityTestHarness, +} from '../helpers/capabilityHarness' + +const BRANCHES = '/admin/api/cms/branches' + +async function forkViaApi(harness: CapabilityTestHarness, owner: string, name: string): Promise { + const res = await harness.cms(BRANCHES, { method: 'POST', cookie: owner, json: { name } }) + expect(res.status).toBe(201) + return (await readJson<{ branch: { id: string } }>(res)).branch.id +} + +describe('branch merge', () => { + let harness: CapabilityTestHarness | null = null + + afterEach(async () => { + await harness?.cleanup() + harness = null + }) + + it('carries branch-only edits and additions into main and moves the bases on', async () => { + harness = await createCapabilityTestHarness() + const owner = await harness.setupOwner() + const branchId = await forkViaApi(harness, owner, 'Feature') + const branch = { branchId } + + // Nothing to merge right after a fork. + expect((await planBranchMerge(harness.db, branchId, 'merge')).plan.changes).toEqual([]) + + const [home] = await listDataRows(harness.db, branch, 'pages') + await saveDataRowDraft(harness.db, branch, home!.id, { + cells: { ...home!.cells, title: 'Branch title' }, + slug: home!.slug, + }) + await upsertDataRowDraft(harness.db, branch, { + id: 'branch-post', + tableId: 'posts', + cells: { title: 'Written on the branch', slug: 'written-on-the-branch' }, + slug: 'written-on-the-branch', + }) + const shell = (await getDraftSite(harness.db, branch))! + await saveDraftSite(harness.db, branch, { ...shell, name: 'Renamed on branch' }) + + const { plan } = await planBranchMerge(harness.db, branchId, 'merge') + expect(plan.conflictCount).toBe(0) + expect(plan.changes.map((change) => [change.kind, change.action, change.label])).toEqual([ + ['site', 'update', 'Site settings'], + ['row', 'update', 'Branch title'], + ['row', 'create', 'Written on the branch'], + ]) + + await applyBranchMerge(harness.db, { branchId, direction: 'merge', resolutions: {}, actorUserId: null }) + expect((await getDataRow(harness.db, MAIN_SCOPE, home!.id))!.cells.title).toBe('Branch title') + expect((await getDataRow(harness.db, MAIN_SCOPE, 'branch-post'))!.cells.title).toBe('Written on the branch') + expect((await getDraftSite(harness.db, MAIN_SCOPE))!.name).toBe('Renamed on branch') + // Both sides agree now, so a second plan is empty in either direction. + expect((await planBranchMerge(harness.db, branchId, 'merge')).plan.changes).toEqual([]) + expect((await planBranchMerge(harness.db, branchId, 'update')).plan.changes).toEqual([]) + }) + + it('merges different fields of the same row and flags the same field as a conflict', async () => { + harness = await createCapabilityTestHarness() + const owner = await harness.setupOwner() + const branchId = await forkViaApi(harness, owner, 'Both') + const branch = { branchId } + const [home] = await listDataRows(harness.db, MAIN_SCOPE, 'pages') + + // Disjoint fields: main edits the SEO title, the branch edits the title. + await saveDataRowDraft(harness.db, MAIN_SCOPE, home!.id, { + cells: { ...home!.cells, seoTitle: 'Main SEO' }, + slug: home!.slug, + }) + await saveDataRowDraft(harness.db, branch, home!.id, { + cells: { ...home!.cells, title: 'Branch title' }, + slug: home!.slug, + }) + const disjoint = await planBranchMerge(harness.db, branchId, 'merge') + expect(disjoint.plan.changes).toHaveLength(1) + expect(disjoint.plan.conflictCount).toBe(0) + await applyBranchMerge(harness.db, { branchId, direction: 'merge', resolutions: {}, actorUserId: null }) + const merged = (await getDataRow(harness.db, MAIN_SCOPE, home!.id))! + expect(merged.cells).toMatchObject({ title: 'Branch title', seoTitle: 'Main SEO' }) + // The branch converged onto the same content. + expect((await getDataRow(harness.db, branch, home!.id))!.cells).toMatchObject({ title: 'Branch title', seoTitle: 'Main SEO' }) + + // Same field: a conflict that needs a decision. + await saveDataRowDraft(harness.db, MAIN_SCOPE, home!.id, { cells: { ...merged.cells, title: 'Main wins' }, slug: home!.slug }) + await saveDataRowDraft(harness.db, branch, home!.id, { cells: { ...merged.cells, title: 'Branch wins' }, slug: home!.slug }) + const conflicted = await planBranchMerge(harness.db, branchId, 'merge') + expect(conflicted.plan.conflictCount).toBe(1) + expect(conflicted.plan.changes[0]!.conflicts).toEqual(['cells.title']) + await expect( + applyBranchMerge(harness.db, { branchId, direction: 'merge', resolutions: {}, actorUserId: null }), + ).rejects.toThrow('Resolve 1 conflicting change') + + const key = conflicted.plan.changes[0]!.key + await applyBranchMerge(harness.db, { branchId, direction: 'merge', resolutions: { [key]: 'from' }, actorUserId: null }) + expect((await getDataRow(harness.db, MAIN_SCOPE, home!.id))!.cells.title).toBe('Branch wins') + expect((await planBranchMerge(harness.db, branchId, 'merge')).plan.changes).toEqual([]) + }) + + it('updates a branch from main and treats delete-versus-edit as a conflict', async () => { + harness = await createCapabilityTestHarness() + const owner = await harness.setupOwner() + const branchId = await forkViaApi(harness, owner, 'Behind') + const branch = { branchId } + + await upsertDataRowDraft(harness.db, MAIN_SCOPE, { + id: 'main-post', + tableId: 'posts', + cells: { title: 'Written on main', slug: 'written-on-main' }, + slug: 'written-on-main', + }) + const update = await planBranchMerge(harness.db, branchId, 'update') + expect(update.plan.changes.map((change) => [change.action, change.label])).toEqual([['create', 'Written on main']]) + await applyBranchMerge(harness.db, { branchId, direction: 'update', resolutions: {}, actorUserId: null }) + expect((await getDataRow(harness.db, branch, 'main-post'))!.cells.title).toBe('Written on main') + + // Main deletes the post, the branch edits it. + await softDeleteDataRow(harness.db, MAIN_SCOPE, 'main-post') + await saveDataRowDraft(harness.db, branch, 'main-post', { cells: { title: 'Edited on branch', slug: 'written-on-main' }, slug: 'written-on-main' }) + const merge = await planBranchMerge(harness.db, branchId, 'merge') + expect(merge.plan.changes.map((change) => [change.action, change.conflicts])).toEqual([['create', ['(deleted)']]]) + const key = merge.plan.changes[0]!.key + // Keeping main's deletion drops the branch's edit too. + await applyBranchMerge(harness.db, { branchId, direction: 'merge', resolutions: { [key]: 'into' }, actorUserId: null }) + expect(await getDataRow(harness.db, MAIN_SCOPE, 'main-post')).toBeNull() + expect(await getDataRow(harness.db, branch, 'main-post')).toBeNull() + }) + + it('exposes plan and apply over HTTP, step-up gated, and can delete the branch afterwards', async () => { + harness = await createCapabilityTestHarness() + const owner = await harness.setupOwner() + const branchId = await forkViaApi(harness, owner, 'Ship It') + await upsertDataRowDraft(harness.db, { branchId }, { + id: 'shipped', + tableId: 'posts', + cells: { title: 'Shipped', slug: 'shipped' }, + slug: 'shipped', + }) + + const planned = await readJson<{ plan: { changes: unknown[] } }>( + await harness.cms(`${BRANCHES}/${branchId}/merge`, { cookie: owner }), + ) + expect(planned.plan.changes).toHaveLength(1) + + const manager = await harness.createRoleUser({ + name: 'Merger', + slug: 'merger', + capabilities: ['site.read', 'site.branches.manage'], + }) + await expectStepUpRequired( + await harness.cms(`${BRANCHES}/${branchId}/merge`, { method: 'POST', cookie: manager.cookie, json: {} }), + ) + const applied = await harness.cms(`${BRANCHES}/${branchId}/merge`, { + method: 'POST', + cookie: owner, + json: { deleteBranch: true }, + }) + expect(applied.status).toBe(200) + expect(await readJson<{ branchDeleted: boolean }>(applied)).toMatchObject({ branchDeleted: true }) + expect((await getDataRow(harness.db, MAIN_SCOPE, 'shipped'))!.cells.title).toBe('Shipped') + const remaining = await readJson<{ branches: Array<{ id: string }> }>(await harness.cms(BRANCHES, { cookie: owner })) + expect(remaining.branches.map((branch) => branch.id)).toEqual(['main']) + }) +}) + +describe('branch merge — direction and base bookkeeping', () => { + let harness: CapabilityTestHarness | null = null + + afterEach(async () => { + await harness?.cleanup() + harness = null + }) + + it('updating a branch from main never writes main, and the base becomes main as of the update', async () => { + harness = await createCapabilityTestHarness() + const owner = await harness.setupOwner() + const branchId = await forkViaApi(harness, owner, 'Downstream') + const branch = { branchId } + const [home] = await listDataRows(harness.db, MAIN_SCOPE, 'pages') + + // Main edits the SEO title; the branch edits the title of the same row. + await saveDataRowDraft(harness.db, MAIN_SCOPE, home!.id, { cells: { ...home!.cells, seoTitle: 'Main SEO' }, slug: home!.slug }) + await saveDataRowDraft(harness.db, branch, home!.id, { cells: { ...home!.cells, title: 'Branch title' }, slug: home!.slug }) + const mainBefore = (await getDataRow(harness.db, MAIN_SCOPE, home!.id))! + + await applyBranchMerge(harness.db, { branchId, direction: 'update', resolutions: {}, actorUserId: null }) + // The branch has both; main is byte-identical to before the update. + expect((await getDataRow(harness.db, branch, home!.id))!.cells).toMatchObject({ title: 'Branch title', seoTitle: 'Main SEO' }) + const mainAfter = (await getDataRow(harness.db, MAIN_SCOPE, home!.id))! + expect(mainAfter.cells).toEqual(mainBefore.cells) + expect(mainAfter.updatedAt).toBe(mainBefore.updatedAt) + + // The branch's own change is still a pending merge, with no conflict. + const next = await planBranchMerge(harness.db, branchId, 'merge') + expect(next.plan.changes.map((change) => [change.action, change.conflicts])).toEqual([['update', []]]) + // A "keep branch" decision on a delete-versus-edit conflict must not resurrect main's deletion either. + await softDeleteDataRow(harness.db, MAIN_SCOPE, home!.id) + const conflicted = await planBranchMerge(harness.db, branchId, 'update') + expect(conflicted.plan.changes.map((change) => [change.action, change.conflicts])).toEqual([['delete', ['(deleted)']]]) + const key = conflicted.plan.changes[0]!.key + await applyBranchMerge(harness.db, { branchId, direction: 'update', resolutions: { [key]: 'into' }, actorUserId: null }) + expect(await getDataRow(harness.db, MAIN_SCOPE, home!.id)).toBeNull() + expect((await getDataRow(harness.db, branch, home!.id))!.cells.title).toBe('Branch title') + }) + + it('moves the base forward when both sides converge, so a later edit is not a conflict', async () => { + harness = await createCapabilityTestHarness() + const owner = await harness.setupOwner() + const branchId = await forkViaApi(harness, owner, 'Converge') + const branch = { branchId } + const [home] = await listDataRows(harness.db, MAIN_SCOPE, 'pages') + + await saveDataRowDraft(harness.db, MAIN_SCOPE, home!.id, { cells: { ...home!.cells, title: 'Same' }, slug: home!.slug }) + await saveDataRowDraft(harness.db, branch, home!.id, { cells: { ...home!.cells, title: 'Same' }, slug: home!.slug }) + expect((await planBranchMerge(harness.db, branchId, 'merge')).plan.changes).toEqual([]) + await applyBranchMerge(harness.db, { branchId, direction: 'merge', resolutions: {}, actorUserId: null }) + + await saveDataRowDraft(harness.db, branch, home!.id, { cells: { ...home!.cells, title: 'Later' }, slug: home!.slug }) + const later = await planBranchMerge(harness.db, branchId, 'merge') + expect(later.plan.changes.map((change) => [change.action, change.conflicts])).toEqual([['update', []]]) + }) + + it('records fork bases from main, so a branch forked off another branch merges the parent\'s work too', async () => { + harness = await createCapabilityTestHarness() + const owner = await harness.setupOwner() + const parentId = await forkViaApi(harness, owner, 'Parent') + await upsertDataRowDraft(harness.db, { branchId: parentId }, { + id: 'parent-post', + tableId: 'posts', + cells: { title: 'From the parent', slug: 'from-the-parent' }, + slug: 'from-the-parent', + }) + const child = await harness.cms(BRANCHES, { method: 'POST', cookie: owner, json: { name: 'Child', fromBranchId: parentId } }) + expect(child.status).toBe(201) + const childId = (await readJson<{ branch: { id: string } }>(child)).branch.id + + const plan = await planBranchMerge(harness.db, childId, 'merge') + expect(plan.plan.changes.map((change) => [change.action, change.label])).toEqual([['create', 'From the parent']]) + await applyBranchMerge(harness.db, { branchId: childId, direction: 'merge', resolutions: {}, actorUserId: null }) + expect((await getDataRow(harness.db, MAIN_SCOPE, 'parent-post'))!.cells.title).toBe('From the parent') + }) +}) diff --git a/src/__tests__/server/branchPreviewLinks.test.ts b/src/__tests__/server/branchPreviewLinks.test.ts new file mode 100644 index 000000000..c42715e83 --- /dev/null +++ b/src/__tests__/server/branchPreviewLinks.test.ts @@ -0,0 +1,128 @@ +/** + * Branch preview links — issuing, the cookie handshake, rendering the + * branch's draft on the public site, and revocation. + */ +import { afterEach, describe, expect, it } from 'bun:test' +import { handleServerRequest } from '../../../server/router' +import { listDataRows, saveDataRowDraft } from '../../../server/repositories/data' +import { + createCapabilityTestHarness, + expectForbidden, + readJson, + type CapabilityTestHarness, +} from '../helpers/capabilityHarness' + +const BRANCHES = '/admin/api/cms/branches' +const PUBLIC_ORIGIN = 'http://localhost' + +function publicRequest(path: string, cookie?: string): Request { + const req = new Request(`${PUBLIC_ORIGIN}${path}`, { redirect: 'manual' }) + if (cookie) req.headers.set('cookie', cookie) + return req +} + +function cookieFrom(res: Response): string { + const header = res.headers.get('set-cookie') ?? '' + return header.split(';')[0] ?? '' +} + +describe('branch preview links', () => { + let harness: CapabilityTestHarness | null = null + + afterEach(async () => { + await harness?.cleanup() + harness = null + }) + + it('issues a link, renders the branch draft behind the cookie, and stops after revocation', async () => { + harness = await createCapabilityTestHarness() + const owner = await harness.setupOwner() + expect((await harness.cms(BRANCHES, { method: 'POST', cookie: owner, json: { name: 'Preview Me' } })).status).toBe(201) + + // Give the branch a home page title main does not have. + const scope = { branchId: 'preview-me' } + const [home] = await listDataRows(harness.db, scope, 'pages') + expect(home).toBeDefined() + await saveDataRowDraft(harness.db, scope, home!.id, { + cells: { ...home!.cells, title: 'Branch-only headline' }, + slug: home!.slug, + }) + + const none = await readJson<{ preview: unknown }>( + await harness.cms(`${BRANCHES}/preview-me/preview`, { cookie: owner }), + ) + expect(none.preview).toBeNull() + + const issued = await harness.cms(`${BRANCHES}/preview-me/preview`, { method: 'POST', cookie: owner }) + expect(issued.status).toBe(201) + const { url, preview } = await readJson<{ url: string; preview: { branchId: string } }>(issued) + expect(preview.branchId).toBe('preview-me') + expect(url).toMatch(/\/_instatic\/preview\/[A-Za-z0-9_-]{20,}$/) + + const active = await readJson<{ preview: { id: string } | null }>( + await harness.cms(`${BRANCHES}/preview-me/preview`, { cookie: owner }), + ) + expect(active.preview).not.toBeNull() + + // Entering the link sets the cookie and lands on the root. + const entry = await handleServerRequest(publicRequest(new URL(url).pathname), { db: harness.db }) + expect(entry.status).toBe(302) + expect(entry.headers.get('location')).toBe('/') + const cookie = cookieFrom(entry) + expect(cookie.startsWith('instatic_branch_preview=')).toBe(true) + expect(entry.headers.get('set-cookie')).toContain('HttpOnly') + + // With the cookie the root renders the branch draft, banner included. + const previewed = await handleServerRequest(publicRequest('/', cookie), { db: harness.db }) + expect(previewed.status).toBe(200) + expect(previewed.headers.get('cache-control')).toBe('no-store') + expect(previewed.headers.get('x-robots-tag')).toBe('noindex') + const html = await previewed.text() + expect(html).toContain('Branch-only headline') + expect(html).toContain('Previewing branch Preview Me') + expect(html).toContain('/_instatic/preview/exit') + + // Without the cookie nothing is published yet, so the root is not the branch. + const plain = await handleServerRequest(publicRequest('/'), { db: harness.db }) + expect(plain.status).not.toBe(200) + + // Exit clears the cookie. + const exit = await handleServerRequest(publicRequest('/_instatic/preview/exit', cookie), { db: harness.db }) + expect(exit.status).toBe(302) + expect(exit.headers.get('set-cookie')).toContain('Max-Age=0') + + // Revoking retires the link: the cookie no longer opens the branch. + const revoked = await harness.cms(`${BRANCHES}/preview-me/preview`, { method: 'DELETE', cookie: owner }) + expect(revoked.status).toBe(200) + const afterRevoke = await handleServerRequest(publicRequest('/', cookie), { db: harness.db }) + expect(afterRevoke.status).not.toBe(200) + const deadEntry = await handleServerRequest(publicRequest(new URL(url).pathname), { db: harness.db }) + expect(deadEntry.status).toBe(302) + expect(deadEntry.headers.get('set-cookie')).toContain('Max-Age=0') + }) + + it('rotates the link on every share and gates issuing on site.branches.manage', async () => { + harness = await createCapabilityTestHarness() + const owner = await harness.setupOwner() + expect((await harness.cms(BRANCHES, { method: 'POST', cookie: owner, json: { name: 'Rotate' } })).status).toBe(201) + + const first = await readJson<{ url: string }>( + await harness.cms(`${BRANCHES}/rotate/preview`, { method: 'POST', cookie: owner }), + ) + const second = await readJson<{ url: string }>( + await harness.cms(`${BRANCHES}/rotate/preview`, { method: 'POST', cookie: owner }), + ) + expect(second.url).not.toBe(first.url) + const stale = await handleServerRequest(publicRequest(new URL(first.url).pathname), { db: harness.db }) + expect(stale.headers.get('set-cookie')).toContain('Max-Age=0') + const fresh = await handleServerRequest(publicRequest(new URL(second.url).pathname), { db: harness.db }) + expect(fresh.headers.get('set-cookie')).not.toContain('Max-Age=0') + + const reader = await harness.createRoleUser({ name: 'Reader', slug: 'reader', capabilities: ['site.read'] }) + expect((await harness.cms(`${BRANCHES}/rotate/preview`, { cookie: reader.cookie })).status).toBe(200) + await expectForbidden(await harness.cms(`${BRANCHES}/rotate/preview`, { method: 'POST', cookie: reader.cookie })) + await expectForbidden(await harness.cms(`${BRANCHES}/rotate/preview`, { method: 'DELETE', cookie: reader.cookie })) + + expect((await harness.cms(`${BRANCHES}/main/preview`, { method: 'POST', cookie: owner })).status).toBe(400) + }) +}) diff --git a/src/__tests__/server/branchScope.test.ts b/src/__tests__/server/branchScope.test.ts new file mode 100644 index 000000000..a83924569 --- /dev/null +++ b/src/__tests__/server/branchScope.test.ts @@ -0,0 +1,172 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test' +import { createTestDb, type TestDb } from '../helpers/createTestDb' +import { + BRANCH_HEADER, + BRANCH_NOT_FOUND_CODE, + MAIN_SCOPE, + resolveBranchScope, +} from '../../../server/branches/scope' +import { insertBranch, listBranches } from '../../../server/repositories/branches' +import { + createDataRow, + getDataRow, + listDataRows, + listDataTables, + getDataTable, + saveDataRowDraft, + softDeleteDataRow, +} from '../../../server/repositories/data' +import { insertDataTableIfAbsent } from '../../../server/repositories/data/tables' +import { getDraftSite, saveDraftSite } from '../../../server/repositories/site' +import { MAIN_SCOPE } from '../../../server/branches/scope' +import { createSite } from '../../../server/repositories/setup' + +let testDb: TestDb + +beforeAll(async () => { + testDb = await createTestDb() +}) + +afterAll(async () => { + await testDb.cleanup() +}) + +function request(header?: string): Request { + return new Request('http://localhost/admin/api/cms/pages', { + headers: header === undefined ? {} : { [BRANCH_HEADER]: header }, + }) +} + +describe('resolveBranchScope', () => { + it('treats a missing or main header as main without touching the database', async () => { + expect(await resolveBranchScope(request(), testDb.db)).toBe(MAIN_SCOPE) + expect(await resolveBranchScope(request('main'), testDb.db)).toBe(MAIN_SCOPE) + expect(await resolveBranchScope(request(' '), testDb.db)).toBe(MAIN_SCOPE) + }) + + it('rejects malformed ids before consulting the registry', async () => { + const res = await resolveBranchScope(request('Not Valid'), testDb.db) + expect(res).toBeInstanceOf(Response) + expect((res as Response).status).toBe(400) + }) + + it('answers 404 with a stable code for an unknown branch', async () => { + const res = await resolveBranchScope(request('ghost'), testDb.db) + expect(res).toBeInstanceOf(Response) + expect((res as Response).status).toBe(404) + const body = await (res as Response).json() + expect(body.code).toBe(BRANCH_NOT_FOUND_CODE) + }) + + it('resolves an existing branch', async () => { + await insertBranch(testDb.db, { + id: 'spring', + name: 'Spring', + baseBranchId: 'main', + createdByUserId: null, + }) + expect(await resolveBranchScope(request('spring'), testDb.db)).toEqual({ branchId: 'spring' }) + const branches = await listBranches(testDb.db) + expect(branches.map((branch) => branch.id)).toEqual(['main', 'spring']) + }) +}) + +describe('branch-scoped repositories', () => { + it('seeds every system table on main with its logical id', async () => { + const tables = await listDataTables(testDb.db, MAIN_SCOPE) + const ids = tables.map((table) => table.id) + expect(ids).toContain('pages') + expect(ids).toContain('posts') + expect(ids).toContain('components') + expect(ids).toContain('layouts') + const { rows } = await testDb.db<{ id: string; logical_id: string; branch_id: string }>` + select id, logical_id, branch_id from data_tables where logical_id = 'pages' + ` + expect(rows).toEqual([{ id: 'pages', logical_id: 'pages', branch_id: 'main' }]) + }) + + it('keeps a branch row invisible to main and exposes only logical ids', async () => { + const spring = { branchId: 'spring' } + const inserted = await insertDataTableIfAbsent(testDb.db, spring, { + id: 'pages', + name: 'Pages', + slug: 'pages', + kind: 'page', + singularLabel: 'Page', + pluralLabel: 'Pages', + }) + expect(inserted).toBe(true) + + const created = await createDataRow(testDb.db, spring, { + id: 'home', + tableId: 'pages', + cells: { title: 'Home', slug: 'index' }, + slug: 'index', + }) + expect(created.id).toBe('home') + expect(created.tableId).toBe('pages') + + const { rows } = await testDb.db<{ id: string; table_id: string; branch_id: string }>` + select id, table_id, branch_id from data_rows where logical_id = 'home' + ` + expect(rows).toEqual([{ id: 'spring:home', table_id: 'spring:pages', branch_id: 'spring' }]) + + expect(await getDataRow(testDb.db, spring, 'home')).not.toBeNull() + expect(await getDataRow(testDb.db, MAIN_SCOPE, 'home')).toBeNull() + expect((await listDataRows(testDb.db, MAIN_SCOPE, 'pages')).map((row) => row.id)).not.toContain('home') + expect((await listDataRows(testDb.db, spring, 'pages')).map((row) => row.id)).toEqual(['home']) + }) + + it('keeps one shell row per branch', async () => { + await createSite(testDb.db, 'Main site', {}) + const main = await getDraftSite(testDb.db, MAIN_SCOPE) + expect(main?.name).toBe('Main site') + expect(await getDraftSite(testDb.db, { branchId: 'spring' })).toBeNull() + + await saveDraftSite(testDb.db, { branchId: 'spring' }, { ...main!, name: 'Spring site' }) + expect((await getDraftSite(testDb.db, { branchId: 'spring' }))?.name).toBe('Spring site') + expect((await getDraftSite(testDb.db, MAIN_SCOPE))?.name).toBe('Main site') + const { rows } = await testDb.db<{ id: string; branch_id: string; logical_id: string }>` + select id, branch_id, logical_id from site order by id + ` + expect(rows).toEqual([ + { id: 'default', branch_id: 'main', logical_id: 'default' }, + { id: 'spring:default', branch_id: 'spring', logical_id: 'default' }, + ]) + }) +}) + +describe('physical ids never cross scopes', () => { + let testDb: TestDb + + beforeEach(async () => { + testDb = await createTestDb() + await createSite(testDb.db, 'Main site', {}) + }) + + afterEach(async () => { + await testDb.cleanup() + }) + + it('refuses a branch row or table addressed by its physical id from main', async () => { + const spring = { branchId: 'spring' } + await insertBranch(testDb.db, { id: 'spring', name: 'Spring', baseBranchId: 'main', createdByUserId: null }) + await insertDataTableIfAbsent(testDb.db, spring, { + id: 'pages', + name: 'Pages', + slug: 'pages', + kind: 'page', + singularLabel: 'Page', + pluralLabel: 'Pages', + }) + await createDataRow(testDb.db, spring, { id: 'home', tableId: 'pages', cells: { title: 'Spring home' }, slug: 'home' }) + + expect(await getDataRow(testDb.db, MAIN_SCOPE, 'spring:home')).toBeNull() + expect(await getDataTable(testDb.db, MAIN_SCOPE, 'spring:pages')).toBeNull() + expect(await listDataRows(testDb.db, MAIN_SCOPE, 'spring:pages')).toEqual([]) + expect(await softDeleteDataRow(testDb.db, MAIN_SCOPE, 'spring:home')).toBeNull() + expect(await saveDataRowDraft(testDb.db, MAIN_SCOPE, 'spring:home', { cells: { title: 'x' }, slug: 'home' })).toBeNull() + // The branch still sees its row. + expect((await getDataRow(testDb.db, spring, 'home'))?.cells.title).toBe('Spring home') + }) +}) diff --git a/src/__tests__/server/branchesHandler.test.ts b/src/__tests__/server/branchesHandler.test.ts new file mode 100644 index 000000000..3f9d369f8 --- /dev/null +++ b/src/__tests__/server/branchesHandler.test.ts @@ -0,0 +1,226 @@ +/** + * Site branches endpoints — registry CRUD, capability + step-up gates, and + * the header-scoped fallback once a branch is gone. + */ +import { afterEach, describe, expect, it } from 'bun:test' +import { upsertDataRowDraft } from '../../../server/repositories/data' +import { + createCapabilityTestHarness, + expectForbidden, + expectStepUpRequired, + readJson, + type CapabilityTestHarness, +} from '../helpers/capabilityHarness' + +const BRANCHES = '/admin/api/cms/branches' +const BRANCH_HEADER = 'x-instatic-branch' + +interface BranchPayload { + id: string + name: string + baseBranchId: string | null +} + +describe('branches endpoints', () => { + let harness: CapabilityTestHarness | null = null + + afterEach(async () => { + await harness?.cleanup() + harness = null + }) + + it('lists main, forks a branch, renames it, and scopes content requests by header', async () => { + harness = await createCapabilityTestHarness() + const owner = await harness.setupOwner() + + const initial = await readJson<{ branches: BranchPayload[] }>(await harness.cms(BRANCHES, { cookie: owner })) + expect(initial.branches.map((branch) => branch.id)).toEqual(['main']) + + const created = await harness.cms(BRANCHES, { + method: 'POST', + cookie: owner, + json: { name: 'Spring Redesign' }, + }) + expect(created.status).toBe(201) + const { branch } = await readJson<{ branch: BranchPayload }>(created) + expect(branch).toMatchObject({ id: 'spring-redesign', name: 'Spring Redesign', baseBranchId: 'main' }) + + const listed = await readJson<{ branches: BranchPayload[] }>(await harness.cms(BRANCHES, { cookie: owner })) + expect(listed.branches.map((entry) => entry.id)).toEqual(['main', 'spring-redesign']) + + // The fork carries the system tables, addressed through the header. + const mainTables = await readJson<{ tables: Array<{ id: string }> }>( + await harness.cms('/admin/api/cms/data/tables', { cookie: owner }), + ) + const branchTables = await readJson<{ tables: Array<{ id: string }> }>( + await harness.cms('/admin/api/cms/data/tables', { + cookie: owner, + headers: { [BRANCH_HEADER]: 'spring-redesign' }, + }), + ) + expect(branchTables.tables.map((table) => table.id).sort()).toEqual( + mainTables.tables.map((table) => table.id).sort(), + ) + + const renamed = await harness.cms(`${BRANCHES}/spring-redesign`, { + method: 'PATCH', + cookie: owner, + json: { name: 'Spring 2027' }, + }) + expect(renamed.status).toBe(200) + expect((await readJson<{ branch: BranchPayload }>(renamed)).branch.name).toBe('Spring 2027') + + const unknown = await harness.cms('/admin/api/cms/data/tables', { + cookie: owner, + headers: { [BRANCH_HEADER]: 'nope' }, + }) + expect(unknown.status).toBe(404) + expect(await readJson<{ code: string }>(unknown)).toMatchObject({ code: 'branch_not_found' }) + }) + + it('refuses duplicate ids, malformed ids, and any change to main', async () => { + harness = await createCapabilityTestHarness() + const owner = await harness.setupOwner() + expect((await harness.cms(BRANCHES, { method: 'POST', cookie: owner, json: { name: 'Alpha' } })).status).toBe(201) + + const duplicate = await harness.cms(BRANCHES, { method: 'POST', cookie: owner, json: { name: 'alpha' } }) + expect(duplicate.status).toBe(409) + + const malformed = await harness.cms(BRANCHES, { method: 'POST', cookie: owner, json: { name: 'x', id: 'Bad:Id' } }) + expect(malformed.status).toBe(400) + + const recreateMain = await harness.cms(BRANCHES, { method: 'POST', cookie: owner, json: { name: 'main' } }) + expect(recreateMain.status).toBe(400) + + const renameMain = await harness.cms(`${BRANCHES}/main`, { method: 'PATCH', cookie: owner, json: { name: 'Live' } }) + expect(renameMain.status).toBe(400) + + const deleteMain = await harness.cms(`${BRANCHES}/main`, { method: 'DELETE', cookie: owner }) + expect(deleteMain.status).toBe(400) + }) + + it('gates management on site.branches.manage and deletion on step-up', async () => { + harness = await createCapabilityTestHarness() + const owner = await harness.setupOwner() + expect((await harness.cms(BRANCHES, { method: 'POST', cookie: owner, json: { name: 'Doomed' } })).status).toBe(201) + + const reader = await harness.createRoleUser({ + name: 'Reader', + slug: 'reader', + capabilities: ['site.read'], + }) + expect((await harness.cms(BRANCHES, { cookie: reader.cookie })).status).toBe(200) + await expectForbidden(await harness.cms(BRANCHES, { method: 'POST', cookie: reader.cookie, json: { name: 'Nope' } })) + await expectForbidden(await harness.cms(`${BRANCHES}/doomed`, { method: 'DELETE', cookie: reader.cookie })) + + const manager = await harness.createRoleUser({ + name: 'Branch manager', + slug: 'branch-manager', + capabilities: ['site.read', 'site.branches.manage'], + }) + await expectStepUpRequired(await harness.cms(`${BRANCHES}/doomed`, { method: 'DELETE', cookie: manager.cookie })) + + const stepped = await harness.stepUp(manager.cookie) + const deleted = await harness.cms(`${BRANCHES}/doomed`, { method: 'DELETE', cookie: stepped }) + expect(deleted.status).toBe(200) + + const gone = await harness.cms('/admin/api/cms/data/tables', { + cookie: owner, + headers: { [BRANCH_HEADER]: 'doomed' }, + }) + expect(gone.status).toBe(404) + expect(await readJson<{ code: string }>(gone)).toMatchObject({ code: 'branch_not_found' }) + + const remaining = await readJson<{ branches: BranchPayload[] }>(await harness.cms(BRANCHES, { cookie: owner })) + expect(remaining.branches.map((branch) => branch.id)).toEqual(['main']) + }) +}) + +describe('branch content on the canvas', () => { + let harness: CapabilityTestHarness | null = null + + afterEach(async () => { + await harness?.cleanup() + harness = null + }) + + it('previews a branch loop from the branch\'s draft rows, and main from published rows only', async () => { + harness = await createCapabilityTestHarness() + const owner = await harness.setupOwner() + expect((await harness.cms(BRANCHES, { method: 'POST', cookie: owner, json: { name: 'Loopy' } })).status).toBe(201) + await upsertDataRowDraft(harness.db, { branchId: 'loopy' }, { + id: 'branch-only', + tableId: 'posts', + cells: { title: 'Only on the branch', slug: 'only-on-the-branch' }, + slug: 'only-on-the-branch', + }) + + const onBranch = await readJson<{ items: Array<{ id: string }> }>( + await harness.cms('/admin/api/cms/data/tables/posts/loop-preview', { + cookie: owner, + headers: { [BRANCH_HEADER]: 'loopy' }, + }), + ) + expect(onBranch.items.map((item) => item.id)).toEqual(['branch-only']) + + const onMain = await readJson<{ items: Array<{ id: string }> }>( + await harness.cms('/admin/api/cms/data/tables/posts/loop-preview', { cookie: owner }), + ) + expect(onMain.items).toEqual([]) + }) +}) + +describe('branch export', () => { + let harness: CapabilityTestHarness | null = null + + afterEach(async () => { + await harness?.cleanup() + harness = null + }) + + function exportForm(request: Record): { body: string; headers: Record } { + return { + body: new URLSearchParams({ exportRequest: JSON.stringify(request) }).toString(), + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + } + } + + it('exports the branch named in the form body, and main without one', async () => { + harness = await createCapabilityTestHarness() + const owner = await harness.setupOwner() + expect((await harness.cms(BRANCHES, { method: 'POST', cookie: owner, json: { name: 'Bundle' } })).status).toBe(201) + await upsertDataRowDraft(harness.db, { branchId: 'bundle' }, { + id: 'bundle-only', + tableId: 'posts', + cells: { title: 'Only in the bundle', slug: 'only-in-the-bundle' }, + slug: 'only-in-the-bundle', + }) + + // The download is a form POST (no branch header): the body names the branch. + const onBranch = await harness.cms('/admin/api/cms/export', { + method: 'POST', + cookie: owner, + ...exportForm({ includeMedia: false, branchId: 'bundle' }), + }) + expect(onBranch.status).toBe(200) + expect(onBranch.headers.get('content-type')).toContain('zip') + // Stored (uncompressed) zip: the bundle JSON is readable in the body. + expect(await onBranch.text()).toContain('only-in-the-bundle') + + const onMain = await harness.cms('/admin/api/cms/export', { + method: 'POST', + cookie: owner, + ...exportForm({ includeMedia: false }), + }) + expect(onMain.status).toBe(200) + expect(await onMain.text()).not.toContain('only-in-the-bundle') + + const unknown = await harness.cms('/admin/api/cms/export', { + method: 'POST', + cookie: owner, + ...exportForm({ includeMedia: false, branchId: 'nope' }), + }) + expect(unknown.status).toBe(404) + expect(await readJson<{ code: string }>(unknown)).toMatchObject({ code: 'branch_not_found' }) + }) +}) diff --git a/src/__tests__/server/cmsDataAuthorization.test.ts b/src/__tests__/server/cmsDataAuthorization.test.ts index 61f8434b1..ee279dfc1 100644 --- a/src/__tests__/server/cmsDataAuthorization.test.ts +++ b/src/__tests__/server/cmsDataAuthorization.test.ts @@ -3,6 +3,7 @@ import { handleCmsRequest } from '../../../server/handlers/cms' import type { DbClient } from '../../../server/db' import { createTestDb, type TestDb } from '../helpers/createTestDb' import { registerPublishFlush } from '../../../server/publish/publishFlush' +import { MAIN_SCOPE } from '../../../server/branches/scope' import { upsertDataRowDraft } from '../../../server/repositories/data' const ownedPassword = 'long-enough-password' @@ -383,6 +384,7 @@ describe('CMS data ownership authorization', () => { flushed = true await upsertDataRowDraft( db, + MAIN_SCOPE, { id: relayResidentId, tableId: 'posts', diff --git a/src/__tests__/server/cmsPublish.test.ts b/src/__tests__/server/cmsPublish.test.ts index 1aa4e6b45..a1e5e6dce 100644 --- a/src/__tests__/server/cmsPublish.test.ts +++ b/src/__tests__/server/cmsPublish.test.ts @@ -11,6 +11,7 @@ import { publishDraftSite } from '../../../server/publish/publishSite' import { createDataRow, saveDataRowDraft } from '../../../server/repositories/data' import { pageToCells } from '../../../src/core/data/pageFromRow' import { createFakeDb } from './dbTestFake' +import { MAIN_SCOPE } from '../../../server/branches/scope' function createPublishFakeDb() { const state = { @@ -28,8 +29,8 @@ function createPublishFakeDb() { if (sql.startsWith('insert into site (')) { state.site = { id: 'default', - name: params[0], - settings_json: params[1], + name: params[1], + settings_json: params[2], created_at: new Date('2026-01-01').toISOString(), updated_at: new Date('2026-01-02').toISOString(), } @@ -43,13 +44,14 @@ function createPublishFakeDb() { if (sql.startsWith('insert into data_rows')) { const row = { id: params[0], - table_id: params[1], - cells_json: params[2], - slug: params[3], - status: params[4], - author_user_id: params[5], - created_by_user_id: params[6], - updated_by_user_id: params[7], + logical_id: params[0], + table_id: params[2], + cells_json: params[3], + slug: params[4], + status: params[5], + author_user_id: params[6], + created_by_user_id: params[7], + updated_by_user_id: params[8], active_version_id: null, published_by_user_id: null, published_at: null, @@ -60,7 +62,7 @@ function createPublishFakeDb() { const idx = state.dataRows.findIndex((r) => r.id === row.id) if (idx >= 0) state.dataRows[idx] = row else state.dataRows.push(row) - return { rows: [{ id: row.id }], rowCount: 1 } + return { rows: [{ logical_id: row.id }], rowCount: 1 } } // saveDataRowDraft — update data_rows set cells_json, slug, updated_by_user_id, plugin_actor_id // params: [0]=cells_json, [1]=slug, [2]=updated_by_user_id, [3]=plugin_actor_id, [4]=rowId @@ -267,9 +269,9 @@ async function seedSiteAndPage( text: string, ) { const shell = makeSiteShell() - await saveDraftSite(db, shell) + await saveDraftSite(db, MAIN_SCOPE, shell) const page = makeHomePage(text) - await createDataRow(db, { + await createDataRow(db, MAIN_SCOPE, { id: page.id, tableId: 'pages', cells: pageToCells(page), @@ -296,7 +298,7 @@ describe('CMS publishing', () => { await publishDraftSite(db, 'admin_1') // Update the draft page text - await saveDataRowDraft(db, 'page_home', { + await saveDataRowDraft(db, MAIN_SCOPE, 'page_home', { cells: pageToCells({ ...makeHomePage('Draft only') }), slug: 'index', }, 'admin_1') @@ -324,7 +326,7 @@ describe('CMS publishing', () => { it('keeps publish status matched when publishing changes the rows recency order', async () => { const { state, db } = createPublishFakeDb() const shell = makeSiteShell() - await saveDraftSite(db, shell) + await saveDraftSite(db, MAIN_SCOPE, shell) const home = makeHomePage('Home') const layout = { @@ -339,7 +341,7 @@ describe('CMS publishing', () => { }, } for (const page of [home, layout]) { - await createDataRow(db, { + await createDataRow(db, MAIN_SCOPE, { id: page.id, tableId: 'pages', cells: pageToCells(page), @@ -372,7 +374,7 @@ describe('CMS publishing', () => { await publishDraftSite(db, 'admin_1') // Update the draft to create mismatch - await saveDataRowDraft(db, 'page_home', { + await saveDataRowDraft(db, MAIN_SCOPE, 'page_home', { cells: pageToCells({ ...makeHomePage('Draft only') }), slug: 'index', }, 'admin_1') @@ -409,9 +411,9 @@ describe('CMS publishing', () => { }, }), }) - await saveDraftSite(db, shell) + await saveDraftSite(db, MAIN_SCOPE, shell) const page = makeHomePage('Runtime page') - await createDataRow(db, { + await createDataRow(db, MAIN_SCOPE, { id: page.id, tableId: 'pages', cells: pageToCells(page), @@ -449,9 +451,9 @@ describe('CMS publishing', () => { }, }), }) - await saveDraftSite(db, shell) + await saveDraftSite(db, MAIN_SCOPE, shell) const page = makeHomePage('Runtime page') - await createDataRow(db, { + await createDataRow(db, MAIN_SCOPE, { id: page.id, tableId: 'pages', cells: pageToCells(page), diff --git a/src/__tests__/server/cmsSiteHandlers.test.ts b/src/__tests__/server/cmsSiteHandlers.test.ts index 75e9a3f8c..bb7222541 100644 --- a/src/__tests__/server/cmsSiteHandlers.test.ts +++ b/src/__tests__/server/cmsSiteHandlers.test.ts @@ -56,8 +56,8 @@ function makeFakeDb() { if (normalized.includes('insert into site')) { siteRow = { id: 'default', - name: values[0], - settings_json: values[1], + name: values[1], + settings_json: values[2], created_at: new Date('2026-01-01').toISOString(), updated_at: new Date('2026-01-02').toISOString(), } diff --git a/src/__tests__/server/cmsSitePersistence.test.ts b/src/__tests__/server/cmsSitePersistence.test.ts index e75b39af7..5cc9836c0 100644 --- a/src/__tests__/server/cmsSitePersistence.test.ts +++ b/src/__tests__/server/cmsSitePersistence.test.ts @@ -8,6 +8,7 @@ import { saveDraftSite, } from '../../../server/repositories/site' import { createFakeDb } from './dbTestFake' +import { MAIN_SCOPE } from '../../../server/branches/scope' function createSiteFakeDb() { const state = { @@ -20,8 +21,8 @@ function createSiteFakeDb() { if (sql.startsWith('insert into site')) { state.site = { id: 'default', - name: params[0], - settings_json: params[1], + name: params[1], + settings_json: params[2], created_at: new Date('2026-01-01').toISOString(), updated_at: new Date('2026-01-02').toISOString(), } @@ -79,7 +80,7 @@ function validShell(overrides: Partial = {}): SiteShell { describe('CMS draft site persistence', () => { it('saves the site shell and loads it back', async () => { const { state, db } = createSiteFakeDb() - await saveDraftSite(db, validShell(), 'user_1') + await saveDraftSite(db, MAIN_SCOPE, validShell(), 'user_1') expect(state.site).toMatchObject({ name: 'Example Site' }) expect(state.site?.settings_json).toMatchObject({ @@ -94,9 +95,9 @@ describe('CMS draft site persistence', () => { it('loads a saved draft site without reading pages (shell-only)', async () => { const { db } = createSiteFakeDb() - await saveDraftSite(db, validShell(), 'user_1') + await saveDraftSite(db, MAIN_SCOPE, validShell(), 'user_1') - const loaded = await getDraftSite(db) + const loaded = await getDraftSite(db, MAIN_SCOPE) expect(loaded).toMatchObject({ id: 'project_1', @@ -110,7 +111,7 @@ describe('CMS draft site persistence', () => { it('round-trips reusable CSS conditions in the site shell', async () => { const { db } = createSiteFakeDb() - await saveDraftSite(db, validShell({ + await saveDraftSite(db, MAIN_SCOPE, validShell({ conditions: [ { id: 'media:(min-width: 1200px)', @@ -135,7 +136,7 @@ describe('CMS draft site persistence', () => { }, }), 'user_1') - const loaded = await getDraftSite(db) + const loaded = await getDraftSite(db, MAIN_SCOPE) expect(loaded?.conditions).toEqual([ { @@ -149,7 +150,7 @@ describe('CMS draft site persistence', () => { it('validates the stored shell and throws SiteValidationError on corrupt data', async () => { const { state, db } = createSiteFakeDb() - await saveDraftSite(db, validShell(), 'user_1') + await saveDraftSite(db, MAIN_SCOPE, validShell(), 'user_1') // Corrupt a breakpoint: inject an invalid width type. // readStoredShell passes arrays through as-is, so this reaches validateSite @@ -158,12 +159,12 @@ describe('CMS draft site persistence', () => { const site = payload.site as Record site.breakpoints = [{ id: 'desktop', label: 'Desktop', width: 'not-a-number', icon: 'monitor' }] - await expect(getDraftSite(db)).rejects.toThrow(SiteValidationError) + await expect(getDraftSite(db, MAIN_SCOPE)).rejects.toThrow(SiteValidationError) }) it('round-trips site runtime settings in the site shell', async () => { const { db } = createSiteFakeDb() - await saveDraftSite(db, validShell({ + await saveDraftSite(db, MAIN_SCOPE, validShell({ runtime: normalizeSiteRuntimeConfig({ scripts: { script_1: { @@ -174,7 +175,7 @@ describe('CMS draft site persistence', () => { }), })) - const loaded = await getDraftSite(db) + const loaded = await getDraftSite(db, MAIN_SCOPE) expect(loaded?.runtime?.scripts.script_1).toMatchObject({ placement: 'head', diff --git a/src/__tests__/server/collabRelay.test.ts b/src/__tests__/server/collabRelay.test.ts index f6514a8bd..bcb4b8ca2 100644 --- a/src/__tests__/server/collabRelay.test.ts +++ b/src/__tests__/server/collabRelay.test.ts @@ -11,7 +11,7 @@ import { projectPageDoc, rostersMap, shellMap, - SITE_DOC_ID, + MAIN_SITE_DOC_ID, treeMap, } from '@core/collab' import { createCollabRelay, type CollabRelay } from '../../../server/collab/relay' @@ -33,6 +33,7 @@ import { createCapabilityTestHarness, type CapabilityTestHarness, } from '../helpers/capabilityHarness' +import { MAIN_SCOPE } from '../../../server/branches/scope' let cleanups: Array<() => Promise> = [] @@ -152,7 +153,7 @@ function gateRosterSweep(db: DbClient): { if ( armed && !gated && - sql.includes('select id, slug from data_rows') && + sql.includes('select logical_id as id, slug from data_rows') && values.includes('pages') ) { gated = true @@ -314,7 +315,7 @@ function observeRosterSweep(db: DbClient): { if ( armed && !announced && - strings.join('?').includes('select id, slug from data_rows') && + strings.join('?').includes('select logical_id as id, slug from data_rows') && values.includes('layouts') ) { announced = true @@ -360,7 +361,7 @@ function populateFreshPage(doc: Y.Doc, title: string, slug: string): void { describe('collab relay', () => { it('seeds a page doc deterministically from the stored row (identical state on repeat)', async () => { const { harness, relay, homeId } = await setup() - const { doc: doc } = await relay.openDoc(`page:${homeId}`) + const { doc: doc } = await relay.openDoc(`page:main:${homeId}`) const projected = projectPageDoc(doc, homeId) expect(projected.slug).toBe('index') expect(projected.rootNodeId).not.toBe('') @@ -369,13 +370,13 @@ describe('collab relay', () => { // deterministic seeding must produce an identical state vector. const relay2 = createCollabRelay(harness.db, { persistDebounceMs: 10 }) cleanups.push(() => relay2.destroy()) - const { doc: doc2 } = await relay2.openDoc(`page:${homeId}`) + const { doc: doc2 } = await relay2.openDoc(`page:main:${homeId}`) expect(Y.encodeStateVector(doc2)).toEqual(Y.encodeStateVector(doc)) }) it('persists the blob AND the derived JSON after an update', async () => { const { harness, relay, homeId } = await setup() - const docId = `page:${homeId}` + const docId = `page:main:${homeId}` const { doc: doc } = await relay.openDoc(docId) editTitleUpdate(doc, 'Hero section') @@ -402,7 +403,7 @@ describe('collab relay', () => { select id from data_rows where table_id = ${'pages'} ` const homeId = rows[0].id - const docId = `page:${homeId}` + const docId = `page:main:${homeId}` const { doc } = await relay.retain(docId) failing.arm() @@ -436,7 +437,7 @@ describe('collab relay', () => { const { rows } = await harness.db<{ id: string }>` select id from data_rows where table_id = ${'pages'} ` - const docId = `page:${rows[0].id}` + const docId = `page:main:${rows[0].id}` const { doc } = await relay.openDoc(docId) failing.arm() @@ -453,7 +454,7 @@ describe('collab relay', () => { it('roster removal soft-deletes the row on site-doc persist', async () => { const { harness, relay, homeId } = await setup() - const { doc: siteDoc } = await relay.openDoc(SITE_DOC_ID) + const { doc: siteDoc } = await relay.openDoc(MAIN_SITE_DOC_ID) siteDoc.transact(() => { const rosters = rostersMap(siteDoc) ;(rosters.get('pages') as Y.Map).delete(homeId) @@ -470,7 +471,7 @@ describe('collab relay', () => { it('an out-of-relay row write resets the doc and notifies listeners', async () => { const { harness, relay, homeId } = await setup() - const docId = `page:${homeId}` + const docId = `page:main:${homeId}` await relay.openDoc(docId) await relay.flushAll() expect((await getCollabDocumentState(harness.db, docId))?.state).toBeDefined() @@ -482,7 +483,7 @@ describe('collab relay', () => { const { rows } = await harness.db<{ cells_json: Record; slug: string }>` select cells_json, slug from data_rows where id = ${homeId} ` - await saveDataRowDraft(harness.db, homeId, { cells: rows[0].cells_json, slug: rows[0].slug }) + await saveDataRowDraft(harness.db, MAIN_SCOPE, homeId, { cells: rows[0].cells_json, slug: rows[0].slug }) await new Promise((resolve) => setTimeout(resolve, 20)) expect(resets).toContain(docId) @@ -491,7 +492,7 @@ describe('collab relay', () => { it('a doc with neither blob nor row starts empty (client-created-row flow) and persists a new row', async () => { const { harness, relay } = await setup() - const docId = 'page:fresh-row-id' + const docId = 'page:main:fresh-row-id' const { doc: doc } = await relay.openDoc(docId) expect(treeMap(doc).get('rootNodeId')).toBeUndefined() @@ -524,12 +525,12 @@ describe('collab relay', () => { it('does not resurrect a dirty deleted page ahead of its same-slug replacement', async () => { const { harness, relay, homeId } = await setup() - const { doc: siteDoc } = await relay.openDoc(SITE_DOC_ID) - const { doc: oldPage } = await relay.openDoc(`page:${homeId}`) + const { doc: siteDoc } = await relay.openDoc(MAIN_SITE_DOC_ID) + const { doc: oldPage } = await relay.openDoc(`page:main:${homeId}`) editTitleUpdate(oldPage, 'Dirty page being replaced') const replacementId = 'replacement-page' - const { doc: replacement } = await relay.openDoc(`page:${replacementId}`) + const { doc: replacement } = await relay.openDoc(`page:main:${replacementId}`) populateFreshPage(replacement, 'Replacement', 'index') siteDoc.transact(() => { @@ -561,7 +562,7 @@ describe('collab relay', () => { const { rows } = await harness.db<{ id: string }>` select id from data_rows where table_id = ${'pages'} ` - const docId = `page:${rows[0].id}` + const docId = `page:main:${rows[0].id}` const { doc } = await relay.openDoc(docId) gated.arm() editTitleUpdate(doc, 'Snapshot A') @@ -599,9 +600,9 @@ describe('collab relay', () => { it('does not insert a client-created page that was removed from the roster before its first flush', async () => { const { harness, relay } = await setup() - const { doc: siteDoc } = await relay.openDoc(SITE_DOC_ID) + const { doc: siteDoc } = await relay.openDoc(MAIN_SITE_DOC_ID) const rowId = 'abandoned-page' - const { doc: pageDoc } = await relay.openDoc(`page:${rowId}`) + const { doc: pageDoc } = await relay.openDoc(`page:main:${rowId}`) populateFreshPage(pageDoc, 'Abandoned', 'abandoned') siteDoc.transact(() => { @@ -621,22 +622,22 @@ describe('collab relay', () => { it('restores the latest page after deletion, disconnect, eviction, and roster undo', async () => { const { harness, relay, homeId } = await setup() - const { doc: siteDoc } = await relay.openDoc(SITE_DOC_ID) - const retained = await relay.retain(`page:${homeId}`) + const { doc: siteDoc } = await relay.openDoc(MAIN_SITE_DOC_ID) + const retained = await relay.retain(`page:main:${homeId}`) editTitleUpdate(retained.doc, 'Latest content survives undo') siteDoc.transact(() => { ;(rostersMap(siteDoc).get('pages') as Y.Map).delete(homeId) }, LOCAL_ORIGIN) await relay.flushAll() - expect(await getCollabDocumentState(harness.db, `page:${homeId}`)).not.toBeNull() + expect(await getCollabDocumentState(harness.db, `page:main:${homeId}`)).not.toBeNull() const destroyed = new Promise((resolve) => { retained.doc.on('destroy', () => resolve()) }) - relay.release(`page:${homeId}`) + relay.release(`page:main:${homeId}`) await destroyed - expect(await getCollabDocumentState(harness.db, `page:${homeId}`)).not.toBeNull() + expect(await getCollabDocumentState(harness.db, `page:main:${homeId}`)).not.toBeNull() siteDoc.transact(() => { ;(rostersMap(siteDoc).get('pages') as Y.Map).set(homeId, true) @@ -665,8 +666,8 @@ describe('collab relay', () => { select id from data_rows where table_id = ${'pages'} ` const homeId = rows[0].id - const { doc: siteDoc } = await relay.openDoc(SITE_DOC_ID) - const { doc: pageDoc } = await relay.openDoc(`page:${homeId}`) + const { doc: siteDoc } = await relay.openDoc(MAIN_SITE_DOC_ID) + const { doc: pageDoc } = await relay.openDoc(`page:main:${homeId}`) gated.arm() editTitleUpdate(pageDoc, 'Persist blocked before roster removal') @@ -703,13 +704,14 @@ describe('collab relay', () => { it('coalesces shell and row invalidations so an external batch creation survives reset', async () => { const { harness, relay, homeId } = await setup() - await relay.openDoc(SITE_DOC_ID) + await relay.openDoc(MAIN_SITE_DOC_ID) const { rows: sourceRows } = await harness.db<{ cells_json: Record }>` select cells_json from data_rows where id = ${homeId} ` const rowId = 'external-batch-page' await createDataRow( harness.db, + MAIN_SCOPE, { id: rowId, tableId: 'pages', @@ -728,12 +730,12 @@ describe('collab relay', () => { }) const unsubscribe = relay.onReset((docId) => { resetIds.add(docId) - if (resetIds.has(SITE_DOC_ID) && resetIds.has(`page:${rowId}`)) finishReset() + if (resetIds.has(MAIN_SITE_DOC_ID) && resetIds.has(`page:main:${rowId}`)) finishReset() }) // siteDocument emits these synchronously after one committed save and // reports newly created rows in the changed/update group. - notifyShellWrite() - notifyRowWrite({ tableId: 'pages', rowIds: [rowId], kind: 'update' }) + notifyShellWrite('main') + notifyRowWrite({ branchId: 'main', tableId: 'pages', rowIds: [rowId], kind: 'update' }) await resetDone unsubscribe() @@ -741,7 +743,7 @@ describe('collab relay', () => { select deleted_at from data_rows where id = ${rowId} ` expect(rows[0]?.deleted_at).toBeNull() - const { doc: reseededSite } = await relay.openDoc(SITE_DOC_ID) + const { doc: reseededSite } = await relay.openDoc(MAIN_SITE_DOC_ID) const pages = rostersMap(reseededSite).get('pages') as Y.Map expect([...pages.keys()]).toContain(rowId) }) @@ -756,20 +758,20 @@ describe('collab relay', () => { const { rows: sourceRows } = await harness.db<{ cells_json: Record }>` select cells_json from data_rows where table_id = ${'pages'} ` - const { doc: siteDoc } = await relay.openDoc(SITE_DOC_ID) + const { doc: siteDoc } = await relay.openDoc(MAIN_SITE_DOC_ID) gated.arm() siteDoc.transact(() => { shellMap(siteDoc).set('name', 'Older collaborative shell') }, LOCAL_ORIGIN) await gated.blocked - notifyShellWrite() + notifyShellWrite('main') // Let reset A capture its own SITE-only batch and wait on the blocked // persist before the external creation emits reset B. await Bun.sleep(5) const rowId = 'later-reset-page' - await createDataRow(harness.db, { + await createDataRow(harness.db, MAIN_SCOPE, { id: rowId, tableId: 'pages', cells: sourceRows[0].cells_json, @@ -782,7 +784,7 @@ describe('collab relay', () => { select deleted_at from data_rows where id = ${rowId} ` expect(rows[0]?.deleted_at).toBeNull() - const { doc: reseededSite } = await relay.openDoc(SITE_DOC_ID) + const { doc: reseededSite } = await relay.openDoc(MAIN_SITE_DOC_ID) const pages = rostersMap(reseededSite).get('pages') as Y.Map expect([...pages.keys()]).toContain(rowId) }) @@ -799,12 +801,12 @@ describe('collab relay', () => { select id, slug, cells_json from data_rows where table_id = ${'pages'} ` const page = rows[0] - const pageDocId = `page:${page.id}` + const pageDocId = `page:main:${page.id}` const deleting = gateCollabBlobDelete(harness.db, pageDocId) const sweep = observeRosterSweep(deleting.db) const relay = createCollabRelay(sweep.db, { persistDebounceMs: 5 }) cleanups.push(() => relay.destroy()) - const { doc: siteDoc } = await relay.openDoc(SITE_DOC_ID) + const { doc: siteDoc } = await relay.openDoc(MAIN_SITE_DOC_ID) await relay.openDoc(pageDocId) const externalCells = structuredClone(page.cells_json) @@ -814,7 +816,7 @@ describe('collab relay', () => { } externalBody.nodes[externalBody.rootNodeId].label = 'Older external page write' deleting.arm() - await saveDataRowDraft(sweep.db, page.id, { + await saveDataRowDraft(sweep.db, MAIN_SCOPE, page.id, { cells: externalCells, slug: page.slug, }) @@ -854,8 +856,8 @@ describe('collab relay', () => { select id, slug, cells_json from data_rows where table_id = ${'pages'} ` const page = rows[0] - const pageDocId = `page:${page.id}` - await relay.openDoc(SITE_DOC_ID) + const pageDocId = `page:main:${page.id}` + await relay.openDoc(MAIN_SITE_DOC_ID) const { doc: pageDoc } = await relay.openDoc(pageDocId) editTitleUpdate(pageDoc, 'Older dirty collaborative page') @@ -864,13 +866,13 @@ describe('collab relay', () => { const resetsDone = new Promise((resolve) => { finishResets = resolve }) const unsubscribe = relay.onReset((docId) => { resetIds.add(docId) - if (resetIds.has(SITE_DOC_ID) && resetIds.has(pageDocId)) finishResets() + if (resetIds.has(MAIN_SITE_DOC_ID) && resetIds.has(pageDocId)) finishResets() }) - const externalShell = await getDraftSite(harness.db) + const externalShell = await getDraftSite(harness.db, MAIN_SCOPE) expect(externalShell).not.toBeNull() gated.arm() - await saveDraftSite(gated.db, { + await saveDraftSite(gated.db, MAIN_SCOPE, { ...externalShell!, name: 'Authoritative external shell', }) @@ -884,7 +886,7 @@ describe('collab relay', () => { rootNodeId: string } body.nodes[body.rootNodeId].label = 'Authoritative external page' - const pageWrite = saveDataRowDraft(gated.db, page.id, { + const pageWrite = saveDataRowDraft(gated.db, MAIN_SCOPE, page.id, { cells: externalCells, slug: page.slug, }) @@ -893,7 +895,7 @@ describe('collab relay', () => { await resetsDone unsubscribe() - expect((await getDraftSite(harness.db))?.name).toBe('Authoritative external shell') + expect((await getDraftSite(harness.db, MAIN_SCOPE))?.name).toBe('Authoritative external shell') const { rows: persisted } = await harness.db<{ cells_json: Record }>` select cells_json from data_rows where id = ${page.id} ` @@ -903,7 +905,7 @@ describe('collab relay', () => { } expect(persistedBody.nodes[persistedBody.rootNodeId]?.label) .toBe('Authoritative external page') - const { doc: reseededSite } = await relay.openDoc(SITE_DOC_ID) + const { doc: reseededSite } = await relay.openDoc(MAIN_SITE_DOC_ID) const { doc: reseededPage } = await relay.openDoc(pageDocId) expect(shellMap(reseededSite).get('name')).toBe('Authoritative external shell') expect(projectPageDoc(reseededPage, page.id).nodes[body.rootNodeId]?.label) @@ -914,7 +916,7 @@ describe('collab relay', () => { const harness = await createCapabilityTestHarness() cleanups.push(() => harness.cleanup()) await harness.setupOwner() - const customTable = await createDataTable(harness.db, { + const customTable = await createDataTable(harness.db, MAIN_SCOPE, { id: 'archive-table', name: 'Archive', slug: 'archive', @@ -929,7 +931,7 @@ describe('collab relay', () => { select id, cells_json from data_rows where table_id = ${'pages'} ` const original = rows[0] - const docId = `page:${original.id}` + const docId = `page:main:${original.id}` const relay = createCollabRelay(harness.db, { persistDebounceMs: 60_000 }) cleanups.push(() => relay.destroy()) const retained = await relay.retain(docId) @@ -937,7 +939,7 @@ describe('collab relay', () => { retained.doc.on('destroy', () => { oldDocDestroyed = true }) editTitleUpdate(retained.doc, 'Stale page edit must not undo the move') - const moved = await updateDataRowTable(harness.db, original.id, customTable.id) + const moved = await updateDataRowTable(harness.db, MAIN_SCOPE, original.id, customTable.id) expect(moved.ok).toBeTrue() await relay.flushAll() @@ -955,7 +957,7 @@ describe('collab relay', () => { expect(resetBlob?.generation).not.toBe(retained.generation) const rebound = await relay.openDoc(docId) expect(rebound.generation).not.toBe(retained.generation) - const { doc: reseededSite } = await relay.openDoc(SITE_DOC_ID) + const { doc: reseededSite } = await relay.openDoc(MAIN_SITE_DOC_ID) const pages = rostersMap(reseededSite).get('pages') as Y.Map expect([...pages.keys()]).not.toContain(original.id) relay.release(docId) @@ -965,7 +967,7 @@ describe('collab relay', () => { const harness = await createCapabilityTestHarness() cleanups.push(() => harness.cleanup()) const cookie = await harness.setupOwner() - const targetTable = await createDataTable(harness.db, { + const targetTable = await createDataTable(harness.db, MAIN_SCOPE, { id: 'import-target', name: 'Import targets', slug: 'import-targets', @@ -973,8 +975,8 @@ describe('collab relay', () => { singularLabel: 'Import target', pluralLabel: 'Import targets', }) - const [page] = await listDataRows(harness.db, 'pages') - const docId = `page:${page.id}` + const [page] = await listDataRows(harness.db, MAIN_SCOPE, 'pages') + const docId = `page:main:${page.id}` const relay = createCollabRelay(harness.db, { persistDebounceMs: 60_000 }) cleanups.push(() => relay.destroy()) const retained = await relay.retain(docId) @@ -1013,7 +1015,7 @@ describe('collab relay', () => { expect(oldDocDestroyed).toBeTrue() const rebound = await relay.openDoc(docId) expect(rebound.generation).not.toBe(oldGeneration) - const { doc: reseededSite } = await relay.openDoc(SITE_DOC_ID) + const { doc: reseededSite } = await relay.openDoc(MAIN_SITE_DOC_ID) const pages = rostersMap(reseededSite).get('pages') as Y.Map expect([...pages.keys()]).not.toContain(page.id) relay.release(docId) @@ -1023,14 +1025,14 @@ describe('collab relay', () => { const harness = await createCapabilityTestHarness() cleanups.push(() => harness.cleanup()) const cookie = await harness.setupOwner() - const [existingPage] = await listDataRows(harness.db, 'pages') - const storedShell = await getDraftSite(harness.db) + const [existingPage] = await listDataRows(harness.db, MAIN_SCOPE, 'pages') + const storedShell = await getDraftSite(harness.db, MAIN_SCOPE) expect(storedShell).not.toBeNull() const relay = createCollabRelay(harness.db, { persistDebounceMs: 60_000 }) cleanups.push(() => relay.destroy()) - const { doc: pageDoc } = await relay.openDoc(`page:${existingPage.id}`) - const { doc: siteDoc } = await relay.openDoc(SITE_DOC_ID) + const { doc: pageDoc } = await relay.openDoc(`page:main:${existingPage.id}`) + const { doc: siteDoc } = await relay.openDoc(MAIN_SITE_DOC_ID) editTitleUpdate(pageDoc, 'Dirty row survives skipped import') siteDoc.transact(() => { shellMap(siteDoc).set('name', 'Dirty shell survives skipped import') @@ -1059,17 +1061,17 @@ describe('collab relay', () => { rootNodeId: string } expect(body.nodes[body.rootNodeId]?.label).toBe('Dirty row survives skipped import') - expect((await getDraftSite(harness.db))?.name) + expect((await getDraftSite(harness.db, MAIN_SCOPE))?.name) .toBe('Dirty shell survives skipped import') }) it('sweeps roster deletions before a site reset flushes a same-slug replacement', async () => { const { harness, relay, homeId } = await setup() - const { doc: siteDoc } = await relay.openDoc(SITE_DOC_ID) - const { doc: oldPage } = await relay.openDoc(`page:${homeId}`) + const { doc: siteDoc } = await relay.openDoc(MAIN_SITE_DOC_ID) + const { doc: oldPage } = await relay.openDoc(`page:main:${homeId}`) editTitleUpdate(oldPage, 'Dirty page during site reset') const replacementId = 'reset-replacement' - const { doc: replacement } = await relay.openDoc(`page:${replacementId}`) + const { doc: replacement } = await relay.openDoc(`page:main:${replacementId}`) populateFreshPage(replacement, 'Reset replacement', 'index') siteDoc.transact(() => { const pages = rostersMap(siteDoc).get('pages') as Y.Map @@ -1080,7 +1082,7 @@ describe('collab relay', () => { // A settings save resets only site:default. Its stale shell must not be // written back, but its current roster still has to release the old slug // before non-reset row docs are flushed. - await relay.resetDocs([SITE_DOC_ID]) + await relay.resetDocs([MAIN_SITE_DOC_ID]) const { rows } = await harness.db<{ id: string; deleted_at: string | null }>` select id, deleted_at from data_rows @@ -1103,7 +1105,7 @@ describe('collab relay', () => { const { rows } = await harness.db<{ id: string }>` select id from data_rows where table_id = ${'pages'} ` - const docId = `page:${rows[0].id}` + const docId = `page:main:${rows[0].id}` const { doc } = await relay.openDoc(docId) // The generation-mint write has landed; arm the gate so the DEBOUNCED @@ -1132,7 +1134,7 @@ describe('collab relay', () => { const { rows } = await harness.db<{ id: string }>` select id from data_rows where table_id = ${'pages'} ` - const docId = `page:${rows[0].id}` + const docId = `page:main:${rows[0].id}` const retained = await relay.retain(docId) gated.arm() @@ -1164,9 +1166,9 @@ describe('collab relay', () => { const { rows } = await harness.db<{ id: string }>` select id from data_rows where table_id = ${'pages'} ` - const docId = `page:${rows[0].id}` + const docId = `page:main:${rows[0].id}` const retained = await relay.retain(docId) - const { doc: siteDoc } = await relay.openDoc(SITE_DOC_ID) + const { doc: siteDoc } = await relay.openDoc(MAIN_SITE_DOC_ID) let originalDestroyed = false retained.doc.on('destroy', () => { originalDestroyed = true }) @@ -1179,7 +1181,7 @@ describe('collab relay', () => { // resetDocs installs both reset gates immediately, then waits for the // blocked SITE persist before it snapshots heldResetRefs. Releasing here // must decrement the live entry without starting a competing eviction. - const reset = relay.resetDocs([SITE_DOC_ID, docId]) + const reset = relay.resetDocs([MAIN_SITE_DOC_ID, docId]) relay.release(docId) gated.release() await reset @@ -1206,7 +1208,7 @@ describe('collab relay', () => { select id, slug, cells_json from data_rows where table_id = ${'pages'} ` const row = rows[0] - const docId = `page:${row.id}` + const docId = `page:main:${row.id}` const seedRelay = createCollabRelay(harness.db, { persistDebounceMs: 5 }) const { doc: seededDoc } = await seedRelay.openDoc(docId) @@ -1233,7 +1235,7 @@ describe('collab relay', () => { resolve() }) }) - await saveDataRowDraft(harness.db, row.id, { + await saveDataRowDraft(harness.db, MAIN_SCOPE, row.id, { cells: externalCells, slug: row.slug, }) @@ -1256,7 +1258,7 @@ describe('collab relay', () => { const { rows } = await harness.db<{ id: string }>` select id from data_rows where table_id = ${'pages'} ` - const docId = `page:${rows[0].id}` + const docId = `page:main:${rows[0].id}` const seedRelay = createCollabRelay(harness.db, { persistDebounceMs: 5 }) const seeded = await seedRelay.openDoc(docId) @@ -1310,7 +1312,7 @@ describe('collab relay', () => { select id, slug, cells_json from data_rows where table_id = ${'pages'} ` const row = rows[0] - const docId = `page:${row.id}` + const docId = `page:main:${row.id}` const { doc } = await relay.openDoc(docId) gated.arm() @@ -1330,7 +1332,7 @@ describe('collab relay', () => { resolve() }) }) - await saveDataRowDraft(harness.db, row.id, { + await saveDataRowDraft(harness.db, MAIN_SCOPE, row.id, { cells: externalCells, slug: row.slug, }) @@ -1360,7 +1362,7 @@ describe('collab relay', () => { select id, slug, cells_json from data_rows where table_id = ${'pages'} ` const row = rows[0] - const docId = `page:${row.id}` + const docId = `page:main:${row.id}` const gated = gateDerivedRowWrites(harness.db, row.id) const relay = createCollabRelay(gated.db, { persistDebounceMs: 5 }) cleanups.push(() => relay.destroy()) @@ -1385,7 +1387,7 @@ describe('collab relay', () => { resolve() }) }) - const externalSave = saveDataRowDraft(harness.db, row.id, { + const externalSave = saveDataRowDraft(harness.db, MAIN_SCOPE, row.id, { cells: externalCells, slug: row.slug, }).then(() => { externalFinished = true }) @@ -1421,9 +1423,9 @@ describe('collab relay', () => { select id, slug, cells_json from data_rows where table_id = ${'pages'} ` const row = rows[0] - const docId = `page:${row.id}` + const docId = `page:main:${row.id}` const { doc: pageDoc } = await relay.openDoc(docId) - const { doc: siteDoc } = await relay.openDoc(SITE_DOC_ID) + const { doc: siteDoc } = await relay.openDoc(MAIN_SITE_DOC_ID) const externalCells = structuredClone(row.cells_json) const externalBody = externalCells.body as { @@ -1433,6 +1435,7 @@ describe('collab relay', () => { externalBody.nodes[externalBody.rootNodeId].label = 'External reset is authoritative' await saveDataRowDraft( harness.db, + MAIN_SCOPE, row.id, { cells: externalCells, slug: row.slug }, null, @@ -1447,7 +1450,7 @@ describe('collab relay', () => { shellMap(siteDoc).set('name', 'Stale collaborative shell') }, LOCAL_ORIGIN) await gated.blocked - const reset = relay.resetDocs([SITE_DOC_ID, docId]) + const reset = relay.resetDocs([MAIN_SITE_DOC_ID, docId]) editTitleUpdate(pageDoc, 'Late stale collaborative edit') await Bun.sleep(30) gated.release() @@ -1481,7 +1484,7 @@ describe('collab relay', () => { select id, slug, cells_json from data_rows where table_id = ${'pages'} ` const row = rows[0] - const docId = `page:${row.id}` + const docId = `page:main:${row.id}` await relay.retain(docId) await relay.flushAll() @@ -1492,7 +1495,7 @@ describe('collab relay', () => { } externalBody.nodes[externalBody.rootNodeId].label = 'Survives reset retry' failing.arm() - await saveDataRowDraft(harness.db, row.id, { + await saveDataRowDraft(harness.db, MAIN_SCOPE, row.id, { cells: externalCells, slug: row.slug, }) @@ -1540,8 +1543,8 @@ describe('collab relay', () => { it('a relay-only page survives a site-doc reset instead of vanishing from the roster', async () => { const { harness, relay } = await setup() const rowId = 'relay-only-page' - const { doc: pageDoc } = await relay.openDoc(`page:${rowId}`) - await relay.openDoc(SITE_DOC_ID) + const { doc: pageDoc } = await relay.openDoc(`page:main:${rowId}`) + await relay.openDoc(MAIN_SITE_DOC_ID) pageDoc.transact(() => { const tree = treeMap(pageDoc) @@ -1563,14 +1566,14 @@ describe('collab relay', () => { // Reset the SITE doc while the new page exists ONLY in the relay. Its // derived JSON must be flushed first, or the reseed — which builds the // roster from listDataRowIdSlugs — cannot see the row at all. - await relay.resetDocs([SITE_DOC_ID]) + await relay.resetDocs([MAIN_SITE_DOC_ID]) const { rows } = await harness.db<{ id: string }>` select id from data_rows where id = ${rowId} and deleted_at is null ` expect(rows).toHaveLength(1) - const { doc: reseeded } = await relay.openDoc(SITE_DOC_ID) + const { doc: reseeded } = await relay.openDoc(MAIN_SITE_DOC_ID) const pages = rostersMap(reseeded).get('pages') as Y.Map expect([...pages.keys()]).toContain(rowId) }) diff --git a/src/__tests__/server/collabRelayBranches.test.ts b/src/__tests__/server/collabRelayBranches.test.ts new file mode 100644 index 000000000..b1f53a06c --- /dev/null +++ b/src/__tests__/server/collabRelayBranches.test.ts @@ -0,0 +1,272 @@ +/** + * The relay and deleted branches — a forgotten branch refuses its docs even + * while its registry row still exists, a re-created id is welcome again, and + * resets queued for a deleted branch never poison the queue. + */ +import { afterEach, describe, expect, it, spyOn } from 'bun:test' +import { createCollabRelay, type CollabRelay } from '../../../server/collab/relay' +import { BranchGoneError } from '../../../server/collab/relayBranches' +import { deleteBranch } from '../../../server/branches/deleteBranch' +import { forkBranch } from '../../../server/branches/fork' +import { branchExists } from '../../../server/repositories/branches' +import { getCollabDocumentState } from '../../../server/repositories/collabDocuments' +import type { DbClient } from '../../../server/db/client' +import { notifyRowWrite } from '../../../server/repositories/rowWriteEvents' +import { listDataRows, upsertDataRowDraft } from '../../../server/repositories/data' +import { MAIN_SCOPE } from '../../../server/branches/scope' +import { LOCAL_ORIGIN } from '@core/collab' +import { createCapabilityTestHarness, type CapabilityTestHarness } from '../helpers/capabilityHarness' + +describe('collab relay and branches', () => { + let harness: CapabilityTestHarness | null = null + let relay: CollabRelay | null = null + + afterEach(async () => { + // A deadlock regression surfaces here as the hook timing out. + await relay?.destroy() + relay = null + await harness?.cleanup() + harness = null + }) + + it('opens a forked branch, refuses it once forgotten, and accepts it again when re-created', async () => { + harness = await createCapabilityTestHarness() + await harness.setupOwner() + await forkBranch(harness.db, { id: 'feature', name: 'Feature', fromBranchId: 'main', createdByUserId: null }) + relay = createCollabRelay(harness.db, { persistDebounceMs: 5 }) + + await expect(relay.openDoc('site:feature')).resolves.toBeTruthy() + await expect(relay.openDoc('site:nope')).rejects.toBeInstanceOf(BranchGoneError) + + // Forgotten BEFORE the registry row goes — the tombstone alone refuses. + await relay.forgetBranch('feature') + await expect(relay.openDoc('site:feature')).rejects.toBeInstanceOf(BranchGoneError) + const [home] = await listDataRows(harness.db, MAIN_SCOPE, 'pages') + await expect(relay.openDoc(`page:feature:${home!.id}`)).rejects.toBeInstanceOf(BranchGoneError) + + await relay.rememberBranch('feature') + await expect(relay.openDoc('site:feature')).resolves.toBeTruthy() + // … and its out-of-relay writes reset its docs again, so an editor never + // persists a stale doc over rows written around the relay. + const featureHome = `page:feature:${home!.id}` + await relay.openDoc(featureHome) + const resets: string[] = [] + relay.onReset((docId) => resets.push(docId)) + notifyRowWrite({ branchId: 'feature', tableId: 'pages', rowIds: [home!.id], kind: 'update' }) + await relay.flushAll() + expect(resets).toContain(featureHome) + }) + + it('drops queued resets for a forgotten branch instead of failing every later flush', async () => { + harness = await createCapabilityTestHarness() + await harness.setupOwner() + await forkBranch(harness.db, { id: 'doomed', name: 'Doomed', fromBranchId: 'main', createdByUserId: null }) + relay = createCollabRelay(harness.db, { persistDebounceMs: 5 }) + const [home] = await listDataRows(harness.db, MAIN_SCOPE, 'pages') + await relay.openDoc(`page:doomed:${home!.id}`) + + // An out-of-relay write queues a reset for the branch's docs … + notifyRowWrite({ branchId: 'doomed', tableId: 'pages', rowIds: [home!.id], kind: 'update' }) + // … and the branch is deleted before that reset runs. + await relay.forgetBranch('doomed') + await expect(relay.flushAll()).resolves.toBeUndefined() + await expect(relay.flushAll()).resolves.toBeUndefined() + await expect(relay.openDoc(`page:doomed:${home!.id}`)).rejects.toBeInstanceOf(BranchGoneError) + // Main is unaffected. + await expect(relay.openDoc(`page:main:${home!.id}`)).resolves.toBeTruthy() + }) + + it('accepts a branch again when its delete fails after the tombstone', async () => { + harness = await createCapabilityTestHarness() + await harness.setupOwner() + await forkBranch(harness.db, { id: 'sticky', name: 'Sticky', fromBranchId: 'main', createdByUserId: null }) + relay = createCollabRelay(harness.db, { persistDebounceMs: 5 }) + await relay.openDoc('site:sticky') + + // The rows survive a failed delete transaction, so the relay must admit + // the branch again instead of refusing it until the id is re-forked. + const failing = new Proxy(harness.db, { + get(target, prop, receiver) { + if (prop === 'transaction') return () => Promise.reject(new Error('disk full')) + const value: unknown = Reflect.get(target, prop, receiver) + return typeof value === 'function' ? value.bind(target) : value + }, + }) satisfies DbClient + await expect(deleteBranch(failing, 'sticky', relay)).rejects.toThrow('disk full') + expect(await branchExists(harness.db, 'sticky')).toBe(true) + await expect(relay.openDoc('site:sticky')).resolves.toBeDefined() + // Out-of-relay writes on the surviving branch reset its docs again. + const [home] = await listDataRows(harness.db, MAIN_SCOPE, 'pages') + const stickyHome = `page:sticky:${home!.id}` + await relay.openDoc(stickyHome) + const resets: string[] = [] + relay.onReset((docId) => resets.push(docId)) + notifyRowWrite({ branchId: 'sticky', tableId: 'pages', rowIds: [home!.id], kind: 'update' }) + await relay.flushAll() + expect(resets).toContain(stickyHome) + }) + + // A reset dropped for a tombstoned branch — whether it was queued before the + // tombstone or arrived after it — must not leave an invalidation marker + // behind: once the branch is admitted again, every persist would report + // "superseded" and the editor's edits would never reach the row JSON. + for (const timing of ['before', 'after'] as const) { + it(`persists edits after a branch comes back when a reset was dropped ${timing} the tombstone`, async () => { + harness = await createCapabilityTestHarness() + await harness.setupOwner() + const branchId = `back-${timing}` + await forkBranch(harness.db, { id: branchId, name: branchId, fromBranchId: 'main', createdByUserId: null }) + relay = createCollabRelay(harness.db, { persistDebounceMs: 5 }) + const [home] = await listDataRows(harness.db, MAIN_SCOPE, 'pages') + const docId = `page:${branchId}:${home!.id}` + await relay.openDoc(docId) + + if (timing === 'before') notifyRowWrite({ branchId, tableId: 'pages', rowIds: [home!.id], kind: 'update' }) + await relay.forgetBranch(branchId) + if (timing === 'after') notifyRowWrite({ branchId, tableId: 'pages', rowIds: [home!.id], kind: 'update' }) + await relay.flushAll() + + await relay.rememberBranch(branchId) + const { doc } = await relay.retain(docId) + doc.transact(() => { + doc.getMap('meta').set('title', `Edited on ${branchId}`) + }, LOCAL_ORIGIN) + await relay.flushAll() + relay.release(docId) + + const { rows } = await harness.db<{ cells_json: unknown }>` + select cells_json from data_rows where id = ${`${branchId}:${home!.id}`} + ` + expect(JSON.stringify(rows[0]?.cells_json ?? null)).toContain(`Edited on ${branchId}`) + }) + } + + it('reseeds a branch from its rows when it comes back, so a dropped reset is not lost', async () => { + harness = await createCapabilityTestHarness() + await harness.setupOwner() + await forkBranch(harness.db, { id: 'revived', name: 'Revived', fromBranchId: 'main', createdByUserId: null }) + relay = createCollabRelay(harness.db, { persistDebounceMs: 5 }) + const [home] = await listDataRows(harness.db, MAIN_SCOPE, 'pages') + const docId = `page:revived:${home!.id}` + await relay.openDoc(docId) + await relay.flushAll() + + // An out-of-relay write lands on the branch and announces itself, but + // the delete tombstones the branch in the same tick: its reset is dropped + // with the blob (older than the row) still stored. Then the delete fails. + await upsertDataRowDraft(harness.db, { branchId: 'revived' }, { + id: home!.id, + tableId: 'pages', + cells: { ...home!.cells, title: 'Written around the relay' }, + slug: home!.slug, + }, null, { collabInternal: true }) + notifyRowWrite({ branchId: 'revived', tableId: 'pages', rowIds: [home!.id], kind: 'update' }) + await relay.forgetBranch('revived') + expect(await getCollabDocumentState(harness.db, docId)).not.toBeNull() + const failing = new Proxy(harness.db, { + get(target, prop, receiver) { + if (prop === 'transaction') return () => Promise.reject(new Error('disk full')) + const value: unknown = Reflect.get(target, prop, receiver) + return typeof value === 'function' ? value.bind(target) : value + }, + }) satisfies DbClient + await expect(deleteBranch(failing, 'revived', relay)).rejects.toThrow('disk full') + + // The reopened doc carries the row's title, not the stale blob's … + const { doc } = await relay.retain(docId) + expect(doc.getMap('meta').get('title')).toBe('Written around the relay') + // … and the next persist keeps building on it. + doc.transact(() => { + doc.getMap('meta').set('title', 'Edited after the revival') + }, LOCAL_ORIGIN) + await relay.flushAll() + relay.release(docId) + const { rows } = await harness.db<{ cells_json: unknown }>` + select cells_json from data_rows where id = ${`revived:${home!.id}`} + ` + expect(JSON.stringify(rows[0]?.cells_json ?? null)).toContain('Edited after the revival') + }) + + + it('does not deadlock a reset of a page and its site doc with an open of that page in flight', async () => { + harness = await createCapabilityTestHarness() + await harness.setupOwner() + await forkBranch(harness.db, { id: 'race', name: 'Race', fromBranchId: 'main', createdByUserId: null }) + relay = createCollabRelay(harness.db, { persistDebounceMs: 5 }) + const [home] = await listDataRows(harness.db, MAIN_SCOPE, 'pages') + const docId = `page:race:${home!.id}` + + // No roster snapshot yet, so this open will reach for the site doc — and + // the write's reset covers both the page and that site doc. The reset + // drains the open; the open must not wait on the reset's site gate. + const opening = relay.openDoc(docId) + notifyRowWrite({ branchId: 'race', tableId: 'pages', rowIds: [home!.id], kind: 'update' }) + await expect(opening).resolves.toBeTruthy() + await relay.flushAll() + await expect(relay.openDoc(docId)).resolves.toBeTruthy() + }) + + it('keeps a branch refused while its revival fails, and admits it once the retry succeeds', async () => { + harness = await createCapabilityTestHarness() + await harness.setupOwner() + await forkBranch(harness.db, { id: 'flaky', name: 'Flaky', fromBranchId: 'main', createdByUserId: null }) + // The relay's own db fails every collab_documents statement while `outage` is set. + let outage = false + const db = new Proxy(harness.db, { + apply(target, thisArg, args: unknown[]) { + const strings = args[0] + if (outage && Array.isArray(strings) && strings.join('?').includes('collab_documents')) { + return Promise.reject(new Error('database is down')) + } + return Reflect.apply(target, thisArg, args) + }, + get(target, prop, receiver) { + const value: unknown = Reflect.get(target, prop, receiver) + if (prop === 'unsafe' && typeof value === 'function') { + return (sql: string, ...rest: unknown[]) => outage && sql.includes('collab_documents') + ? Promise.reject(new Error('database is down')) + : (value as (...a: unknown[]) => unknown).call(target, sql, ...rest) + } + return typeof value === 'function' ? value.bind(target) : value + }, + }) satisfies DbClient + relay = createCollabRelay(db, { persistDebounceMs: 5 }) + await relay.openDoc('site:flaky') + await relay.forgetBranch('flaky') + + const errorLog = spyOn(console, 'error').mockImplementation(() => undefined) + try { + outage = true + await relay.rememberBranch('flaky') + // Still refused: the purge could not run, so the stale blob may be there. + await expect(relay.openDoc('site:flaky')).rejects.toBeInstanceOf(BranchGoneError) + outage = false + // The next admission retries the purge and lifts the tombstone. + await expect(relay.openDoc('site:flaky')).resolves.toBeTruthy() + } finally { + errorLog.mockRestore() + } + }) + + it('keeps the ref counts of sockets still bound to a forgotten branch when it comes back', async () => { + harness = await createCapabilityTestHarness() + await harness.setupOwner() + await forkBranch(harness.db, { id: 'held', name: 'Held', fromBranchId: 'main', createdByUserId: null }) + relay = createCollabRelay(harness.db, { persistDebounceMs: 5 }) + const [home] = await listDataRows(harness.db, MAIN_SCOPE, 'pages') + const docId = `page:held:${home!.id}` + + // Socket A holds the doc when the branch is forgotten (delete attempt) … + await relay.retain(docId) + await relay.forgetBranch('held') + await relay.rememberBranch('held') + // … socket B binds after the revival, then A's stale close arrives. + const bound = await relay.retain(docId) + relay.release(docId) + // B's doc must survive A's release: the same live instance comes back. + const again = await relay.retain(docId) + expect(again.doc).toBe(bound.doc) + relay.release(docId) + relay.release(docId) + }) +}) diff --git a/src/__tests__/server/collabRelayIntegration.test.ts b/src/__tests__/server/collabRelayIntegration.test.ts index b01e750e0..6fccacd10 100644 --- a/src/__tests__/server/collabRelayIntegration.test.ts +++ b/src/__tests__/server/collabRelayIntegration.test.ts @@ -168,7 +168,7 @@ function insertChildNode(doc: Y.Doc, nodeId: string, moduleId: string): void { describe('collab relay integration (real server, real sockets)', () => { it('two clients edit concurrently, converge, and the relay persists blob + derived JSON', async () => { const stack = await startStack() - const docId = `page:${stack.homeId}` + const docId = `page:main:${stack.homeId}` const clientA = connectClient(stack) const clientB = connectClient(stack) @@ -200,7 +200,7 @@ describe('collab relay integration (real server, real sockets)', () => { return nodeLabel(restored, rootId) === 'Renamed by A' }) await waitFor(async () => { - const row = await getDataRow(stack.harness.db, stack.homeId) + const row = await getDataRow(stack.harness.db, MAIN_SCOPE, stack.homeId) if (!row) return false const page = pageFromRow(row) return page.nodes[rootId]?.label === 'Renamed by A' && page.nodes['node-from-b'] !== undefined @@ -209,7 +209,7 @@ describe('collab relay integration (real server, real sockets)', () => { it('refuses a read-only edit AND resets the viewer so its own screen reverts', async () => { const stack = await startStack() - const docId = `page:${stack.homeId}` + const docId = `page:main:${stack.homeId}` const viewer = await stack.harness.createRoleUser({ name: 'Read Only', @@ -259,7 +259,7 @@ describe('collab relay integration (real server, real sockets)', () => { it('enforces per-category capabilities on partial writers and relays read-only presence', async () => { const stack = await startStack() - const docId = `page:${stack.homeId}` + const docId = `page:main:${stack.homeId}` const contentUser = await stack.harness.createRoleUser({ name: 'Copy Editor', @@ -342,7 +342,7 @@ describe('collab relay integration (real server, real sockets)', () => { it('resets a doc when the row is written outside the relay', async () => { const stack = await startStack() - const docId = `page:${stack.homeId}` + const docId = `page:main:${stack.homeId}` const client = connectClient(stack) const bound = client.bind(docId) @@ -354,8 +354,8 @@ describe('collab relay integration (real server, real sockets)', () => { // Out-of-relay write (imports, plugins, HTTP save): mutate the stored // JSON directly — the repository notifies, the relay drops the doc and // broadcasts FRAME_RESET. - const row = await getDataRow(stack.harness.db, stack.homeId) - await saveDataRowDraft(stack.harness.db, stack.homeId, { + const row = await getDataRow(stack.harness.db, MAIN_SCOPE, stack.homeId) + await saveDataRowDraft(stack.harness.db, MAIN_SCOPE, stack.homeId, { cells: { ...row!.cells, title: 'Rewritten outside the relay' }, slug: row!.slug, }) @@ -371,7 +371,7 @@ describe('collab relay integration (real server, real sockets)', () => { it('a reconnecting client catches up on edits it missed while offline', async () => { const stack = await startStack() - const docId = `page:${stack.homeId}` + const docId = `page:main:${stack.homeId}` const clientA = connectClient(stack) const clientB = connectClient(stack) @@ -392,7 +392,7 @@ describe('collab relay integration (real server, real sockets)', () => { it('a peer cannot erase another peer\'s presence for everyone', async () => { const stack = await startStack() - const docId = `page:${stack.homeId}` + const docId = `page:main:${stack.homeId}` const identityOf = async (email: string) => { const user = (await findUserByEmail(stack.harness.db, email))! @@ -504,7 +504,7 @@ describe('collab relay integration (real server, real sockets)', () => { } const client = connectClient(stack) - const bound = client.bind(`page:${homeId}`) + const bound = client.bind(`page:main:${homeId}`) await bound.whenSynced const rootId = treeMap(bound.doc).get('rootNodeId') as string @@ -512,14 +512,14 @@ describe('collab relay integration (real server, real sockets)', () => { // edit reaches this peer, the relay has definitely applied it. Asserting on // the editing client's own doc would pass before the frame ever left it. const observer = connectClient(stack) - const boundObserver = observer.bind(`page:${homeId}`) + const boundObserver = observer.bind(`page:main:${homeId}`) await boundObserver.whenSynced setNodeLabel(bound.doc, rootId, 'Edited seconds before publish') await waitFor(() => nodeLabel(boundObserver.doc, rootId) === 'Edited seconds before publish') const labelInRow = async (): Promise => { - const row = await getDataRow(harness.db, homeId) + const row = await getDataRow(harness.db, MAIN_SCOPE, homeId) return pageFromRow(row!).nodes[rootId]?.label } @@ -537,7 +537,7 @@ describe('collab relay integration (real server, real sockets)', () => { it('refuses a stale lineage instead of merging a dead generation', async () => { const stack = await startStack() - const docId = `page:${stack.homeId}` + const docId = `page:main:${stack.homeId}` const client = connectClient(stack) const bound = client.bind(docId) @@ -545,7 +545,7 @@ describe('collab relay integration (real server, real sockets)', () => { const rootId = treeMap(bound.doc).get('rootNodeId') as string setNodeLabel(bound.doc, rootId, 'Before the reset') await waitFor(async () => { - const row = await getDataRow(stack.harness.db, stack.homeId) + const row = await getDataRow(stack.harness.db, MAIN_SCOPE, stack.homeId) return Boolean(row && pageFromRow(row).nodes[rootId]?.label === 'Before the reset') }) @@ -570,7 +570,7 @@ describe('collab relay integration (real server, real sockets)', () => { it('a frame stamped with a dead generation is answered with a reset, not applied', async () => { const stack = await startStack() - const docId = `page:${stack.homeId}` + const docId = `page:main:${stack.homeId}` const client = connectClient(stack) const bound = client.bind(docId) await bound.whenSynced @@ -622,7 +622,7 @@ describe('collab relay integration (real server, real sockets)', () => { // direction that was silently dropping work. it('recovers edits authored while the socket was down, on reconnect', async () => { const stack = await startStack() - const docId = `page:${stack.homeId}` + const docId = `page:main:${stack.homeId}` const client = connectClient(stack) const bound = client.bind(docId) @@ -649,8 +649,10 @@ describe('collab relay integration (real server, real sockets)', () => { // And it reaches storage, not just memory. await waitFor(async () => { - const row = await getDataRow(stack.harness.db, stack.homeId) + const row = await getDataRow(stack.harness.db, MAIN_SCOPE, stack.homeId) return Boolean(row && pageFromRow(row).nodes[rootId]?.label === 'Written while offline') }) }) }) + +import { MAIN_SCOPE } from '../../../server/branches/scope' \ No newline at end of file diff --git a/src/__tests__/server/collabUpdateGuard.test.ts b/src/__tests__/server/collabUpdateGuard.test.ts index 8c35ae885..4e9e86a9c 100644 --- a/src/__tests__/server/collabUpdateGuard.test.ts +++ b/src/__tests__/server/collabUpdateGuard.test.ts @@ -9,7 +9,7 @@ import { seedPageDoc, seedSiteDoc, shellMap, - SITE_DOC_ID, + MAIN_SITE_DOC_ID, treeMap, } from '@core/collab' import type { CoreCapability } from '@core/capabilities' @@ -75,7 +75,7 @@ describe('collab update capability guard', () => { }) const doc = new Y.Doc() seedPageDoc(doc, page) - const docId = 'page:page-1' + const docId = 'page:main:page-1' const contentUpdate = updateFrom(doc, (fork) => { const props = nodeMap(fork, 'copy').get('props') as Y.Map @@ -107,8 +107,8 @@ describe('collab update capability guard', () => { const settings = shellMap(fork).get('settings') as Y.Map settings.set('metaTitle', 'Collaborative title') }) - expectAllowed(SITE_DOC_ID, doc, contentUpdate, CONTENT) - expectForbidden(SITE_DOC_ID, doc, contentUpdate, STYLE, 'forbidden content change') + expectAllowed(MAIN_SITE_DOC_ID, doc, contentUpdate, CONTENT) + expectForbidden(MAIN_SITE_DOC_ID, doc, contentUpdate, STYLE, 'forbidden content change') const styleUpdate = updateFrom(doc, (fork) => { const styleRules = shellMap(fork).get('styleRules') as Y.Map @@ -118,21 +118,21 @@ describe('collab update capability guard', () => { styles: { color: 'var(--foreground)' }, }) }) - expectAllowed(SITE_DOC_ID, doc, styleUpdate, STYLE) - expectForbidden(SITE_DOC_ID, doc, styleUpdate, CONTENT, 'forbidden style change') + expectAllowed(MAIN_SITE_DOC_ID, doc, styleUpdate, STYLE) + expectForbidden(MAIN_SITE_DOC_ID, doc, styleUpdate, CONTENT, 'forbidden style change') const structureUpdate = updateFrom(doc, (fork) => { shellMap(fork).set('name', 'Renamed site') }) - expectAllowed(SITE_DOC_ID, doc, structureUpdate, STRUCTURE) - expectForbidden(SITE_DOC_ID, doc, structureUpdate, CONTENT, 'forbidden structure change') + expectAllowed(MAIN_SITE_DOC_ID, doc, structureUpdate, STRUCTURE) + expectForbidden(MAIN_SITE_DOC_ID, doc, structureUpdate, CONTENT, 'forbidden structure change') const rosterUpdate = updateFrom(doc, (fork) => { const pages = rostersMap(fork).get('pages') as Y.Map pages.set('page-2', true) }) - expectAllowed(SITE_DOC_ID, doc, rosterUpdate, STRUCTURE) - expectForbidden(SITE_DOC_ID, doc, rosterUpdate, CONTENT, 'roster changes') + expectAllowed(MAIN_SITE_DOC_ID, doc, rosterUpdate, STRUCTURE) + expectForbidden(MAIN_SITE_DOC_ID, doc, rosterUpdate, CONTENT, 'roster changes') }) it('requires structure capability for component and layout documents', () => { @@ -141,9 +141,9 @@ describe('collab update capability guard', () => { const componentUpdate = updateFrom(componentDoc, (fork) => { metaMap(fork).set('name', 'Renamed hero') }) - expectAllowed('component:component-1', componentDoc, componentUpdate, STRUCTURE) + expectAllowed('component:main:component-1', componentDoc, componentUpdate, STRUCTURE) expectForbidden( - 'component:component-1', + 'component:main:component-1', componentDoc, componentUpdate, CONTENT, @@ -168,9 +168,9 @@ describe('collab update capability guard', () => { classes: { 'hero-layout': { id: 'hero-layout', name: 'Hero layout', styles: {} } }, }) }) - expectAllowed('layout:layout-1', layoutDoc, layoutUpdate, STRUCTURE) + expectAllowed('layout:main:layout-1', layoutDoc, layoutUpdate, STRUCTURE) expectForbidden( - 'layout:layout-1', + 'layout:main:layout-1', layoutDoc, layoutUpdate, STYLE, diff --git a/src/__tests__/server/dataCms.test.ts b/src/__tests__/server/dataCms.test.ts index 830f7f82d..affd2eb03 100644 --- a/src/__tests__/server/dataCms.test.ts +++ b/src/__tests__/server/dataCms.test.ts @@ -15,6 +15,7 @@ import { import { handleServerRequest } from '../../../server/router' import { resetForTests } from '../../../server/publish/renderCache' import { createFakeDb } from './dbTestFake' +import { MAIN_SCOPE } from '../../../server/branches/scope' type QueryHandler = (sql: string, params: unknown[]) => DbResult | undefined @@ -59,10 +60,11 @@ describe('data CMS repository', () => { it('lists data tables with frontend field names', async () => { const db = makeDataFakeDb([ (sql) => { - if (!sql.startsWith('select id, name, slug, kind, route_base')) return undefined + if (!sql.startsWith('select logical_id, name, slug, kind, route_base')) return undefined return { rows: [{ id: 'posts', + logical_id: 'posts', name: 'Posts', slug: 'posts', kind: 'postType', @@ -81,7 +83,7 @@ describe('data CMS repository', () => { }, ]) - await expect(listDataTables(db)).resolves.toEqual([{ + await expect(listDataTables(db, MAIN_SCOPE)).resolves.toEqual([{ id: 'posts', name: 'Posts', slug: 'posts', @@ -103,12 +105,14 @@ describe('data CMS repository', () => { const db = makeDataFakeDb([ (sql, params) => { if (!sql.startsWith('insert into data_tables')) return undefined - expect(String(params[0])).toBeTruthy() // id (nanoid) - expect(params[1]).toBe('Products') - expect(params[2]).toBe('products') + expect(String(params[0])).toBeTruthy() // physical id (nanoid on main) + expect(params[1]).toBe('main') + expect(params[2]).toBe('Products') + expect(params[3]).toBe('products') return { rows: [{ id: 'products', + logical_id: 'products', name: 'Products', slug: 'products', kind: 'postType', @@ -133,7 +137,7 @@ describe('data CMS repository', () => { }, ]) - const table = await createDataTable(db, { + const table = await createDataTable(db, MAIN_SCOPE, { name: 'Products', slug: 'products', kind: 'postType', @@ -157,10 +161,11 @@ describe('data CMS repository', () => { // updateDataTable reads the table first, so a patch cannot drop the // mandatory `title`/`slug` of a post type by omitting them. (sql) => { - if (!sql.startsWith('select id, name, slug, kind')) return undefined + if (!sql.startsWith('select logical_id, name, slug, kind')) return undefined return { rows: [{ id: 'products', + logical_id: 'products', name: 'Products', slug: 'products', kind: 'postType', @@ -184,6 +189,7 @@ describe('data CMS repository', () => { return { rows: [{ id: 'products', + logical_id: 'products', name: 'Catalog', slug: 'catalog', kind: 'postType', @@ -202,7 +208,7 @@ describe('data CMS repository', () => { }, ]) - await expect(updateDataTable(db, 'products', { + await expect(updateDataTable(db, MAIN_SCOPE, 'products', { name: 'Catalog', slug: 'catalog', routeBase: '/catalog', diff --git a/src/__tests__/server/dataMetaRoute.test.ts b/src/__tests__/server/dataMetaRoute.test.ts index 28898a6a5..f20749770 100644 --- a/src/__tests__/server/dataMetaRoute.test.ts +++ b/src/__tests__/server/dataMetaRoute.test.ts @@ -17,6 +17,7 @@ import { createFakeDb } from './dbTestFake' const fakeDataTableRow = { id: 'posts', + logical_id: 'posts', name: 'Posts', slug: 'posts', kind: 'postType', @@ -91,7 +92,7 @@ function makeAuthDb(sessionIdHash: string) { } // listDataTables - if (normalized.startsWith('select id, name, slug, kind, route_base')) { + if (normalized.startsWith('select logical_id, name, slug, kind, route_base')) { return { rows: [fakeDataTableRow], rowCount: 1 } } diff --git a/src/__tests__/server/importEndpointGuidance.test.ts b/src/__tests__/server/importEndpointGuidance.test.ts index 7c383aa7f..0468726f8 100644 --- a/src/__tests__/server/importEndpointGuidance.test.ts +++ b/src/__tests__/server/importEndpointGuidance.test.ts @@ -17,6 +17,7 @@ import { createSqliteClient } from '../../../server/db/sqlite' import { runMigrations } from '../../../server/db/runMigrations' import { sqliteMigrations } from '../../../server/db/migrations-sqlite' import type { DbClient } from '../../../server/db/client' +import { MAIN_SCOPE } from '../../../server/branches/scope' import { handleImportRoute } from '../../../server/handlers/cms/import' async function setupDb(): Promise<{ db: DbClient; cleanup: () => Promise }> { @@ -49,7 +50,7 @@ describe('POST /admin/api/cms/import with a ZIP body', () => { it('points the caller at the archive endpoint instead of blaming the schema', async () => { const { db, cleanup } = await setupDb() try { - const res = await handleImportRoute(importRequest(zipBytes()), db) + const res = await handleImportRoute(importRequest(zipBytes()), db, MAIN_SCOPE) // Unauthenticated callers are rejected before body parsing; the guidance // only has to hold once the request reaches validation. if (!res || res.status === 401 || res.status === 403) return @@ -71,6 +72,7 @@ describe('ZIP detection', () => { const res = await handleImportRoute( importRequest(JSON.stringify({ schemaVersion: 1, exportedAt: '', tables: [], rows: [] })), db, + MAIN_SCOPE, ) if (!res || res.status === 401 || res.status === 403) return const body = (await res.json().catch(() => ({}))) as { error?: string } diff --git a/src/__tests__/server/postTypeBuiltInFields.test.ts b/src/__tests__/server/postTypeBuiltInFields.test.ts index 04b938d52..c337e5484 100644 --- a/src/__tests__/server/postTypeBuiltInFields.test.ts +++ b/src/__tests__/server/postTypeBuiltInFields.test.ts @@ -30,6 +30,7 @@ import { POST_TYPE_MANDATORY_FIELD_IDS, POST_TYPE_OPTIONAL_BUILTIN_FIELD_IDS, } from '@core/data/schemas' +import { MAIN_SCOPE } from '../../../server/branches/scope' async function setupDb(): Promise<{ db: DbClient; cleanup: () => Promise }> { const dir = await mkdtemp(join(tmpdir(), 'instatic-posttype-')) @@ -48,7 +49,7 @@ describe('createDataTable — post-type built-in fields', () => { it('seeds the built-in fields when a post type ships only custom ones', async () => { const { db, cleanup } = await setupDb() try { - const table = await createDataTable(db, { + const table = await createDataTable(db, MAIN_SCOPE, { name: 'Recipes', slug: 'recipes', kind: 'postType', @@ -76,7 +77,7 @@ describe('createDataTable — post-type built-in fields', () => { const supplied = buildPostTypeDefaultFields().filter( (field) => !['featuredMedia', 'seoTitle', 'seoDescription'].includes(field.id), ) - const table = await createDataTable(db, { + const table = await createDataTable(db, MAIN_SCOPE, { name: 'Catalog', slug: 'catalog', kind: 'postType', @@ -101,7 +102,7 @@ describe('createDataTable — post-type built-in fields', () => { it('seeds the full canonical set when no fields are supplied at all', async () => { const { db, cleanup } = await setupDb() try { - const table = await createDataTable(db, { + const table = await createDataTable(db, MAIN_SCOPE, { name: 'Notes', slug: 'notes', kind: 'postType', @@ -121,7 +122,7 @@ describe('createDataTable — post-type built-in fields', () => { it('makes entries in such a table routable', async () => { const { db, cleanup } = await setupDb() try { - const table = await createDataTable(db, { + const table = await createDataTable(db, MAIN_SCOPE, { name: 'Recipes', slug: 'recipes', kind: 'postType', @@ -141,7 +142,7 @@ describe('createDataTable — post-type built-in fields', () => { it('does not override a caller-supplied built-in field', async () => { const { db, cleanup } = await setupDb() try { - const table = await createDataTable(db, { + const table = await createDataTable(db, MAIN_SCOPE, { name: 'Recipes', slug: 'recipes', kind: 'postType', @@ -160,7 +161,7 @@ describe('createDataTable — post-type built-in fields', () => { it('seeds the built-in fields on the import path too', async () => { const { db, cleanup } = await setupDb() try { - const inserted = await insertDataTableIfAbsent(db, { + const inserted = await insertDataTableIfAbsent(db, MAIN_SCOPE, { id: 'tbl-imported', name: 'Recipes', slug: 'recipes', @@ -171,7 +172,7 @@ describe('createDataTable — post-type built-in fields', () => { fields: [{ type: 'text', id: 'crop', label: 'Crop' }], }) expect(inserted).toBe(true) - const table = await getDataTable(db, 'tbl-imported') + const table = await getDataTable(db, MAIN_SCOPE, 'tbl-imported') expect(table!.fields.map((field) => field.id)).toContain('slug') expect(slugForTable(table!, { slug: 'tomato' })).toBe('tomato') } finally { @@ -182,7 +183,7 @@ describe('createDataTable — post-type built-in fields', () => { it('leaves ordinary data tables untouched', async () => { const { db, cleanup } = await setupDb() try { - const table = await createDataTable(db, { + const table = await createDataTable(db, MAIN_SCOPE, { name: 'Chambers', slug: 'chambers', kind: 'data', @@ -202,7 +203,7 @@ describe('createDataTable — post-type built-in fields', () => { describe('updateDataTable — post-type built-in fields survive a PATCH', () => { /** Create the shape the bug needs: a post type with built-ins plus customs. */ async function seedRecipes(db: DbClient) { - return createDataTable(db, { + return createDataTable(db, MAIN_SCOPE, { name: 'Recipes', slug: 'recipes', kind: 'postType', @@ -219,7 +220,7 @@ describe('updateDataTable — post-type built-in fields survive a PATCH', () => const table = await seedRecipes(db) // Adding one custom field. This is the natural payload, and it used to // delete every built-in, `slug` included. - const updated = await updateDataTable(db, table.id, { + const updated = await updateDataTable(db, MAIN_SCOPE, table.id, { fields: [ { type: 'text', id: 'crop', label: 'Crop' }, { type: 'text', id: 'yield', label: 'Yield' }, @@ -241,7 +242,7 @@ describe('updateDataTable — post-type built-in fields survive a PATCH', () => const { db, cleanup } = await setupDb() try { const table = await seedRecipes(db) - const updated = await updateDataTable(db, table.id, { + const updated = await updateDataTable(db, MAIN_SCOPE, table.id, { fields: [{ type: 'text', id: 'crop', label: 'Crop' }], }) // The actual damage: with no `slug` field this returns '', and every @@ -255,7 +256,7 @@ describe('updateDataTable — post-type built-in fields survive a PATCH', () => it('preserves a customised built-in rather than reseeding the default', async () => { const { db, cleanup } = await setupDb() try { - const table = await createDataTable(db, { + const table = await createDataTable(db, MAIN_SCOPE, { name: 'Recipes', slug: 'recipes', kind: 'postType', @@ -263,7 +264,7 @@ describe('updateDataTable — post-type built-in fields survive a PATCH', () => pluralLabel: 'Recipes', fields: [{ type: 'text', id: 'title', label: 'Recipe name' }], }) - const updated = await updateDataTable(db, table.id, { + const updated = await updateDataTable(db, MAIN_SCOPE, table.id, { fields: [{ type: 'text', id: 'crop', label: 'Crop' }], }) const title = updated!.fields.filter((field) => field.id === 'title') @@ -278,7 +279,7 @@ describe('updateDataTable — post-type built-in fields survive a PATCH', () => const { db, cleanup } = await setupDb() try { const table = await seedRecipes(db) - const updated = await updateDataTable(db, table.id, { + const updated = await updateDataTable(db, MAIN_SCOPE, table.id, { fields: [ { type: 'text', id: 'slug', label: 'Web address', required: true, builtIn: true }, { type: 'text', id: 'crop', label: 'Crop' }, @@ -298,10 +299,10 @@ describe('updateDataTable — post-type built-in fields survive a PATCH', () => const table = await seedRecipes(db) // Simulate the damage this bug shipped: a table already missing them. await db`update data_tables set fields_json = ${[{ type: 'text', id: 'crop', label: 'Crop' }]} where id = ${table.id}` - const damaged = await getDataTable(db, table.id) + const damaged = await getDataTable(db, MAIN_SCOPE, table.id) expect(damaged!.fields.map((field) => field.id)).not.toContain('slug') - const repaired = await updateDataTable(db, table.id, { + const repaired = await updateDataTable(db, MAIN_SCOPE, table.id, { fields: [{ type: 'text', id: 'crop', label: 'Crop' }], }) for (const required of POST_TYPE_MANDATORY_FIELD_IDS) { @@ -317,7 +318,7 @@ describe('updateDataTable — post-type built-in fields survive a PATCH', () => const { db, cleanup } = await setupDb() try { const table = await seedRecipes(db) - const updated = await updateDataTable(db, table.id, { + const updated = await updateDataTable(db, MAIN_SCOPE, table.id, { fields: [{ type: 'text', id: 'crop', label: 'Crop' }], }) const ids = updated!.fields.map((field) => field.id) @@ -333,7 +334,7 @@ describe('updateDataTable — post-type built-in fields survive a PATCH', () => const { db, cleanup } = await setupDb() try { const table = await seedRecipes(db) - const updated = await updateDataTable(db, table.id, { fields: [] }) + const updated = await updateDataTable(db, MAIN_SCOPE, table.id, { fields: [] }) expect(updated!.fields.map((field) => field.id)).not.toContain('crop') expect(updated!.fields.map((field) => field.id)).toContain('slug') } finally { @@ -344,7 +345,7 @@ describe('updateDataTable — post-type built-in fields survive a PATCH', () => it('does not touch the fields of an ordinary data table', async () => { const { db, cleanup } = await setupDb() try { - const table = await createDataTable(db, { + const table = await createDataTable(db, MAIN_SCOPE, { name: 'Chambers', slug: 'chambers', kind: 'data', @@ -352,7 +353,7 @@ describe('updateDataTable — post-type built-in fields survive a PATCH', () => pluralLabel: 'Chambers', fields: [{ type: 'text', id: 'chamberCode', label: 'Chamber' }], }) - const updated = await updateDataTable(db, table.id, { + const updated = await updateDataTable(db, MAIN_SCOPE, table.id, { fields: [{ type: 'text', id: 'rack', label: 'Rack' }], }) expect(updated!.fields.map((field) => field.id)).toEqual(['rack']) diff --git a/src/__tests__/server/publicForms.test.ts b/src/__tests__/server/publicForms.test.ts index b1e8fdc81..ea03020d6 100644 --- a/src/__tests__/server/publicForms.test.ts +++ b/src/__tests__/server/publicForms.test.ts @@ -98,6 +98,7 @@ interface FakeTableRow { const newsletterTableRow: FakeTableRow = { id: 'newsletter_submissions', + logical_id: 'newsletter_submissions', name: 'Newsletter submissions', slug: 'newsletter-submissions', kind: 'data', @@ -133,7 +134,7 @@ function makeDb(options: { rowCount: 1, } } - if (sql.startsWith('select id, name, slug, kind, route_base')) { + if (sql.startsWith('select logical_id, name, slug, kind, route_base')) { const row = tableRows[String(params[0])] if (!row) return { rows: [], rowCount: 0 } return { @@ -150,22 +151,23 @@ function makeDb(options: { if (sql.startsWith('insert into data_rows')) { createdRows.push({ id: params[0], - table_id: params[1], - cells_json: params[2], - slug: params[3], - status: params[4], - author_user_id: params[5], - created_by_user_id: params[6], - updated_by_user_id: params[7], + logical_id: params[0], + table_id: params[2], + cells_json: params[3], + slug: params[4], + status: params[5], + author_user_id: params[6], + created_by_user_id: params[7], + updated_by_user_id: params[8], }) - return { rows: [{ id: params[0] }], rowCount: 1 } + return { rows: [{ logical_id: params[0] }], rowCount: 1 } } if (sql.startsWith('select data_tables.slug')) { const row = createdRows.find((candidate) => candidate.id === params[0]) const table = row ? tableRows[String(row.table_id)] : undefined return table ? { rows: [{ slug: table.slug }], rowCount: 1 } : { rows: [], rowCount: 0 } } - if (sql.startsWith('select data_rows.id') && sql.includes('from data_rows')) { + if (sql.startsWith('select data_rows.logical_id') && sql.includes('from data_rows')) { const row = createdRows.find((candidate) => candidate.id === params[0]) if (!row) return { rows: [], rowCount: 0 } return { diff --git a/src/__tests__/server/publishRebakeTemplate.test.ts b/src/__tests__/server/publishRebakeTemplate.test.ts index 11d6e182f..81d8777b6 100644 --- a/src/__tests__/server/publishRebakeTemplate.test.ts +++ b/src/__tests__/server/publishRebakeTemplate.test.ts @@ -26,6 +26,7 @@ function rowDate(value: string) { function pageRow(page: Page, extraCells: Record = {}) { return { id: page.id, + logical_id: page.id, table_id: 'pages', slug: page.slug, status: 'draft', @@ -68,7 +69,7 @@ function buildFakeDb(layout: Page, about: Page) { } } - if (s.includes('select data_rows.id') && s.includes('from data_rows') && s.includes('order by')) { + if (s.includes('select data_rows.logical_id') && s.includes('from data_rows') && s.includes('order by')) { if (params[0] === 'pages') { return { rows: [ diff --git a/src/__tests__/server/publishRuntimeErrorResponse.test.ts b/src/__tests__/server/publishRuntimeErrorResponse.test.ts index 65cc023a9..2045d20c7 100644 --- a/src/__tests__/server/publishRuntimeErrorResponse.test.ts +++ b/src/__tests__/server/publishRuntimeErrorResponse.test.ts @@ -6,6 +6,7 @@ import { createCapabilityTestHarness, readJson, } from '../helpers/capabilityHarness' +import { MAIN_SCOPE } from '../../../server/branches/scope' describe('publish runtime validation response', () => { it('returns live compiler diagnostics without attempting a publish', async () => { @@ -68,7 +69,7 @@ describe('publish runtime validation response', () => { expect(siteResponse.status).toBe(200) const { site } = await readJson<{ site: SiteShell }>(siteResponse) - await saveDraftSite(harness.db, { + await saveDraftSite(harness.db, MAIN_SCOPE, { ...site, files: [ ...site.files, diff --git a/src/__tests__/server/publishScheduler.test.ts b/src/__tests__/server/publishScheduler.test.ts index 97b9aa956..8224b66d1 100644 --- a/src/__tests__/server/publishScheduler.test.ts +++ b/src/__tests__/server/publishScheduler.test.ts @@ -3,6 +3,7 @@ import type { DbClient } from '../../../server/db' import { tickPublishScheduler } from '../../../server/publish/publishScheduler' import { getDataRow, listDuePublishSchedules } from '../../../server/repositories/data/rows' import { createTestDb, type TestDb } from '../helpers/createTestDb' +import { MAIN_SCOPE } from '../../../server/branches/scope' async function seedScheduledPageRow( db: DbClient, @@ -48,7 +49,7 @@ describe('publish scheduler', () => { await tickPublishScheduler(db) - const row = await getDataRow(db, rowId) + const row = await getDataRow(db, MAIN_SCOPE, rowId) expect(row).toMatchObject({ id: rowId, tableId: 'pages', diff --git a/src/__tests__/server/publishStaticArtefact.test.ts b/src/__tests__/server/publishStaticArtefact.test.ts index 0043753ff..03496dfad 100644 --- a/src/__tests__/server/publishStaticArtefact.test.ts +++ b/src/__tests__/server/publishStaticArtefact.test.ts @@ -134,12 +134,13 @@ function buildFakeDb( // ── listDataRows (pages + components) ───────────────────────────────── // `listDataRows` parameterizes the table_id ($1), so we check params. - if (s.includes('select data_rows.id') && s.includes('from data_rows') && s.includes('order by')) { + if (s.includes('select data_rows.logical_id') && s.includes('from data_rows') && s.includes('order by')) { if (params[0] === 'pages') { return { rows: [ { id: staticPage.id, + logical_id: staticPage.id, table_id: 'pages', slug: staticPage.slug, status: 'draft', @@ -176,6 +177,7 @@ function buildFakeDb( }, { id: dynamicPage.id, + logical_id: dynamicPage.id, table_id: 'pages', slug: dynamicPage.slug, status: 'draft', diff --git a/src/__tests__/server/rowVersions.test.ts b/src/__tests__/server/rowVersions.test.ts new file mode 100644 index 000000000..c5f95aeb2 --- /dev/null +++ b/src/__tests__/server/rowVersions.test.ts @@ -0,0 +1,70 @@ +/** + * Version history — listing a row's published versions and restoring one + * into the draft on the request's branch. + */ +import { afterEach, describe, expect, it } from 'bun:test' +import { MAIN_SCOPE } from '../../../server/branches/scope' +import { getDataRow, listDataRows, saveDataRowDraft } from '../../../server/repositories/data' +import { + createCapabilityTestHarness, + readJson, + type CapabilityTestHarness, +} from '../helpers/capabilityHarness' + +const ROWS = '/admin/api/cms/data/rows' + +describe('row version history', () => { + let harness: CapabilityTestHarness | null = null + + afterEach(async () => { + await harness?.cleanup() + harness = null + }) + + it('lists published versions and restores one into the draft, on main and on a branch', async () => { + harness = await createCapabilityTestHarness() + const owner = await harness.setupOwner() + const [home] = await listDataRows(harness.db, MAIN_SCOPE, 'pages') + expect(home).toBeDefined() + + const empty = await readJson<{ versions: unknown[] }>( + await harness.cms(`${ROWS}/${home!.id}/versions`, { cookie: owner }), + ) + expect(empty.versions).toEqual([]) + + await saveDataRowDraft(harness.db, MAIN_SCOPE, home!.id, { cells: { ...home!.cells, title: 'First' }, slug: home!.slug }) + expect((await harness.cms(`${ROWS}/${home!.id}/publish`, { method: 'POST', cookie: owner })).status).toBe(200) + await saveDataRowDraft(harness.db, MAIN_SCOPE, home!.id, { cells: { ...home!.cells, title: 'Second' }, slug: home!.slug }) + expect((await harness.cms(`${ROWS}/${home!.id}/publish`, { method: 'POST', cookie: owner })).status).toBe(200) + + const listed = await readJson<{ versions: Array<{ id: string; versionNumber: number; publishedByName: string | null }> }>( + await harness.cms(`${ROWS}/${home!.id}/versions`, { cookie: owner }), + ) + expect(listed.versions.map((version) => version.versionNumber)).toEqual([2, 1]) + expect(listed.versions[0]!.publishedByName).toBeTruthy() + const first = listed.versions[1]! + + // Restore v1 into main's draft. + const restored = await harness.cms(`${ROWS}/${home!.id}/versions/${first.id}/restore`, { method: 'POST', cookie: owner }) + expect(restored.status).toBe(200) + expect((await readJson<{ row: { cells: Record } }>(restored)).row.cells.title).toBe('First') + expect((await getDataRow(harness.db, MAIN_SCOPE, home!.id))!.cells.title).toBe('First') + + // Restore v2 into a branch's draft without touching main. + const fork = await harness.cms('/admin/api/cms/branches', { method: 'POST', cookie: owner, json: { name: 'History' } }) + expect(fork.status).toBe(201) + const second = listed.versions[0]! + const onBranch = await harness.cms(`${ROWS}/${home!.id}/versions/${second.id}/restore`, { + method: 'POST', + cookie: owner, + headers: { 'x-instatic-branch': 'history' }, + }) + expect(onBranch.status).toBe(200) + expect((await getDataRow(harness.db, { branchId: 'history' }, home!.id))!.cells.title).toBe('Second') + expect((await getDataRow(harness.db, MAIN_SCOPE, home!.id))!.cells.title).toBe('First') + + // An unknown or foreign version id is a 404. + const missing = await harness.cms(`${ROWS}/${home!.id}/versions/nope/restore`, { method: 'POST', cookie: owner }) + expect(missing.status).toBe(404) + }) +}) diff --git a/src/admin/AuthenticatedAdmin.tsx b/src/admin/AuthenticatedAdmin.tsx index b47fee4ca..656951a1f 100644 --- a/src/admin/AuthenticatedAdmin.tsx +++ b/src/admin/AuthenticatedAdmin.tsx @@ -59,6 +59,7 @@ import { Navigate, useInRouterContext } from './lib/routing' import { SpotlightRoot } from './spotlight' import { prewarmedLazy } from './lib/prewarmedLazy' import { useAdminUi } from './state/adminUi' +import { useBranchWorkspaceKey } from './state/branchStore' import styles from './AdminEntry.module.css' // The 10 workspace pages — pre-warmed AND synchronously-renderable once @@ -207,6 +208,10 @@ export default function AuthenticatedAdmin({ section, currentUser }: Authenticat const fallbackWorkspace = firstAccessibleWorkspace(currentUser) const siteImportOpen = useAdminUi((s) => s.siteImportOpen) const siteExportOpen = useAdminUi((s) => s.siteExport !== null) + // Branch-scoped workspaces remount on a branch switch — and after a merge + // or update rewrote the branch in place — so every hook inside them reloads + // against the current content (the site store, entry lists, grids). + const branchKey = useBranchWorkspaceKey() // Schedule background preloads for non-active workspace pages AFTER // the active page has rendered + painted. `useEffect` fires after @@ -309,9 +314,9 @@ export default function AuthenticatedAdmin({ section, currentUser }: Authenticat shouldn't ship until needed. */} }> {section === 'dashboard' ? : - section === 'site' ? : - section === 'content' ? : - section === 'data' ? : + section === 'site' ? : + section === 'content' ? : + section === 'data' ? : section === 'media' ? : section === 'plugins' ? : section === 'users' ? : diff --git a/src/admin/ai/useMcpWorkspaceBridge.ts b/src/admin/ai/useMcpWorkspaceBridge.ts index 182b5c58f..7245225f4 100644 --- a/src/admin/ai/useMcpWorkspaceBridge.ts +++ b/src/admin/ai/useMcpWorkspaceBridge.ts @@ -8,6 +8,7 @@ import { useEffect } from 'react' import { Type } from '@core/utils/typeboxHelpers' import type { AiToolOutput } from '@core/ai' import { getErrorMessage } from '@core/utils/errorMessage' +import { withAmbientHeaders } from '@core/http' import { readNdjsonStream } from './ndjsonStream' import { postToolResult } from './toolResultApi' @@ -82,14 +83,14 @@ export async function runMcpWorkspaceBridgeConnection( let bridgeId = '' try { - const res = await fetch(`${MCP_BRIDGE_PATH}?scope=${scope}`, { + const res = await fetch(`${MCP_BRIDGE_PATH}?scope=${scope}`, withAmbientHeaders({ method: 'GET', credentials: 'same-origin', // The bridge body stays newline-delimited JSON, but the event-stream // media type prevents reverse proxies from buffering the open response. headers: { Accept: 'text/event-stream' }, signal, - }) + })) if (res.status === 401 || res.status === 403) return 'auth' if (!res.ok || !res.body) return 'transient' diff --git a/src/admin/main.tsx b/src/admin/main.tsx index ead466299..b9d774351 100644 --- a/src/admin/main.tsx +++ b/src/admin/main.tsx @@ -8,6 +8,7 @@ import { AdminZoomGuard } from './shared/AdminZoomGuard' import { ErrorBoundary, flattenErrorChain, logErrorChain } from '@ui/components/ErrorBoundary' import { ToastProvider, pushToast } from '@ui/components/Toast' import '../styles/globals.css' +import { installBranchRequestHeaders } from './state/activeBranch' // `installPluginRuntime()` used to be called here, eagerly. That dragged // the whole plugin-host-hooks module (which imports `useEditorStore` from @@ -55,6 +56,9 @@ function handleRootError( } } +// Every admin request names the branch this tab edits (main sends nothing). +installBranchRequestHeaders() + const root = createRoot(rootElement, { onCaughtError: (error, info) => { handleRootError('react-root:caught', error, info, null) diff --git a/src/admin/pages/content/ContentPage.module.css b/src/admin/pages/content/ContentPage.module.css index ef7cd2dd8..d6f86e7a3 100644 --- a/src/admin/pages/content/ContentPage.module.css +++ b/src/admin/pages/content/ContentPage.module.css @@ -299,3 +299,12 @@ font-size: var(--text-6xl); } } + +/* Inline reason under a control whose action is unavailable in this context + * (e.g. publishing while a branch is active). */ +.fieldHint { + margin: 0; + color: var(--text-muted); + font-size: var(--text-2xs); + line-height: 1.4; +} diff --git a/src/admin/pages/content/ContentPage.tsx b/src/admin/pages/content/ContentPage.tsx index 95ef66183..90965db43 100644 --- a/src/admin/pages/content/ContentPage.tsx +++ b/src/admin/pages/content/ContentPage.tsx @@ -473,6 +473,7 @@ export function ContentPage() { if (workspace.selectedEntry) void handlePublishEntry(workspace.selectedEntry) }} onSchedule={handleScheduleEntry} + onRestored={handleScheduleEntry} /> )} contentSidebar={( diff --git a/src/admin/pages/content/components/ContentSettingsPanel/ContentSettingsPanel.tsx b/src/admin/pages/content/components/ContentSettingsPanel/ContentSettingsPanel.tsx index bf89524b6..778b8e5b3 100644 --- a/src/admin/pages/content/components/ContentSettingsPanel/ContentSettingsPanel.tsx +++ b/src/admin/pages/content/components/ContentSettingsPanel/ContentSettingsPanel.tsx @@ -20,6 +20,7 @@ import { } from '@core/data/schemas' import propertiesStyles from '../../../site/panels/PropertiesPanel/PropertiesPanel.module.css' import { PanelHeader } from '@admin/shared/PanelHeader' +import { useBranchPublishGate } from '@admin/state/branchStore' import styles from '../../ContentPage.module.css' // Lazy-load the generic custom-field editors: they pull in the Data @@ -147,10 +148,14 @@ export function ContentSettingsPanel({ const canEditSelectedEntry = Boolean(selectedEntry && canEditEntry) const canMoveSelectedEntry = Boolean(selectedEntry && canMoveEntry) const canChangeStatus = Boolean(selectedEntry && (canEditEntry || canPublishEntry)) + // Publishing only exists on main — the option stays listed but disabled, + // and the reason renders under the control while a branch is active. + const branchGate = useBranchPublishGate() + const canPublishHere = canPublishEntry && !branchGate.onBranch const statusOptions = [ { value: 'draft', label: 'Draft', enabled: canEditEntry }, { value: 'scheduled', label: 'Scheduled', enabled: false }, - { value: 'published', label: 'Published', enabled: canPublishEntry }, + { value: 'published', label: 'Published', enabled: canPublishHere }, { value: 'unpublished', label: 'Unpublished', enabled: canEditEntry }, ].filter((option) => option.enabled || option.value === selectedEntry?.status) .map(({ value, label, enabled }) => ({ value, label, disabled: !enabled })) @@ -234,12 +239,15 @@ export function ContentSettingsPanel({ disabled={!canChangeStatus} onChange={(event) => { const nextStatus = event.target.value as DataRowStatus - if (nextStatus === 'published' && !canPublishEntry) return + if (nextStatus === 'published' && !canPublishHere) return if (nextStatus !== 'published' && !canEditEntry) return onStatusChange(nextStatus) }} options={statusOptions} /> + {branchGate.reason && ( +

{branchGate.reason}

+ )}
Public URL diff --git a/src/admin/pages/content/components/ContentToolbar/ContentToolbar.tsx b/src/admin/pages/content/components/ContentToolbar/ContentToolbar.tsx index b3c15452b..597e970f3 100644 --- a/src/admin/pages/content/components/ContentToolbar/ContentToolbar.tsx +++ b/src/admin/pages/content/components/ContentToolbar/ContentToolbar.tsx @@ -1,8 +1,9 @@ -import { useState } from 'react' +import { Suspense, lazy, useState } from 'react' import { CalendarSolidIcon } from 'pixel-art-icons/icons/calendar-solid' import { CheckIcon } from 'pixel-art-icons/icons/check' import { CircleAlertSolidIcon } from 'pixel-art-icons/icons/circle-alert-solid' import { ExternalLinkSolidIcon } from 'pixel-art-icons/icons/external-link-solid' +import { ArchiveRestoreSolidIcon } from 'pixel-art-icons/icons/archive-restore-solid' import { LoaderIcon } from 'pixel-art-icons/icons/loader' import { SaveSolidIcon } from 'pixel-art-icons/icons/save-solid' import { SendSolidIcon } from 'pixel-art-icons/icons/send-solid' @@ -14,6 +15,7 @@ import { type PublishActionStatusTone, } from '@site/toolbar/PublishActionGroup' import { SchedulePublishDialog } from '@admin/modals/SchedulePublishDialog' +import { useBranchPublishGate } from '@admin/state/branchStore' import type { SaveMessage } from '@content/hooks/useContentEntryDraft' interface ContentToolbarProps { @@ -28,6 +30,8 @@ interface ContentToolbarProps { onSaveDraft: () => void onPublish: () => void onSchedule: (entry: DataRow) => void + /** Fires with the draft row after a published version is restored into it. */ + onRestored: (entry: DataRow) => void } // --------------------------------------------------------------------------- @@ -41,6 +45,11 @@ interface ContentToolbarProps { type PublishButtonState = 'idle' | 'busy' | 'success' | 'error' +// Opened rarely — loaded on first open so the content route chunk stays small. +const VersionHistoryDialog = lazy(() => + import('@admin/shared/VersionHistoryDialog').then((m) => ({ default: m.VersionHistoryDialog })), +) + interface ToolbarViewState { statusText: string statusTone: PublishActionStatusTone @@ -163,6 +172,7 @@ export function ContentToolbar({ onSaveDraft, onPublish, onSchedule, + onRestored, }: ContentToolbarProps) { const entryLabel = (selectedCollection?.singularLabel ?? 'entry').toLowerCase() // Destructure the derived view state so the JSX below keeps reading like @@ -175,6 +185,10 @@ export function ContentToolbar({ const isSaving = saveMessage === 'saving' const isPublishing = saveMessage === 'publishing' const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false) + const [historyOpen, setHistoryOpen] = useState(false) + // Publishing and scheduling only exist on main — disabled with the reason + // inline while a branch is active. + const branchGate = useBranchPublishGate() const menuItems: PublishActionMenuItem[] = [ { @@ -192,10 +206,18 @@ export function ContentToolbar({ id: 'schedule-publish', label: `Schedule ${entryLabel}…`, icon: CalendarSolidIcon, - disabled: !selectedEntry || !canPublish || isPublishing, + disabled: !selectedEntry || !canPublish || isPublishing || branchGate.onBranch, onSelect: () => setScheduleDialogOpen(true), testId: 'toolbar-content-schedule-publish-action', }, + { + id: 'version-history', + label: 'Version history…', + icon: ArchiveRestoreSolidIcon, + disabled: !selectedEntry || !canSaveDraft, + onSelect: () => setHistoryOpen(true), + testId: 'toolbar-content-version-history-action', + }, { id: 'open-live', label: `Open live ${entryLabel}`, @@ -215,15 +237,30 @@ export function ContentToolbar({ statusLabel={isCleanPublished ? null : statusText} statusTone={statusTone} publishLabel={publishLabel} - publishAriaLabel={isCleanPublished ? 'Published' : `Publish ${entryLabel}`} - publishTitle={isCleanPublished ? 'Published' : `Publish ${entryLabel}`} + publishAriaLabel={ + branchGate.reason + ? `Cannot publish: ${branchGate.reason}` + : isCleanPublished ? 'Published' : `Publish ${entryLabel}` + } + publishTitle={branchGate.reason ?? (isCleanPublished ? 'Published' : `Publish ${entryLabel}`)} publishState={publishState} publishBusy={isPublishing} - publishDisabled={!selectedEntry || !canPublish || isPublishing || isCleanPublished} + publishDisabled={!selectedEntry || !canPublish || isPublishing || isCleanPublished || branchGate.onBranch} publishIcon={PublishIcon} onPublish={onPublish} menuItems={menuItems} /> + {selectedEntry && historyOpen && ( + + setHistoryOpen(false)} + onRestored={onRestored} + /> + + )} {selectedEntry && ( { void handleSaveActiveDraft() }, testId: 'toolbar-data-save-draft-action', }] + // Publishing only exists on main — on a branch the group stays visible but + // disabled, with the reason inline. + const branchGate = useBranchPublishGate() const publishStatus = activeDraft?.saveError ? { label: 'Draft save failed', tone: 'danger' as const } : isSavingDraft @@ -341,12 +345,20 @@ export function DataPage() { 0} @@ -44,6 +48,8 @@ export function DataGridBulkActionBar({ size="sm" shape="pill" className={styles.bulkBarBtn} + disabled={branchGate.onBranch} + tooltip={branchGate.reason ?? undefined} onClick={() => onSetStatus('published')} > Publish diff --git a/src/admin/pages/data/components/DataGrid/DataRowContextMenu.tsx b/src/admin/pages/data/components/DataGrid/DataRowContextMenu.tsx index 50e4a49f7..517915901 100644 --- a/src/admin/pages/data/components/DataGrid/DataRowContextMenu.tsx +++ b/src/admin/pages/data/components/DataGrid/DataRowContextMenu.tsx @@ -15,6 +15,7 @@ import { LayoutSolidIcon } from 'pixel-art-icons/icons/layout-solid' import { OpenSolidIcon } from 'pixel-art-icons/icons/open-solid' import { TrashSolidIcon } from 'pixel-art-icons/icons/trash-solid' import type { DataRow, DataRowStatus, DataTable } from '@core/data/schemas' +import { useBranchPublishGate } from '@admin/state/branchStore' interface DataRowContextMenuProps { x: number @@ -130,6 +131,9 @@ export function DataRowContextMenu({ onOpenInSiteEditor, }) const publishable = hasPublishWorkflow(table) && onSetRowStatus != null + // Publishing only exists on main — the item stays listed but disabled with + // the reason while a branch is active. + const branchGate = useBranchPublishGate() useEffect(() => { firstItemRef.current?.focus() @@ -177,7 +181,8 @@ export function DataRowContextMenu({ <> { void setRowStatusFromMenu(row.id, 'published', onSetRowStatus, onClose) }} diff --git a/src/admin/pages/data/components/ExportDialog/ExportDialog.tsx b/src/admin/pages/data/components/ExportDialog/ExportDialog.tsx index 04ce526a2..eb5777a92 100644 --- a/src/admin/pages/data/components/ExportDialog/ExportDialog.tsx +++ b/src/admin/pages/data/components/ExportDialog/ExportDialog.tsx @@ -26,6 +26,8 @@ import { Switch } from '@ui/components/Switch' import { pushToast } from '@ui/components/Toast' import { assignRailAccents, railTintVar } from '@ui/railAccent' import { getExportSummary, submitSiteBundleExport } from '@core/persistence/cmsTransfer' +import { MAIN_BRANCH_ID } from '@core/branches' +import { useActiveBranchId } from '@admin/state/branchStore' import { listCmsDataRows } from '@core/persistence/cmsData' import { isAbortError } from '@core/http' import { getErrorMessage } from '@core/utils/errorMessage' @@ -194,6 +196,7 @@ export function ExportDialog({ const requestedTablesRef = useRef>(new Set()) const [summary, setSummary] = useState(null) + const activeBranchId = useActiveBranchId() const [exporting, setExporting] = useState(false) const [error, setError] = useState(null) @@ -274,6 +277,8 @@ export function ExportDialog({ includeSite: siteShell, includeMediaFolders, includeRedirects, + // The download is a form POST without the branch header: name the branch. + ...(activeBranchId === MAIN_BRANCH_ID ? {} : { branchId: activeBranchId }), } const estimate = useExportEstimate(open ? request : null) diff --git a/src/admin/pages/site/agent/agentSlice.ts b/src/admin/pages/site/agent/agentSlice.ts index 1c3260456..753ef8681 100644 --- a/src/admin/pages/site/agent/agentSlice.ts +++ b/src/admin/pages/site/agent/agentSlice.ts @@ -18,7 +18,7 @@ import { nanoid } from 'nanoid' import type { EditorStoreSliceCreator } from '@site/store/types' -import { ApiError, isAbortError, responseErrorMessage } from '@core/http' +import { ApiError, isAbortError, responseErrorMessage, withAmbientHeaders } from '@core/http' import type { AiChatRequestBody } from '@core/ai' import { pushToast } from '@ui/components/Toast' import { @@ -606,12 +606,12 @@ export function createAgentSlice( } const body: AiChatRequestBody = { conversationId, content: [...content], snapshot } - const res = await fetch(`/admin/api/ai/chat/${config.scope}`, { + const res = await fetch(`/admin/api/ai/chat/${config.scope}`, withAmbientHeaders({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: controller.signal, - }) + })) if (!res.ok) { const fallback = res.status === 502 diff --git a/src/admin/pages/site/collab/awarenessState.ts b/src/admin/pages/site/collab/awarenessState.ts index aea79e7ce..22fd208b4 100644 --- a/src/admin/pages/site/collab/awarenessState.ts +++ b/src/admin/pages/site/collab/awarenessState.ts @@ -20,6 +20,7 @@ import { collabAwareness, onCollabProviderChange, } from '@site/store/slices/site/collabBinding' +import { collabBranchId } from '@site/store/slices/site/collabBranch' const PointerSchema = Type.Object({ /** Iframe-viewport coordinates inside the breakpoint frame. */ @@ -76,11 +77,12 @@ export function activeEditorDocId(state: { activeDocument: { kind: string; vcId?: string } | null activePageId: string | null }): string | null { + const branchId = collabBranchId() if (state.activeDocument?.kind === 'visualComponent' && state.activeDocument.vcId) { - return encodeCollabDocId({ kind: 'component', rowId: state.activeDocument.vcId }) + return encodeCollabDocId({ kind: 'component', branchId, rowId: state.activeDocument.vcId }) } return state.activePageId - ? encodeCollabDocId({ kind: 'page', rowId: state.activePageId }) + ? encodeCollabDocId({ kind: 'page', branchId, rowId: state.activePageId }) : null } diff --git a/src/admin/pages/site/collab/collabProvider.ts b/src/admin/pages/site/collab/collabProvider.ts index 7d639aac1..1f7b31273 100644 --- a/src/admin/pages/site/collab/collabProvider.ts +++ b/src/admin/pages/site/collab/collabProvider.ts @@ -109,6 +109,15 @@ interface BoundEntry { * client-created-row flow). See @core/collab/protocol. */ generation: string + /** + * Local updates made before the lineage is known. The server refuses a + * write carrying `''` once its doc has content — and the doc has content + * as soon as the FIRST such write lands, so a second local change inside + * the bind round trip (a row created and placed, a seed in two + * transactions) would be reset as stale. Held here and sent as one update + * the moment the first inbound frame names the lineage. + */ + pending: Uint8Array[] synced: boolean whenSynced: Promise resolveSynced: () => void @@ -201,20 +210,29 @@ export function createCollabProvider( const entry: BoundEntry = { doc, generation: '', + pending: [], synced: false, whenSynced, resolveSynced, updateHandler: (update, origin) => { if (origin === REMOTE_ORIGIN) return - const encoder = encoding.createEncoder() - syncProtocol.writeUpdate(encoder, update) - sendFrame(docId, FRAME_SYNC, encoding.toUint8Array(encoder)) + if (entry.generation === '') { + entry.pending.push(update) + return + } + sendUpdate(docId, update) }, } doc.on('update', entry.updateHandler) return entry } + function sendUpdate(docId: string, update: Uint8Array): void { + const encoder = encoding.createEncoder() + syncProtocol.writeUpdate(encoder, update) + sendFrame(docId, FRAME_SYNC, encoding.toUint8Array(encoder)) + } + const awarenessUpdateHandler = ( { added, updated, removed }: { added: number[]; updated: number[]; removed: number[] }, origin: unknown, @@ -252,9 +270,15 @@ export function createCollabProvider( return } // Adopt the server's lineage from the first frame that names one, so every - // subsequent outbound frame is refusable if this doc is later reseeded. + // subsequent outbound frame is refusable if this doc is later reseeded — + // and release the local changes held back until now, as one update. if (entry.generation === '' && frame.generation !== '') { entry.generation = frame.generation + if (entry.pending.length > 0) { + const held = entry.pending + entry.pending = [] + sendUpdate(frame.docId, held.length === 1 ? held[0]! : Y.mergeUpdates(held)) + } } if (frame.frameType !== FRAME_SYNC) return diff --git a/src/admin/pages/site/hooks/usePersistence.ts b/src/admin/pages/site/hooks/usePersistence.ts index a7548d717..4fd115cea 100644 --- a/src/admin/pages/site/hooks/usePersistence.ts +++ b/src/admin/pages/site/hooks/usePersistence.ts @@ -36,10 +36,19 @@ import { connectCollabProvider, disconnectCollabProvider, } from '@site/store/slices/site/collabBinding' +import { setCollabBranchGoneHandler, setCollabBranchId } from '@site/store/slices/site/collabBranch' import { consumePendingCmsSiteReload, hasPendingCmsSiteReload, } from '@admin/state/adminEvents' +import { fallBackToMain, useBranchStore } from '@admin/state/branchStore' + +/** + * Branch the in-memory site document was loaded from. A remount on another + * branch must reload rather than reuse the store's site, which still holds + * the previous branch's content. + */ +let loadedBranchId: string | null = null export interface PersistenceSaveStatus { state: 'loading' | 'synced' | 'connecting' | 'offline' | 'error' @@ -108,17 +117,28 @@ export function usePersistence( async function load(): Promise { // Read actions point-in-time — no React subscription needed. - const { site: existingSite, loadSite, createSite } = useEditorStore.getState() + const { site: loadedSite, loadSite, createSite, clearSite } = useEditorStore.getState() + + // Every doc id the editor mints from here on carries this branch. + const activeBranchId = useBranchStore.getState().activeBranchId + setCollabBranchId(activeBranchId) + const branchChanged = loadedBranchId !== null && loadedBranchId !== activeBranchId + // A different branch: the in-memory site (and the detached docs seeded + // from it under the old branch's ids) must not stay on screen or accept + // edits while the new branch loads. Clearing drops both at once. + if (branchChanged && loadedSite) clearSite() + const existingSite = branchChanged ? null : loadedSite const pendingCmsSiteReload = hasPendingCmsSiteReload() const shouldReloadExistingSite = existingSite - ? pendingCmsSiteReload || siteMissesEditorDataDeepLink(existingSite) + ? pendingCmsSiteReload || branchChanged || siteMissesEditorDataDeepLink(existingSite) : false if (existingSite && !shouldReloadExistingSite) { // In-memory document from an earlier editor mount. The provider // connect below re-syncs every doc against the server, so any drift // (writes from other admins / plugins while we were away) projects in. + loadedBranchId = activeBranchId setLoadState({ phase: 'ready' }) return } @@ -133,6 +153,7 @@ export function usePersistence( if (result) { if (pendingCmsSiteReload) consumePendingCmsSiteReload() loadSite(result.site) + loadedBranchId = activeBranchId applyDefaultBreakpointPreference(result.site.breakpoints) setLoadState({ phase: 'ready' }) return @@ -164,6 +185,7 @@ export function usePersistence( // storage rows; the provider connect below then binds the server-seeded // docs for them. const created = createSite('My Site') + loadedBranchId = activeBranchId applyDefaultBreakpointPreference(created.breakpoints) try { await adapterRef.current.saveSite(created) @@ -199,7 +221,10 @@ export function usePersistence( // the error. When a stale in-memory site survived a transient reload // failure, we DO connect — live sync recovers against those docs and the // connected state then supersedes the stale load error in saveStatus. + // A site that belongs to another branch (its load failed after a + // switch) is never bound under this branch's ids. if (useEditorStore.getState().site === null) return + if (loadedBranchId !== useBranchStore.getState().activeBranchId) return const { createCollabProvider } = await providerModule if (cancelled) return provider = createCollabProvider() @@ -226,10 +251,14 @@ export function usePersistence( }) } } + // The server says this branch is gone (deleted while the tab was on it): + // leave it rather than rebind — the store's fallback toasts once. + setCollabBranchGoneHandler((branchId) => fallBackToMain(branchId)) void boot() return () => { cancelled = true + setCollabBranchGoneHandler(null) offStatus?.() if (connected) { // Tears the provider down AND resets the binding to detached docs diff --git a/src/admin/pages/site/panels/AgentPanel/AgentPanel.tsx b/src/admin/pages/site/panels/AgentPanel/AgentPanel.tsx index a0f726532..444732f47 100644 --- a/src/admin/pages/site/panels/AgentPanel/AgentPanel.tsx +++ b/src/admin/pages/site/panels/AgentPanel/AgentPanel.tsx @@ -54,7 +54,7 @@ import type { OpenAgentImageMenu, } from './agentImageTypes' import { ToolCallRow } from './ToolCallRow' -import { formatRelativeTime } from './relativeTime' +import { formatRelativeTime } from '@core/utils/relativeTime' import styles from './AgentPanel.module.css' const PANEL_WIDTH = 320 diff --git a/src/admin/pages/site/panels/AgentPanel/ConversationHistory.tsx b/src/admin/pages/site/panels/AgentPanel/ConversationHistory.tsx index b7137ba2f..468b941b7 100644 --- a/src/admin/pages/site/panels/AgentPanel/ConversationHistory.tsx +++ b/src/admin/pages/site/panels/AgentPanel/ConversationHistory.tsx @@ -14,7 +14,7 @@ import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from '@ui/componen import { BulletlistSolidIcon } from 'pixel-art-icons/icons/bulletlist-solid' import { PlusIcon } from 'pixel-art-icons/icons/plus' import { TrashSolidIcon } from 'pixel-art-icons/icons/trash-solid' -import { formatRelativeTime } from './relativeTime' +import { formatRelativeTime } from '@core/utils/relativeTime' import styles from './AgentPanel.module.css' export function ConversationHistory() { diff --git a/src/admin/pages/site/store/slices/site/collabBinding.ts b/src/admin/pages/site/store/slices/site/collabBinding.ts index a557c0586..7aadd3a81 100644 --- a/src/admin/pages/site/store/slices/site/collabBinding.ts +++ b/src/admin/pages/site/store/slices/site/collabBinding.ts @@ -39,6 +39,7 @@ import { createCollabDocSet, dataMap, encodeCollabDocId, + isSiteDocId, LOCAL_ORIGIN, metaMap, parseCollabDocId, @@ -53,10 +54,11 @@ import { seedSiteDoc, SEED_ORIGIN, shellMap, - SITE_DOC_ID, + siteDocId, treeMap, type CollabDocSet, } from '@core/collab' +import { allDocIdsForSite, collabBranchId, notifyCollabBranchGone } from './collabBranch' import { clonePackageJson } from '@core/site-dependencies/manifest' import { cloneSiteRuntimeConfig } from '@core/site-runtime' import { validateSite } from '@core/persistence/validate' @@ -147,7 +149,7 @@ export function onCollabProviderChange(listener: () => void): () => void { // --------------------------------------------------------------------------- function undoScopesFor(docId: string, doc: Y.Doc): Y.Map[] { - return docId === SITE_DOC_ID + return isSiteDocId(docId) ? [shellMap(doc), rostersMap(doc)] : [treeMap(doc), metaMap(doc), dataMap(doc)] } @@ -277,7 +279,9 @@ export function applyLocalSitePatches( const before = new Map() for (const [docId, entry] of managed) before.set(docId, entry.manager.undoStack.length) - const touched = applySitePatchesToDocs(patches, preSite, nextSite, decidingDocSet, LOCAL_ORIGIN) + const touched = applySitePatchesToDocs( + patches, preSite, nextSite, decidingDocSet, LOCAL_ORIGIN, collabBranchId(), + ) alignedSiteRef = nextSite if (touched.length === 0) return { accepted: true } @@ -392,7 +396,7 @@ function flushProjections(): void { const batch = [...pendingProjections] pendingProjections.clear() // Site doc last — it assembles rows the row projections just refreshed. - batch.sort((a, b) => (a === SITE_DOC_ID ? 1 : 0) - (b === SITE_DOC_ID ? 1 : 0)) + batch.sort((a, b) => (isSiteDocId(a) ? 1 : 0) - (isSiteDocId(b) ? 1 : 0)) for (const id of batch) projectDocIntoStore(id) } @@ -474,7 +478,7 @@ function projectDocIntoStore(docId: string): void { rows.push(known) continue } - const rowDocId = encodeCollabDocId({ kind, rowId: id }) + const rowDocId = encodeCollabDocId({ kind, branchId: collabBranchId(), rowId: id }) const fresh = rowFromDoc(rowDocId) as T | null if (fresh) { rows.push(fresh) @@ -548,15 +552,6 @@ function projectDocIntoStore(docId: string): void { // Lifecycle + provider connection // --------------------------------------------------------------------------- -function allDocIdsForSite(site: SiteDocument): string[] { - return [ - SITE_DOC_ID, - ...site.pages.map((p) => encodeCollabDocId({ kind: 'page', rowId: p.id })), - ...site.visualComponents.map((vc) => encodeCollabDocId({ kind: 'component', rowId: vc.id })), - ...site.layouts.map((l) => encodeCollabDocId({ kind: 'layout', rowId: l.id })), - ] -} - /** * Reset the doc world to mirror a freshly loaded site (or nothing). In * detached mode the docs are seeded locally; in connected mode every doc @@ -590,23 +585,25 @@ export function resetCollabDocsFromSite(site: SiteDocument | null): void { } function seedDetachedDocs(site: SiteDocument): void { - const siteDoc = docs.ensure(SITE_DOC_ID) + const branchId = collabBranchId() + const shellDocId = siteDocId(branchId) + const siteDoc = docs.ensure(shellDocId) seedSiteDoc(siteDoc, site) - ensureManaged(SITE_DOC_ID, siteDoc) + ensureManaged(shellDocId, siteDoc) for (const page of site.pages) { - const docId = encodeCollabDocId({ kind: 'page', rowId: page.id }) + const docId = encodeCollabDocId({ kind: 'page', branchId, rowId: page.id }) const doc = docs.ensure(docId) seedPageDoc(doc, page) ensureManaged(docId, doc) } for (const vc of site.visualComponents) { - const docId = encodeCollabDocId({ kind: 'component', rowId: vc.id }) + const docId = encodeCollabDocId({ kind: 'component', branchId, rowId: vc.id }) const doc = docs.ensure(docId) seedComponentDoc(doc, vc) ensureManaged(docId, doc) } for (const layout of site.layouts) { - const docId = encodeCollabDocId({ kind: 'layout', rowId: layout.id }) + const docId = encodeCollabDocId({ kind: 'layout', branchId, rowId: layout.id }) const doc = docs.ensure(docId) seedLayoutDoc(doc, layout) ensureManaged(docId, doc) @@ -626,7 +623,7 @@ function bindDocThroughProvider(docId: string): void { // A row doc bound on demand (a peer created the row) re-assembles the // site once its content arrives — the roster projection skipped it // while it was empty. - if (docId !== SITE_DOC_ID) scheduleProjection(SITE_DOC_ID) + if (!isSiteDocId(docId)) scheduleProjection(siteDocId(collabBranchId())) }) } @@ -650,6 +647,13 @@ export function connectCollabProvider(next: CollabProvider): void { }) detachProviderReset?.() detachProviderReset = next.onReset((docId, reason) => { + if (reason === 'gone') { + // The branch is gone: leave it (rebinding would only be refused again). + next.unbind(docId) + const parsed = parseCollabDocId(docId) + if (parsed) notifyCollabBranchGone(parsed.branchId) + return + } collabResetToast(reason) const current = storeApi?.getState() if ( diff --git a/src/admin/pages/site/store/slices/site/collabBranch.ts b/src/admin/pages/site/store/slices/site/collabBranch.ts new file mode 100644 index 000000000..538eaebc3 --- /dev/null +++ b/src/admin/pages/site/store/slices/site/collabBranch.ts @@ -0,0 +1,47 @@ +/** + * The branch the editor's doc world addresses. + * + * Every collab doc id the binding mints carries a branch (see `@core/collab` + * docIds), so a site loaded from one branch can never bind to another + * branch's documents. The editor runtime sets the branch before the site + * loads; detached-mode tests run on main. + */ +import { MAIN_BRANCH_ID } from '@core/branches' +import { encodeCollabDocId, siteDocId } from '@core/collab' +import type { SiteDocument } from '@core/page-tree' + +let activeBranchId: string = MAIN_BRANCH_ID + +export function setCollabBranchId(branchId: string): void { + activeBranchId = branchId +} + +export function collabBranchId(): string { + return activeBranchId +} + +/** + * Installed by the editor runtime: called when the server reports a doc's + * branch as gone (deleted while this tab was on it) so the tab leaves the + * branch instead of rebinding. + */ +let branchGoneHandler: ((branchId: string) => void) | null = null + +export function setCollabBranchGoneHandler(handler: ((branchId: string) => void) | null): void { + branchGoneHandler = handler +} + +export function notifyCollabBranchGone(branchId: string): void { + branchGoneHandler?.(branchId) +} + +/** Every doc id a site document binds on the active branch, shell first. */ +export function allDocIdsForSite(site: SiteDocument): string[] { + const branchId = activeBranchId + return [ + siteDocId(branchId), + ...site.pages.map((p) => encodeCollabDocId({ kind: 'page', branchId, rowId: p.id })), + ...site.visualComponents.map((vc) => encodeCollabDocId({ kind: 'component', branchId, rowId: vc.id })), + ...site.layouts.map((l) => encodeCollabDocId({ kind: 'layout', branchId, rowId: l.id })), + ] +} diff --git a/src/admin/pages/site/store/slices/site/collabNotices.ts b/src/admin/pages/site/store/slices/site/collabNotices.ts index 0975e2967..9799a7eb1 100644 --- a/src/admin/pages/site/store/slices/site/collabNotices.ts +++ b/src/admin/pages/site/store/slices/site/collabNotices.ts @@ -47,6 +47,7 @@ const BLOCK_COPY: Record = { const RESET_REASON_COPY: Record = { rewritten: '', + gone: '', stale: 'This document was rebuilt while you were disconnected, so unsent changes were discarded. The latest version is now loaded.', refused: 'Your role does not allow that change, so it was undone.', oversize: 'That change was too large to sync in one step and was undone. Try making it in smaller pieces.', @@ -84,7 +85,8 @@ export function clearCollabBlockNotice(): void { * so it stays silent; the other three mean their work was discarded. */ export function collabResetToast(reason: ResetReason): void { - if (reason === 'rewritten') return + // `gone` is handled by the branch fallback (a toast of its own). + if (reason === 'rewritten' || reason === 'gone') return pushToast({ kind: 'error', title: 'A change was reverted', body: RESET_REASON_COPY[reason] }) } diff --git a/src/admin/pages/site/toolbar/PublishButton.tsx b/src/admin/pages/site/toolbar/PublishButton.tsx index f18bfb65d..7bad139b7 100644 --- a/src/admin/pages/site/toolbar/PublishButton.tsx +++ b/src/admin/pages/site/toolbar/PublishButton.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { Suspense, lazy, useEffect, useRef, useState } from 'react' import type { SiteDocument } from '@core/page-tree' import { selectActivePage, useEditorStore } from '@site/store/store' import { getCmsPublishStatus, publishCmsDraft } from '@core/persistence' @@ -8,7 +8,9 @@ import { CheckIcon } from 'pixel-art-icons/icons/check' import { CircleAlertSolidIcon } from 'pixel-art-icons/icons/circle-alert-solid' import { CloudUploadSolidIcon } from 'pixel-art-icons/icons/cloud-upload-solid' import { EyeSolidIcon } from 'pixel-art-icons/icons/eye-solid' +import { ArchiveRestoreSolidIcon } from 'pixel-art-icons/icons/archive-restore-solid' import { StepUpCancelledMessage, useStepUp } from '@admin/shared/StepUp' +import { useBranchPublishGate } from '@admin/state/branchStore' import { SchedulePublishDialog } from '@admin/modals/SchedulePublishDialog' import type { PersistenceSaveStatus } from '@site/hooks/usePersistence' import { pushToast } from '@ui/components/Toast' @@ -18,6 +20,11 @@ import type { SiteRuntimeDiagnostic } from '@core/site-runtime' type PublishState = 'idle' | 'publishing' | 'published' | 'error' +// Opened rarely — loaded on first open so the site route shell stays small. +const VersionHistoryDialog = lazy(() => + import('@admin/shared/VersionHistoryDialog').then((m) => ({ default: m.VersionHistoryDialog })), +) + interface PublishButtonProps { enabled?: boolean saveStatus?: PersistenceSaveStatus @@ -38,8 +45,12 @@ export function PublishButton({ const activePage = useEditorStore(selectActivePage) const openPreview = useEditorStore((s) => s.openPreview) const { runStepUp } = useStepUp() + // Publishing only exists on main: on a branch the control stays visible + // but disabled, and the status chip carries the reason inline. + const branchGate = useBranchPublishGate() const [state, setState] = useState('idle') const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false) + const [historyOpen, setHistoryOpen] = useState(false) const statusTimerRef = useRef | null>(null) /** * The `site` reference captured when the button entered the "published" @@ -60,7 +71,7 @@ export function PublishButton({ }, []) useEffect(() => { - if (!enabled || !siteId) return + if (!enabled || !siteId || branchGate.onBranch) return let cancelled = false async function loadPublishStatus() { @@ -78,7 +89,7 @@ export function PublishButton({ void loadPublishStatus() return () => { cancelled = true } - }, [enabled, siteId]) + }, [enabled, siteId, branchGate.onBranch]) useEffect(() => { if (state !== 'published' || site === publishedSiteRef.current) return @@ -102,6 +113,7 @@ export function PublishButton({ if ( !site || !enabled || + branchGate.onBranch || state === 'publishing' || runtimeErrorCount > 0 || runtimeValidationPending @@ -156,6 +168,7 @@ export function PublishButton({ const disabled = ( !site || !enabled || + branchGate.onBranch || isPublishing || notSynced || runtimeErrorCount > 0 || @@ -167,6 +180,8 @@ export function PublishButton({ state === 'error' ? 'Retry publish' : 'Publish' + // No branch entry here: the strip names the branch and the disabled Publish + // carries the reason, so the status pill stays about sync and code health. const status = syncError ? { label: 'Sync failed', @@ -212,7 +227,7 @@ export function PublishButton({ id: 'schedule-publish', label: 'Schedule publish…', icon: CalendarSolidIcon, - disabled: !activePage || runtimeErrorCount > 0 || runtimeValidationPending, + disabled: !activePage || branchGate.onBranch || runtimeErrorCount > 0 || runtimeValidationPending, onSelect: () => setScheduleDialogOpen(true), testId: 'toolbar-schedule-publish-action', }, @@ -224,6 +239,16 @@ export function PublishButton({ onSelect: () => openPreview(), testId: 'toolbar-preview-action', }, + { + // Published versions of the active page; restoring rewrites its draft + // on the active branch and the relay reloads the canvas. + id: 'version-history', + label: 'Version history…', + icon: ArchiveRestoreSolidIcon, + disabled: !activePage, + onSelect: () => setHistoryOpen(true), + testId: 'toolbar-version-history-action', + }, // "Open live page" used to live here. It now has a dedicated // toolbar icon button (`OpenLivePageButton`) next to the avatar so // it's reachable on every admin route — not just the Site editor. @@ -237,18 +262,21 @@ export function PublishButton({ statusAriaLabel={status.ariaLabel} publishLabel={label} publishAriaLabel={ - state === 'published' - ? 'Published' - : runtimeErrorCount > 0 - ? `Cannot publish: ${runtimeErrorLabel}` - : 'Publish site' + branchGate.reason + ? `Cannot publish: ${branchGate.reason}` + : state === 'published' + ? 'Published' + : runtimeErrorCount > 0 + ? `Cannot publish: ${runtimeErrorLabel}` + : 'Publish site' } publishTitle={ - state === 'published' - ? 'Published' - : runtimeErrorCount > 0 - ? `Resolve ${runtimeErrorLabel} before publishing` - : 'Publish site' + branchGate.reason + ?? (state === 'published' + ? 'Published' + : runtimeErrorCount > 0 + ? `Resolve ${runtimeErrorLabel} before publishing` + : 'Publish site') } publishState={state === 'publishing' ? 'busy' : state === 'published' ? 'success' : state} publishBusy={isPublishing} @@ -257,6 +285,16 @@ export function PublishButton({ onPublish={handlePublish} menuItems={menuItems} /> + {activePage && historyOpen && ( + + setHistoryOpen(false)} + /> + + )} {activePage && ( {overlay} +
)} + {adminNavigationSlot ?? }
diff --git a/src/admin/pages/users/utils/capabilities.ts b/src/admin/pages/users/utils/capabilities.ts index 2cc318e13..f36a373ed 100644 --- a/src/admin/pages/users/utils/capabilities.ts +++ b/src/admin/pages/users/utils/capabilities.ts @@ -24,6 +24,7 @@ export const CAPABILITY_GROUPS: CapabilityGroup[] = [ 'site.structure.edit', 'site.content.edit', 'site.style.edit', + 'site.branches.manage', ], }, { title: 'Pages', capabilities: ['pages.edit', 'pages.publish'] }, diff --git a/src/admin/shared/BranchSwitcher/BranchChip.tsx b/src/admin/shared/BranchSwitcher/BranchChip.tsx new file mode 100644 index 000000000..159413059 --- /dev/null +++ b/src/admin/shared/BranchSwitcher/BranchChip.tsx @@ -0,0 +1,408 @@ +/** + * BranchChip — the toolbar entry point for branches. + * + * A compact chip next to the site brand (icon only on main, tinted name on a + * branch) opens a palette: search first, then the current branch and the + * others by recency. The palette's footer flips into an in-place creator so + * a new branch is two keystrokes away without leaving the toolbar. Managing + * (rename, delete) lives in a dialog behind "Manage branches…". + */ +import { Suspense, lazy, useEffect, useId, useRef, useState, type FormEvent, type KeyboardEvent } from 'react' +import { createPortal } from 'react-dom' +import { CheckIcon } from 'pixel-art-icons/icons/check' +import { ChevronLeftIcon } from 'pixel-art-icons/icons/chevron-left' +import { CircleDotSolidIcon } from 'pixel-art-icons/icons/circle-dot-solid' +import { EditSolidIcon } from 'pixel-art-icons/icons/edit-solid' +import { GitBranchSolidIcon } from 'pixel-art-icons/icons/git-branch-solid' +import { PlusIcon } from 'pixel-art-icons/icons/plus' +import { MAIN_BRANCH_ID, slugifyBranchName, type SiteBranch } from '@core/branches' +import { getErrorMessage } from '@core/utils/errorMessage' +import { hasCapability } from '@admin/access' +import { useCurrentAdminUser } from '@admin/sessionContext' +import { + createBranch, + refreshBranches, + switchBranch, + useActiveBranch, + useBranchStore, + useBranches, +} from '@admin/state/branchStore' +import { Button } from '@ui/components/Button' +import { + ContextMenu, + ContextMenuItem, + ContextMenuSeparator, + MenuSearchHeader, +} from '@ui/components/ContextMenu' +import { FormField } from '@ui/components/FormField' +import { Input } from '@ui/components/Input' +import { SegmentedControl } from '@ui/components/SegmentedControl' +import { TagPill } from '@ui/components/TagPill' +import { pushToast } from '@ui/components/Toast' +import { cn } from '@ui/cn' +import { branchAccentStyle } from './branchAccent' +import { describeUpdated } from './branchTime' +import styles from './BranchSwitcher.module.css' + +const ManageBranchesDialog = lazy(() => + import('./ManageBranchesDialog').then((m) => ({ default: m.ManageBranchesDialog })), +) + +function branchMeta(branch: SiteBranch): string { + if (branch.id === MAIN_BRANCH_ID) return 'The live site' + const updated = describeUpdated(branch.updatedAt) + return updated ? `from ${branch.baseBranchId ?? MAIN_BRANCH_ID} · ${updated}` : `from ${branch.baseBranchId ?? MAIN_BRANCH_ID}` +} + +function BranchRow({ + branch, + current, + onSelect, +}: { + branch: SiteBranch + current: boolean + onSelect: () => void +}) { + const isMain = branch.id === MAIN_BRANCH_ID + return ( + + + + {branch.name} + {branchMeta(branch)} + + {/* Not an aria-hidden span itself: the menu sizes those as 16px icon slots. */} + + {isMain && + + ) +} + +export function BranchChip() { + const user = useCurrentAdminUser() + const canManage = hasCapability(user, 'site.branches.manage') + const branches = useBranches() + const current = useActiveBranch() + const mode = useBranchStore((state) => state.switcher) + const openSwitcher = useBranchStore((state) => state.openSwitcher) + const closeSwitcher = useBranchStore((state) => state.closeSwitcher) + const manageOpen = useBranchStore((state) => state.manageOpen) + const openManage = useBranchStore((state) => state.openManage) + const closeManage = useBranchStore((state) => state.closeManage) + + const [query, setQuery] = useState('') + const chipRef = useRef(null) + const searchRef = useRef(null) + const menuId = useId() + const currentHeadingId = `${menuId}-current` + const recentHeadingId = `${menuId}-recent` + + const open = mode !== 'closed' + const onMain = current.id === MAIN_BRANCH_ID + + useEffect(() => { + const controller = new AbortController() + refreshBranches(controller.signal).catch((err: unknown) => { + if (controller.signal.aborted) return + console.error('[branches] failed to load branches:', err) + }) + return () => controller.abort() + }, []) + + useEffect(() => { + if (!open) return + refreshBranches().catch((err: unknown) => { + console.error('[branches] failed to refresh branches:', err) + }) + }, [open]) + + useEffect(() => { + if (open && mode === 'list') searchRef.current?.focus() + }, [open, mode]) + + const needle = query.trim().toLowerCase() + const filtered = branches.filter( + (branch) => branch.name.toLowerCase().includes(needle) || branch.id.includes(needle), + ) + const others = branches.filter((branch) => branch.id !== current.id) + + function close(): void { + closeSwitcher() + setQuery('') + // Unmounting the focused search field drops focus to . Hand it + // back to the chip — unless the dismissing click already moved it + // elsewhere, or a switch is remounting the toolbar around the chip. + const chip = chipRef.current + setTimeout(() => { + if (document.activeElement === document.body && chip?.isConnected) chip.focus() + }, 0) + } + + function select(branch: SiteBranch): void { + switchBranch(branch.id) + close() + } + + function startCreate(): void { + openSwitcher('create') + } + + function onSearchKeyDown(event: KeyboardEvent): void { + if (event.key !== 'Enter') return + event.preventDefault() + const first = filtered[0] + if (first) select(first) + else if (canManage && slugifyBranchName(query)) startCreate() + } + + return ( + <> + + + {open && createPortal( + + ) : undefined} + > + {mode === 'list' ? ( + <> + {needle ? ( + <> + {filtered.map((branch) => ( + select(branch)} + /> + ))} + {filtered.length === 0 && (canManage && slugifyBranchName(query) ? ( + + + ) : ( +
No branch matches.
+ ))} + + ) : ( + <> +
+ + +
+ {others.length > 0 && ( +
+ + {others.map((branch) => ( + select(branch)} + /> + ))} +
+ )} + + )} + {canManage && ( + <> + + + + { + close() + openManage() + }} + > + + + )} + + ) : ( + openSwitcher('list')} + onCancel={close} + onCreated={close} + /> + )} +
, + document.body, + )} + + {manageOpen && ( + + + + )} + + ) +} + +interface CreateBranchFormProps { + current: SiteBranch + branches: SiteBranch[] + /** Seeded from the palette search so "Create …" keeps the text. */ + initialName: string + onBack: () => void + onCancel: () => void + onCreated: () => void +} + +/** + * The in-place creator. Mounted only while the palette is in create mode, + * so every open starts fresh: the name from the search (if any), and the + * branch the user is on as the default base — whichever way the mode was + * entered (chip, palette row, or the Spotlight command). + */ +function CreateBranchForm({ current, branches, initialName, onBack, onCancel, onCreated }: CreateBranchFormProps) { + const [name, setName] = useState(initialName) + const [from, setFrom] = useState(current.id) + const [creating, setCreating] = useState(false) + const onMain = current.id === MAIN_BRANCH_ID + const slug = slugifyBranchName(name) + const duplicate = branches.some((branch) => branch.id === slug) + const fromOptions = [ + { value: MAIN_BRANCH_ID, label: 'main' }, + ...(onMain ? [] : [{ value: current.id, label: current.name }]), + ] + + async function submit(event: FormEvent): Promise { + event.preventDefault() + if (!slug || duplicate || creating) return + setCreating(true) + try { + const branch = await createBranch({ name: name.trim(), id: slug, fromBranchId: from }) + onCreated() + pushToast({ + kind: 'success', + title: `Created ${branch.name}`, + body: `You're now editing ${branch.name}. Everything on it stays private until you merge it.`, + }) + } catch (err) { + console.error('[branches] create failed:', err) + pushToast({ kind: 'error', title: 'Could not create branch', body: getErrorMessage(err, 'Unknown branch error') }) + } finally { + setCreating(false) + } + } + + return ( +
+
+ + Create branch +
+ + setName(event.target.value)} + placeholder="spring-redesign" + fieldSize="sm" + monospace + invalid={duplicate} + data-testid="branch-create-name" + /> + +

+ {duplicate + ? `A branch called ${slug} already exists.` + : slug && slug !== name.trim() + ? `Will be created as ${slug}` + : 'Everything on the branch stays private until you merge it.'} +

+ + {fromOptions.length > 1 ? ( + + ) : ( + main, the live site + )} + +
+ + +
+
+ ) +} diff --git a/src/admin/shared/BranchSwitcher/BranchContextStrip.tsx b/src/admin/shared/BranchSwitcher/BranchContextStrip.tsx new file mode 100644 index 000000000..e638aecbd --- /dev/null +++ b/src/admin/shared/BranchSwitcher/BranchContextStrip.tsx @@ -0,0 +1,272 @@ +/** + * BranchContextStrip — the band above the toolbar while a branch is active. + * + * Painted in the workspace surface colour so it reads as part of the canvas + * rather than the toolbar chrome; the branch's identity tint lands on the + * icon and name only. Carries the branch's own actions — everything that is + * ABOUT the branch rather than about the page — and disappears on main. + */ +import { Suspense, lazy, useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { ArrowDownIcon } from 'pixel-art-icons/icons/arrow-down' +import { CircleDotSolidIcon } from 'pixel-art-icons/icons/circle-dot-solid' +import { EditSolidIcon } from 'pixel-art-icons/icons/edit-solid' +import { EyeOffSolidIcon } from 'pixel-art-icons/icons/eye-off-solid' +import { GitBranchSolidIcon } from 'pixel-art-icons/icons/git-branch-solid' +import { GitMergeSolidIcon } from 'pixel-art-icons/icons/git-merge-solid' +import { LinkIcon } from 'pixel-art-icons/icons/link' +import { MoreHorizontalSolidIcon } from 'pixel-art-icons/icons/more-horizontal-solid' +import { ShareSolidIcon } from 'pixel-art-icons/icons/share-solid' +import { TrashSolidIcon } from 'pixel-art-icons/icons/trash-solid' +import { MAIN_BRANCH_ID, type BranchPreview, type MergeDirection, type SiteBranch } from '@core/branches' +import { getCmsBranchPreview, issueCmsBranchPreview, revokeCmsBranchPreview } from '@core/persistence' +import { isAbortError } from '@core/http' +import { getErrorMessage } from '@core/utils/errorMessage' +import { hasCapability } from '@admin/access' +import { useCurrentAdminUser } from '@admin/sessionContext' +import { switchBranch, useActiveBranch, useBranchStore } from '@admin/state/branchStore' +import { Button } from '@ui/components/Button' +import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from '@ui/components/ContextMenu' +import { pushToast } from '@ui/components/Toast' +import { branchAccentStyle } from './branchAccent' +import { describeUpdated } from './branchTime' +import styles from './BranchSwitcher.module.css' + +const DeleteBranchDialog = lazy(() => + import('./DeleteBranchDialog').then((m) => ({ default: m.DeleteBranchDialog })), +) +const MergeBranchDialog = lazy(() => + import('./MergeBranchDialog').then((m) => ({ default: m.MergeBranchDialog })), +) + +async function copyToClipboard(text: string): Promise { + try { + await navigator.clipboard.writeText(text) + return true + } catch { + // Clipboard access is blocked in some contexts; the URL still reaches + // the user through the toast body. + return false + } +} + +export function BranchContextStrip() { + const current = useActiveBranch() + if (current.id === MAIN_BRANCH_ID) return null + // Keyed by branch so every piece of per-branch state (preview link, open + // menus, dialogs) starts fresh on a switch instead of being reset by hand. + return +} + +function BranchStripBody({ branch: current }: { branch: SiteBranch }) { + const user = useCurrentAdminUser() + const canManage = hasCapability(user, 'site.branches.manage') + const openManage = useBranchStore((state) => state.openManage) + const [moreOpen, setMoreOpen] = useState(false) + const [deleting, setDeleting] = useState(false) + const [preview, setPreview] = useState(null) + const [sharing, setSharing] = useState(false) + const [merge, setMerge] = useState(null) + const moreRef = useRef(null) + + useEffect(() => { + const controller = new AbortController() + getCmsBranchPreview(current.id) + .then((state) => { + if (!controller.signal.aborted) setPreview(state) + }) + .catch((err: unknown) => { + if (isAbortError(err) || controller.signal.aborted) return + console.error('[branches] failed to load the preview link state:', err) + }) + return () => controller.abort() + }, [current.id]) + + const updated = describeUpdated(current.updatedAt) + const meta = [`from ${current.baseBranchId ?? MAIN_BRANCH_ID}`, updated].filter(Boolean).join(' · ') + + async function share(): Promise { + if (sharing) return + setSharing(true) + try { + const issued = await issueCmsBranchPreview(current.id) + setPreview(issued.preview) + const copied = await copyToClipboard(issued.url) + pushToast({ + kind: 'success', + title: copied ? 'Preview link copied' : 'Preview link ready', + body: preview + ? `${issued.url} — the previous link no longer works.` + : `${issued.url} — anyone with the link sees this branch's draft.`, + }) + } catch (err) { + console.error('[branches] share preview failed:', err) + pushToast({ kind: 'error', title: 'Could not create the preview link', body: getErrorMessage(err, 'Unknown branch error') }) + } finally { + setSharing(false) + } + } + + async function revoke(): Promise { + try { + await revokeCmsBranchPreview(current.id) + setPreview(null) + pushToast({ kind: 'success', title: 'Preview link revoked', body: 'The shared link no longer opens this branch.' }) + } catch (err) { + console.error('[branches] revoke preview failed:', err) + pushToast({ kind: 'error', title: 'Could not revoke the preview link', body: getErrorMessage(err, 'Unknown branch error') }) + } + } + + return ( +
+
+ ) +} diff --git a/src/admin/shared/BranchSwitcher/BranchSwitcher.module.css b/src/admin/shared/BranchSwitcher/BranchSwitcher.module.css new file mode 100644 index 000000000..21ec135c5 --- /dev/null +++ b/src/admin/shared/BranchSwitcher/BranchSwitcher.module.css @@ -0,0 +1,216 @@ +/* Branch switcher — the toolbar chip, its palette, and the context strip. */ + +/* ── Chip ─────────────────────────────────────────────────────────────────── */ + +/* Doubled selector: beats the Button primitive's icon-only width so the + chip is a true square. */ +.chip.chip { + --branch-accent: var(--accent-1); + width: 28px; + height: 28px; + padding: 0; + margin-right: var(--space-3xs); + border-radius: var(--input-radius); + color: var(--text-subtle); +} + +.chipBranch { + color: var(--branch-accent); + background: color-mix(in oklab, var(--branch-accent) 12%, transparent); +} + +.chipBranch:hover { + background: color-mix(in oklab, var(--branch-accent) 18%, transparent); +} + +/* ── Palette ─────────────────────────────────────────────────────────────── */ + +.group { + padding: var(--space-xs) var(--space-m) var(--space-4xs); + color: var(--text-muted); + font-size: var(--text-2xs); + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.row { + --branch-accent: var(--text-muted); + min-height: 42px; +} + +.rowIcon { + display: inline-flex; + flex-shrink: 0; + color: var(--branch-accent); +} + +.rowMain { + display: flex; + flex: 1; + flex-direction: column; + gap: var(--space-px); + min-width: 0; + text-align: left; +} + +.rowName { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 650; +} + +.rowMeta { + color: var(--text-muted); + font-size: var(--text-2xs); + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rowTrailing { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + flex-shrink: 0; + color: var(--text-muted); +} + +.rowCurrent .rowTrailing { + color: var(--text); +} + +.empty { + padding: var(--space-s) var(--space-m); + color: var(--text-muted); + font-size: var(--text-xs); +} + +.createRow { + color: var(--text); +} + +.code { + padding: 0 var(--space-4xs); + border-radius: var(--radius-sm); + background: var(--bg-surface-3); + font-family: var(--font-mono); + font-size: var(--text-2xs); +} + +/* ── Creator ─────────────────────────────────────────────────────────────── */ + +.createForm { + display: flex; + flex-direction: column; + gap: var(--space-s); + padding: var(--space-xs) var(--space-m) var(--space-m); +} + +.createHeader { + display: flex; + align-items: center; + gap: var(--space-xs); +} + +.createTitle { + color: var(--text); + font-size: var(--text-s); + font-weight: 750; +} + +.hint { + margin: 0; + color: var(--text-muted); + font-size: var(--text-2xs); +} + +.hintError { + color: var(--danger-text); +} + +.fromMain { + color: var(--text-muted); + font-size: var(--text-xs); +} + +.createActions { + display: flex; + justify-content: flex-end; + gap: var(--space-xs); +} + +/* ── Context strip ───────────────────────────────────────────────────────── */ + +/* Painted in the canvas surface colour (the same token CanvasRoot uses), so it + * reads as a band that belongs to the workspace rather than to the toolbar + * chrome. Identity tint stays on the icon and name only. */ +.strip { + --branch-accent: var(--accent-1); + position: relative; + z-index: 29; + display: flex; + align-items: center; + gap: var(--space-xs); + height: 34px; + padding: 0 var(--space-s) 0 var(--space-m); + flex-shrink: 0; + border-bottom: 1px solid var(--border); + background: var(--bg-surface-2); + color: var(--text); + font-size: var(--text-xs); + user-select: none; +} + +.stripIcon { + flex-shrink: 0; + color: var(--branch-accent); +} + +.stripName { + color: var(--branch-accent); + font-weight: 800; + white-space: nowrap; +} + +.stripMeta { + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.stripSpacer { + flex: 1; + min-width: var(--space-s); +} + +@media (max-width: 760px) { + .chipBranch { + max-width: 120px; + } + + .stripMeta { + display: none; + } +} + +.stripLinked { + display: inline-flex; + align-items: center; + gap: var(--space-4xs); + margin-left: var(--space-xs); + color: var(--text-muted); + font-size: var(--text-2xs); + font-weight: 600; + white-space: nowrap; +} + +/* A pill inside a shrink-to-fit flex row: its percentage max-width has no + definite box to resolve against (WebKit collapses the label to one letter), + and nothing here needs it to truncate. */ +.pill { + max-width: none; +} diff --git a/src/admin/shared/BranchSwitcher/DeleteBranchDialog.tsx b/src/admin/shared/BranchSwitcher/DeleteBranchDialog.tsx new file mode 100644 index 000000000..18e5e0077 --- /dev/null +++ b/src/admin/shared/BranchSwitcher/DeleteBranchDialog.tsx @@ -0,0 +1,73 @@ +/** + * Confirm deleting a branch. Deletion discards every unmerged change on the + * branch, so it always confirms (independent of the "confirm before delete" + * editor preference) and runs through step-up — the server re-verifies the + * actor before dropping the rows. + */ +import { useState } from 'react' +import type { SiteBranch } from '@core/branches' +import { getErrorMessage } from '@core/utils/errorMessage' +import { deleteBranch } from '@admin/state/branchStore' +import { StepUpCancelledMessage, useStepUp } from '@admin/shared/StepUp' +import { Button } from '@ui/components/Button' +import { Dialog } from '@ui/components/Dialog' +import { pushToast } from '@ui/components/Toast' + +interface DeleteBranchDialogProps { + branch: SiteBranch + onClose: () => void +} + +export function DeleteBranchDialog({ branch, onClose }: DeleteBranchDialogProps) { + const { runStepUp } = useStepUp() + const [busy, setBusy] = useState(false) + + async function confirmDelete(): Promise { + if (busy) return + setBusy(true) + try { + await runStepUp(() => deleteBranch(branch.id)) + onClose() + pushToast({ kind: 'success', title: `Deleted ${branch.name}` }) + } catch (err) { + if (err instanceof Error && err.message === StepUpCancelledMessage) return + console.error('[branches] delete failed:', err) + pushToast({ kind: 'error', title: 'Could not delete branch', body: getErrorMessage(err, 'Unknown branch error') }) + } finally { + setBusy(false) + } + } + + return ( + + + + + )} + > +

+ Every change made on {branch.name} that has not been merged into main is + discarded. Preview links for the branch stop working. This cannot be undone. +

+
+ ) +} diff --git a/src/admin/shared/BranchSwitcher/ManageBranchesDialog.module.css b/src/admin/shared/BranchSwitcher/ManageBranchesDialog.module.css new file mode 100644 index 000000000..ba279f481 --- /dev/null +++ b/src/admin/shared/BranchSwitcher/ManageBranchesDialog.module.css @@ -0,0 +1,121 @@ +/* Manage branches dialog — one row per branch. */ + +.list { + display: flex; + flex-direction: column; + gap: var(--space-4xs); + margin: 0; + padding: 0; + list-style: none; +} + +.row { + --branch-accent: var(--text-muted); + display: flex; + align-items: center; + gap: var(--space-s); + min-height: 44px; + padding: var(--space-xs) var(--space-s); + border-radius: var(--radius); + background: var(--bg-surface-2); + color: var(--text); + font-size: var(--text-xs); +} + +.rowCurrent { + background: var(--bg-surface-3); +} + +.rowCreate { + border: 1px dashed var(--border); + background: transparent; +} + +.icon { + display: inline-flex; + flex-shrink: 0; + color: var(--branch-accent); +} + +.main { + display: flex; + flex: 1; + flex-direction: column; + gap: var(--space-px); + min-width: 0; +} + +.name { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + min-width: 0; + font-weight: 650; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.id { + padding: 0 var(--space-4xs); + border-radius: var(--radius-sm); + background: var(--bg-surface-3); + color: var(--text-muted); + font-family: var(--font-mono); + font-size: var(--text-2xs); + font-weight: 500; +} + +.meta { + color: var(--text-muted); + font-size: var(--text-2xs); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.actions { + display: inline-flex; + align-items: center; + gap: var(--space-4xs); + flex-shrink: 0; +} + +.createForm, +.renameForm { + display: flex; + flex: 1; + align-items: center; + gap: var(--space-xs); + min-width: 0; +} + +.createForm > :nth-child(2), +.renameForm > :first-child { + flex: 1; + min-width: 0; +} + +.createFrom { + color: var(--text-muted); + font-size: var(--text-2xs); + white-space: nowrap; +} + +/* A pill inside a shrink-to-fit flex row: its percentage max-width has no + definite box to resolve against (WebKit collapses the label to one letter), + and nothing here needs it to truncate. */ +.pill { + max-width: none; +} + +.search { + margin-bottom: var(--space-s); +} + +.empty { + padding: var(--space-m); + color: var(--text-muted); + font-size: var(--text-xs); + text-align: center; +} diff --git a/src/admin/shared/BranchSwitcher/ManageBranchesDialog.tsx b/src/admin/shared/BranchSwitcher/ManageBranchesDialog.tsx new file mode 100644 index 000000000..75572a9a2 --- /dev/null +++ b/src/admin/shared/BranchSwitcher/ManageBranchesDialog.tsx @@ -0,0 +1,312 @@ +/** + * ManageBranchesDialog — every branch in one list: open, rename inline, + * delete, or create a new one. The chip's palette is for switching fast; + * this is where housekeeping happens. + */ +import { useState, type FormEvent } from 'react' +import { CheckIcon } from 'pixel-art-icons/icons/check' +import { CircleDotSolidIcon } from 'pixel-art-icons/icons/circle-dot-solid' +import { CloseIcon } from 'pixel-art-icons/icons/close' +import { EditSolidIcon } from 'pixel-art-icons/icons/edit-solid' +import { GitBranchSolidIcon } from 'pixel-art-icons/icons/git-branch-solid' +import { PlusIcon } from 'pixel-art-icons/icons/plus' +import { TrashSolidIcon } from 'pixel-art-icons/icons/trash-solid' +import { MAIN_BRANCH_ID, slugifyBranchName, type SiteBranch } from '@core/branches' +import { getErrorMessage } from '@core/utils/errorMessage' +import { + createBranch, + renameBranch, + switchBranch, + useActiveBranch, + useBranchStore, + useBranches, +} from '@admin/state/branchStore' +import { useAdminUi } from '@admin/state/adminUi' +import { Button } from '@ui/components/Button' +import { Dialog } from '@ui/components/Dialog' +import { Input } from '@ui/components/Input' +import { SearchBar } from '@ui/components/SearchBar' +import { TagPill } from '@ui/components/TagPill' +import { pushToast } from '@ui/components/Toast' +import { cn } from '@ui/cn' +import { branchAccentStyle } from './branchAccent' +import { describeUpdated } from './branchTime' +import { DeleteBranchDialog } from './DeleteBranchDialog' +import styles from './ManageBranchesDialog.module.css' + +interface ManageBranchesDialogProps { + open: boolean + onClose: () => void +} + +export function ManageBranchesDialog({ open, onClose }: ManageBranchesDialogProps) { + const siteName = useAdminUi((state) => state.siteName) + const branches = useBranches() + const current = useActiveBranch() + + const [creating, setCreating] = useState(false) + const [newName, setNewName] = useState('') + const [submitting, setSubmitting] = useState(false) + // The strip's "Rename…" opens the dialog straight into the current row. + const initialRenamingId = useBranchStore.getState().manageRenamingId + const [renamingId, setRenamingId] = useState(initialRenamingId) + const [renameDraft, setRenameDraft] = useState( + () => branches.find((branch) => branch.id === initialRenamingId)?.name ?? '', + ) + const [deleting, setDeleting] = useState(null) + const [query, setQuery] = useState('') + const needle = query.trim().toLowerCase() + const visible = needle + ? branches.filter((branch) => branch.name.toLowerCase().includes(needle) || branch.id.includes(needle)) + : branches + + const newSlug = slugifyBranchName(newName) + const newSlugTaken = branches.some((branch) => branch.id === newSlug) + + function close(): void { + setCreating(false) + setNewName('') + setRenamingId(null) + onClose() + } + + async function submitCreate(event: FormEvent): Promise { + event.preventDefault() + if (!newSlug || newSlugTaken || submitting) return + setSubmitting(true) + try { + const branch = await createBranch({ name: newName.trim(), id: newSlug, fromBranchId: current.id }) + setCreating(false) + setNewName('') + pushToast({ kind: 'success', title: `Created ${branch.name}`, body: `You're now editing ${branch.name}.` }) + } catch (err) { + console.error('[branches] create failed:', err) + pushToast({ kind: 'error', title: 'Could not create branch', body: getErrorMessage(err, 'Unknown branch error') }) + } finally { + setSubmitting(false) + } + } + + async function submitRename(event: FormEvent, branch: SiteBranch): Promise { + event.preventDefault() + const name = renameDraft.trim() + if (!name || name === branch.name) { + setRenamingId(null) + return + } + try { + await renameBranch(branch.id, name) + setRenamingId(null) + } catch (err) { + console.error('[branches] rename failed:', err) + pushToast({ kind: 'error', title: 'Could not rename branch', body: getErrorMessage(err, 'Unknown branch error') }) + } + } + + return ( + <> + + + + + )} + > + +
    + {creating && ( +
  • +
    { void submitCreate(event) }}> + + setNewName(event.target.value)} + placeholder="new-branch-name" + fieldSize="xs" + monospace + invalid={newSlugTaken} + aria-label="New branch name" + data-testid="branch-manage-new-name" + onKeyDown={(event) => { + if (event.key === 'Escape') { + // Consumed here: the dialog's own Escape (and any other + // document-level Escape) must not see it. + event.preventDefault() + event.stopPropagation() + setCreating(false) + } + }} + /> + + from {current.name} + + + +
    +
  • + )} + + {visible.length === 0 && ( +
  • No branch matches.
  • + )} + {visible.map((branch) => { + const isMain = branch.id === MAIN_BRANCH_ID + const isCurrent = branch.id === current.id + const renaming = renamingId === branch.id + const updated = describeUpdated(branch.updatedAt) + return ( +
  • + + + {renaming ? ( +
    { void submitRename(event, branch) }}> + setRenameDraft(event.target.value)} + fieldSize="xs" + aria-label={`Rename ${branch.name}`} + data-testid="branch-manage-rename-input" + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + setRenamingId(null) + } + }} + /> + + +
    + ) : ( + + + {branch.name} + {branch.id !== branch.name && {branch.id}} + + + {isMain + ? 'The live site' + : [`from ${branch.baseBranchId ?? MAIN_BRANCH_ID}`, updated].filter(Boolean).join(' · ')} + + + )} + + {!renaming && ( + + {isCurrent ? ( + + ) : ( + + )} + {!isMain && ( + <> + + + + )} + + )} +
  • + ) + })} +
+
+ + {deleting && setDeleting(null)} />} + + ) +} diff --git a/src/admin/shared/BranchSwitcher/MergeBranchDialog.module.css b/src/admin/shared/BranchSwitcher/MergeBranchDialog.module.css new file mode 100644 index 000000000..e852e63a4 --- /dev/null +++ b/src/admin/shared/BranchSwitcher/MergeBranchDialog.module.css @@ -0,0 +1,101 @@ +/* Merge / update review dialog. */ + +.loading { + display: flex; + flex-direction: column; + gap: var(--space-s); + padding: var(--space-xs) 0; +} + +.error, +.empty, +.summary { + margin: 0 0 var(--space-m); + color: var(--text); + font-size: var(--text-xs); + line-height: 1.5; +} + +.error { + color: var(--danger-text); +} + +.empty { + color: var(--text-muted); +} + +.group { + margin-bottom: var(--space-m); +} + +.groupTitle { + margin: 0 0 var(--space-xs); + color: var(--text-muted); + font-size: var(--text-2xs); + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.list { + display: flex; + flex-direction: column; + gap: var(--space-4xs); + margin: 0; + padding: 0; + list-style: none; +} + +.row { + display: flex; + align-items: center; + gap: var(--space-s); + min-height: 40px; + padding: var(--space-xs) var(--space-s); + border-radius: var(--radius); + background: var(--bg-surface-2); + color: var(--text); + font-size: var(--text-xs); +} + +.rowConflict { + background: color-mix(in oklab, var(--warning) 10%, var(--bg-surface-2)); +} + +/* Fixed action column so the labels line up whatever the pill says. */ +.action { + display: inline-flex; + flex: 0 0 72px; +} + +.main { + display: flex; + flex: 1; + flex-direction: column; + gap: var(--space-px); + min-width: 0; +} + +.label { + font-weight: 650; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.conflict { + color: var(--warning); + font-size: var(--text-2xs); +} + +.deleteToggle { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + color: var(--text); + font-size: var(--text-xs); +} + +.footerSpacer { + flex: 1; +} diff --git a/src/admin/shared/BranchSwitcher/MergeBranchDialog.tsx b/src/admin/shared/BranchSwitcher/MergeBranchDialog.tsx new file mode 100644 index 000000000..a396c61f6 --- /dev/null +++ b/src/admin/shared/BranchSwitcher/MergeBranchDialog.tsx @@ -0,0 +1,250 @@ +/** + * MergeBranchDialog — review what a merge (branch → main) or an update + * (main → branch) will change, decide every conflict, then apply. + * + * The plan comes from the server; the dialog never guesses. A change with + * conflicts renders a two-way choice — keep this side or take the other — + * and the primary action stays disabled until every conflict has a + * decision. The server re-plans on apply, so a change that landed after + * the reviewer looked surfaces as a fresh conflict instead of being + * applied unseen. + */ +import { useEffect, useState } from 'react' +import { ArrowDownIcon } from 'pixel-art-icons/icons/arrow-down' +import { GitMergeSolidIcon } from 'pixel-art-icons/icons/git-merge-solid' +import { + type MergeChange, + type MergeDirection, + type MergePlan, + type MergeResolution, + type SiteBranch, +} from '@core/branches' +import { isAbortError } from '@core/http' +import { getCmsBranchMergePlan } from '@core/persistence' +import { getErrorMessage } from '@core/utils/errorMessage' +import { mergeBranch } from '@admin/state/branchStore' +import { StepUpCancelledMessage, useStepUp } from '@admin/shared/StepUp' +import { Button } from '@ui/components/Button' +import { Dialog } from '@ui/components/Dialog' +import { SegmentedControl } from '@ui/components/SegmentedControl' +import { Skeleton } from '@ui/components/Skeleton' +import { Switch } from '@ui/components/Switch' +import { TagPill } from '@ui/components/TagPill' +import { pushToast } from '@ui/components/Toast' +import { cn } from '@ui/cn' +import styles from './MergeBranchDialog.module.css' + +interface MergeBranchDialogProps { + branch: SiteBranch + direction: MergeDirection + onClose: () => void +} + +const ACTION_LABEL: Record = { + create: 'New', + update: 'Changed', + delete: 'Removed', +} + +function groupLabel(change: MergeChange): string { + if (change.kind === 'site') return 'Site' + if (change.kind === 'table') return 'Tables' + return change.tableName ? `${change.tableName} entries` : 'Entries' +} + +function groupChanges(changes: MergeChange[]): Array<{ label: string; changes: MergeChange[] }> { + const groups = new Map() + for (const change of changes) { + const label = groupLabel(change) + groups.set(label, [...(groups.get(label) ?? []), change]) + } + return [...groups.entries()].map(([label, entries]) => ({ label, changes: entries })) +} + +function describeConflicts(conflicts: string[]): string { + if (conflicts.includes('(deleted)')) return 'Deleted on one side, changed on the other' + const fields = conflicts.slice(0, 3).join(', ') + return conflicts.length > 3 ? `Both sides changed ${fields} and ${conflicts.length - 3} more` : `Both sides changed ${fields}` +} + +export function MergeBranchDialog({ branch, direction, onClose }: MergeBranchDialogProps) { + const { runStepUp } = useStepUp() + const [plan, setPlan] = useState(null) + const [loadError, setLoadError] = useState(null) + const [resolutions, setResolutions] = useState>({}) + const [deleteAfter, setDeleteAfter] = useState(direction === 'merge') + const [busy, setBusy] = useState(false) + + const isMerge = direction === 'merge' + const title = isMerge ? `Merge ${branch.name} into main` : `Update ${branch.name} from main` + const intoLabel = isMerge ? 'Keep main' : 'Keep branch' + const fromLabel = isMerge ? 'Take branch' : 'Take main' + + // Mounted fresh per open (keyed by direction in the strip), so the plan + // state starts empty and needs no reset here. + useEffect(() => { + const controller = new AbortController() + getCmsBranchMergePlan(branch.id, direction) + .then((next) => { + if (!controller.signal.aborted) setPlan(next) + }) + .catch((err: unknown) => { + if (isAbortError(err) || controller.signal.aborted) return + console.error('[branches] merge plan failed:', err) + setLoadError(getErrorMessage(err, 'Could not compare the branches')) + }) + return () => controller.abort() + }, [branch.id, direction]) + + const unresolved = plan + ? plan.changes.filter((change) => change.conflicts.length > 0 && !resolutions[change.key]).length + : 0 + const total = plan?.changes.length ?? 0 + + async function apply(): Promise { + if (!plan || busy || unresolved > 0) return + setBusy(true) + try { + const result = await runStepUp(() => + mergeBranch(branch.id, direction, { resolutions, deleteBranch: isMerge && deleteAfter }), + ) + onClose() + const count = result.plan.changes.length + pushToast({ + kind: 'success', + title: isMerge ? `Merged ${branch.name} into main` : `Updated ${branch.name} from main`, + body: isMerge + ? `${count} change${count === 1 ? '' : 's'} landed in main's draft. Publish when you're ready.${result.branchDeleted ? ' The branch was deleted.' : ''}` + : `${count} change${count === 1 ? '' : 's'} from main now on the branch.`, + }) + } catch (err) { + if (err instanceof Error && err.message === StepUpCancelledMessage) return + console.error('[branches] merge failed:', err) + pushToast({ + kind: 'error', + title: isMerge ? 'Merge failed' : 'Update failed', + body: getErrorMessage(err, 'Unknown merge error'), + }) + // A conflict that appeared after the plan was loaded: reload it. + getCmsBranchMergePlan(branch.id, direction).then(setPlan).catch(() => undefined) + } finally { + setBusy(false) + } + } + + return ( + + {isMerge && ( + + )} + + ) +} diff --git a/src/admin/shared/BranchSwitcher/branchAccent.ts b/src/admin/shared/BranchSwitcher/branchAccent.ts new file mode 100644 index 000000000..573441746 --- /dev/null +++ b/src/admin/shared/BranchSwitcher/branchAccent.ts @@ -0,0 +1,14 @@ +/** + * Identity tint per branch. Main stays achromatic; every other branch gets a + * deterministic accent from the shared pill palette, exposed as the + * `--branch-accent` custom property the switcher stylesheet reads. Color is + * identity here, never decoration — it lands on the icon and the name only. + */ +import type { CSSProperties } from 'react' +import { MAIN_BRANCH_ID, type SiteBranch } from '@core/branches' +import { pillAccent, pillAccentVar } from '@ui/pillAccent' + +export function branchAccentStyle(branch: Pick): CSSProperties | undefined { + if (branch.id === MAIN_BRANCH_ID) return undefined + return { '--branch-accent': pillAccentVar(pillAccent(branch.id)) } as CSSProperties +} diff --git a/src/admin/shared/BranchSwitcher/branchTime.ts b/src/admin/shared/BranchSwitcher/branchTime.ts new file mode 100644 index 000000000..0083ca602 --- /dev/null +++ b/src/admin/shared/BranchSwitcher/branchTime.ts @@ -0,0 +1,11 @@ +import { formatRelativeTime } from '@core/utils/relativeTime' + +/** "updated just now" / "updated 3h ago" / "updated 12/03/2026". */ +export function describeUpdated(isoTimestamp: string): string { + const ms = Date.parse(isoTimestamp) + if (Number.isNaN(ms) || ms <= 0) return '' + const relative = formatRelativeTime(ms) + if (relative === 'now') return 'updated just now' + if (/^\d+[mhd]$/.test(relative)) return `updated ${relative} ago` + return `updated ${relative}` +} diff --git a/src/admin/shared/BranchSwitcher/index.ts b/src/admin/shared/BranchSwitcher/index.ts new file mode 100644 index 000000000..6b81d8f6a --- /dev/null +++ b/src/admin/shared/BranchSwitcher/index.ts @@ -0,0 +1,3 @@ +export { BranchChip } from './BranchChip' +export { BranchContextStrip } from './BranchContextStrip' +export { ManageBranchesDialog } from './ManageBranchesDialog' diff --git a/src/admin/shared/CapabilityPicker/capabilityMeta.ts b/src/admin/shared/CapabilityPicker/capabilityMeta.ts index 56eb239b6..25fe60e89 100644 --- a/src/admin/shared/CapabilityPicker/capabilityMeta.ts +++ b/src/admin/shared/CapabilityPicker/capabilityMeta.ts @@ -37,6 +37,10 @@ export const CAPABILITY_META: Record = { label: 'Edit site styles', description: 'Modify CSS classes, style overrides, breakpoints, and framework tokens.', }, + 'site.branches.manage': { + label: 'Manage branches', + description: 'Create, rename, delete, merge, and update site branches, and share their preview links.', + }, 'pages.edit': { label: 'Edit pages', description: 'Edit page metadata such as title, slug, and SEO fields.', diff --git a/src/admin/shared/VersionHistoryDialog/VersionHistoryDialog.module.css b/src/admin/shared/VersionHistoryDialog/VersionHistoryDialog.module.css new file mode 100644 index 000000000..bfae51a9a --- /dev/null +++ b/src/admin/shared/VersionHistoryDialog/VersionHistoryDialog.module.css @@ -0,0 +1,86 @@ +/* Version history dialog. */ + +.loading { + display: flex; + flex-direction: column; + gap: var(--space-s); + padding: var(--space-xs) 0; +} + +.error, +.empty { + margin: 0; + color: var(--text-muted); + font-size: var(--text-xs); + line-height: 1.5; +} + +.error { + color: var(--danger-text); +} + +.list { + display: flex; + flex-direction: column; + gap: var(--space-4xs); + margin: 0; + padding: 0; + list-style: none; +} + +.row { + display: flex; + align-items: center; + gap: var(--space-s); + min-height: 44px; + padding: var(--space-xs) var(--space-s); + border-radius: var(--radius); + background: var(--bg-surface-2); + color: var(--text); + font-size: var(--text-xs); +} + +.rowConfirm { + background: color-mix(in oklab, var(--warning) 10%, var(--bg-surface-2)); +} + +.icon { + display: inline-flex; + flex-shrink: 0; + color: var(--text-muted); +} + +.main { + display: flex; + flex: 1; + flex-direction: column; + gap: var(--space-px); + min-width: 0; +} + +.label { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + font-weight: 650; +} + +.meta { + color: var(--text-muted); + font-size: var(--text-2xs); + line-height: 1.4; +} + +.actions { + display: inline-flex; + align-items: center; + gap: var(--space-4xs); + flex-shrink: 0; +} + +/* A pill inside a shrink-to-fit flex row: its percentage max-width has no + definite box to resolve against (WebKit collapses the label to one letter), + and nothing here needs it to truncate. */ +.pill { + max-width: none; +} diff --git a/src/admin/shared/VersionHistoryDialog/VersionHistoryDialog.tsx b/src/admin/shared/VersionHistoryDialog/VersionHistoryDialog.tsx new file mode 100644 index 000000000..d354b13af --- /dev/null +++ b/src/admin/shared/VersionHistoryDialog/VersionHistoryDialog.tsx @@ -0,0 +1,162 @@ +/** + * VersionHistoryDialog — every published version of a row, newest first, + * with "Restore" copying a version's content back into the draft on the + * active branch. Restoring never publishes: the draft still goes live + * through Publish (main) or a merge (branch). + */ +import { useEffect, useState } from 'react' +import { ArchiveRestoreSolidIcon } from 'pixel-art-icons/icons/archive-restore-solid' +import type { DataRow, DataRowVersionSummary } from '@core/data/schemas' +import { isAbortError } from '@core/http' +import { listCmsDataRowVersions, restoreCmsDataRowVersion } from '@core/persistence' +import { getErrorMessage } from '@core/utils/errorMessage' +import { formatRelativeTime } from '@core/utils/relativeTime' +import { Button } from '@ui/components/Button' +import { Dialog } from '@ui/components/Dialog' +import { Skeleton } from '@ui/components/Skeleton' +import { TagPill } from '@ui/components/TagPill' +import { pushToast } from '@ui/components/Toast' +import { cn } from '@ui/cn' +import styles from './VersionHistoryDialog.module.css' + +interface VersionHistoryDialogProps { + rowId: string + /** "page", "post", … — used in copy. */ + entityLabel: string + /** The row's current title, for the dialog eyebrow. */ + title?: string | null + onClose: () => void + /** Fires with the restored draft row. */ + onRestored?: (row: DataRow) => void +} + +function publishedLabel(version: DataRowVersionSummary): string { + const when = new Date(version.publishedAt) + const relative = formatRelativeTime(when.getTime()) + const absolute = Number.isNaN(when.getTime()) ? '' : when.toLocaleString() + const by = version.publishedByName ? ` by ${version.publishedByName}` : '' + // formatRelativeTime returns "now", "5m" / "3h" / "2d", or a plain date + // once older than a week — only the middle form takes "ago". + const when2 = relative === 'now' ? 'just now' : /^\d+[mhd]$/.test(relative) ? `${relative} ago` : relative + return `Published ${when2}${by}${absolute ? ` · ${absolute}` : ''}` +} + +export function VersionHistoryDialog({ rowId, entityLabel, title, onClose, onRestored }: VersionHistoryDialogProps) { + const [versions, setVersions] = useState(null) + const [loadError, setLoadError] = useState(null) + const [confirming, setConfirming] = useState(null) + const [restoring, setRestoring] = useState(null) + + useEffect(() => { + const controller = new AbortController() + listCmsDataRowVersions(rowId, controller.signal) + .then((next) => { + if (!controller.signal.aborted) setVersions(next) + }) + .catch((err: unknown) => { + if (isAbortError(err) || controller.signal.aborted) return + console.error('[versions] failed to load version history:', err) + setLoadError(getErrorMessage(err, 'Could not load version history')) + }) + return () => controller.abort() + }, [rowId]) + + async function restore(version: DataRowVersionSummary): Promise { + if (restoring) return + setRestoring(version.id) + try { + const row = await restoreCmsDataRowVersion(rowId, version.id) + onRestored?.(row) + onClose() + pushToast({ + kind: 'success', + title: `Restored version ${version.versionNumber}`, + body: `The ${entityLabel} draft now matches version ${version.versionNumber}. Publish or merge when you're ready.`, + }) + } catch (err) { + console.error('[versions] restore failed:', err) + pushToast({ kind: 'error', title: 'Could not restore the version', body: getErrorMessage(err, 'Unknown version error') }) + } finally { + setRestoring(null) + setConfirming(null) + } + } + + return ( + + Done + + )} + > + {loadError ? ( +

{loadError}

+ ) : !versions ? ( +
+ + +
+ ) : versions.length === 0 ? ( +

+ This {entityLabel} has not been published yet, so there is nothing to restore. +

+ ) : ( +
    + {versions.map((version, index) => { + const isLatest = index === 0 + const isConfirming = confirming === version.id + return ( +
  • + + + + Version {version.versionNumber} + {isLatest && } + + + {isConfirming + ? `Replace the current draft with version ${version.versionNumber}? Unpublished edits on the draft are lost.` + : publishedLabel(version)} + + + {isConfirming ? ( + + + + + ) : ( + + )} +
  • + ) + })} +
+ )} +
+ ) +} diff --git a/src/admin/shared/VersionHistoryDialog/index.ts b/src/admin/shared/VersionHistoryDialog/index.ts new file mode 100644 index 000000000..9cbc17bd7 --- /dev/null +++ b/src/admin/shared/VersionHistoryDialog/index.ts @@ -0,0 +1 @@ +export { VersionHistoryDialog } from './VersionHistoryDialog' diff --git a/src/admin/spotlight/SpotlightResults.tsx b/src/admin/spotlight/SpotlightResults.tsx index 539a6badd..a11814dfd 100644 --- a/src/admin/spotlight/SpotlightResults.tsx +++ b/src/admin/spotlight/SpotlightResults.tsx @@ -55,6 +55,7 @@ const GROUP_LABELS: Record = { account: 'Account', settings: 'Settings', preview: 'Preview', + branches: 'Branches', ai: 'AI Assistant', help: 'Help', recent: 'Recent', diff --git a/src/admin/spotlight/SpotlightRow.tsx b/src/admin/spotlight/SpotlightRow.tsx index 4e35949ae..17f7a9e8a 100644 --- a/src/admin/spotlight/SpotlightRow.tsx +++ b/src/admin/spotlight/SpotlightRow.tsx @@ -39,6 +39,8 @@ import { ArrowUpIcon } from 'pixel-art-icons/icons/arrow-up' import { ArrowDownIcon } from 'pixel-art-icons/icons/arrow-down' import { PlusIcon } from 'pixel-art-icons/icons/plus' import { EditSolidIcon } from 'pixel-art-icons/icons/edit-solid' +import { GitBranchSolidIcon } from 'pixel-art-icons/icons/git-branch-solid' +import { CircleDotSolidIcon } from 'pixel-art-icons/icons/circle-dot-solid' import { ContainerSolidIcon } from 'pixel-art-icons/icons/container-solid' import { BoxSolidIcon } from 'pixel-art-icons/icons/box-solid' import { BoxStackSolidIcon } from 'pixel-art-icons/icons/box-stack-solid' @@ -101,6 +103,8 @@ const ICON_MAP: Record = { 'arrow-down': ArrowDownIcon, 'plus': PlusIcon, 'edit-solid': EditSolidIcon, + 'git-branch-solid': GitBranchSolidIcon, + 'circle-dot-solid': CircleDotSolidIcon, 'container-solid': ContainerSolidIcon, 'box-solid': BoxSolidIcon, 'box-stack-solid': BoxStackSolidIcon, diff --git a/src/admin/spotlight/builtinCommands.ts b/src/admin/spotlight/builtinCommands.ts index 0a3b756ea..abe13eb69 100644 --- a/src/admin/spotlight/builtinCommands.ts +++ b/src/admin/spotlight/builtinCommands.ts @@ -38,6 +38,7 @@ import { getAiAssistantCommands } from './commands/aiAssistant' import { getImportHtmlCommands } from './commands/importHtml' import { getSiteImportCommands } from './commands/siteImport' import { getSiteExportCommands } from './commands/siteExport' +import { getBranchesCommands } from './commands/branches' /** * Module-level cache of the STATIC built-in command list. Each @@ -84,6 +85,7 @@ export function getAllCommands(): Command[] { ...getImportHtmlCommands(), ...getSiteImportCommands(), ...getSiteExportCommands(), + ...getBranchesCommands(), ...getAiAssistantCommands(), ...getHelpCommands(), ] diff --git a/src/admin/spotlight/commands/branches.ts b/src/admin/spotlight/commands/branches.ts new file mode 100644 index 000000000..6793b6fd4 --- /dev/null +++ b/src/admin/spotlight/commands/branches.ts @@ -0,0 +1,72 @@ +/** + * Branch commands — switch, create, manage. Available on every workspace: + * the active branch is a property of the tab, not of a section. + * + * Switching to a specific branch by name is a provider (`branchesProvider`) + * so typing a branch name in the root palette finds it directly. + */ +import { MAIN_BRANCH_ID } from '@core/branches' +import { isOnMainBranch, switchBranch, useBranchStore } from '@admin/state/branchStore' +import type { Command } from '../types' + +export function getBranchesCommands(): Command[] { + return [ + { + id: 'branches.switch', + title: 'Switch branch…', + subtitle: 'Open the branch switcher', + group: 'branches', + iconName: 'git-branch-solid', + keywords: ['branch', 'switch', 'checkout', 'workspace'], + workspaces: ['any'], + capability: 'site.read', + run: (ctx) => { + ctx.closeSpotlight() + useBranchStore.getState().openSwitcher('list') + }, + }, + { + id: 'branches.create', + title: 'Create branch…', + subtitle: 'Fork the current branch into a private copy', + group: 'branches', + iconName: 'git-branch-solid', + keywords: ['branch', 'create', 'new', 'fork'], + workspaces: ['any'], + capability: 'site.branches.manage', + run: (ctx) => { + ctx.closeSpotlight() + useBranchStore.getState().openSwitcher('create') + }, + }, + { + id: 'branches.switchMain', + title: 'Switch to main', + subtitle: 'Back to the live site', + group: 'branches', + iconName: 'circle-dot-solid', + keywords: ['branch', 'main', 'live', 'switch'], + workspaces: ['any'], + capability: 'site.read', + when: () => !isOnMainBranch(), + run: (ctx) => { + ctx.closeSpotlight() + switchBranch(MAIN_BRANCH_ID) + }, + }, + { + id: 'branches.manage', + title: 'Manage branches…', + subtitle: 'Rename or delete branches', + group: 'branches', + iconName: 'edit-solid', + keywords: ['branch', 'manage', 'rename', 'delete'], + workspaces: ['any'], + capability: 'site.branches.manage', + run: (ctx) => { + ctx.closeSpotlight() + useBranchStore.getState().openManage() + }, + }, + ] +} diff --git a/src/admin/spotlight/commands/editor.ts b/src/admin/spotlight/commands/editor.ts index c45450a48..51d905bc3 100644 --- a/src/admin/spotlight/commands/editor.ts +++ b/src/admin/spotlight/commands/editor.ts @@ -14,6 +14,7 @@ import { StepUpCancelledMessage } from '@admin/shared/StepUp' import { publishCmsDraft } from '@core/persistence' +import { isOnMainBranch } from '@admin/state/branchStore' import type { Command } from '../types' /** Mirrors `SITE_WRITE_CAPABILITIES` — any holder can save a draft. */ @@ -34,6 +35,8 @@ export function getEditorCommands(): Command[] { keywords: ['publish', 'deploy', 'live', 'production'], workspaces: ['site'], capability: 'pages.publish', + // Publishing only exists on main; on a branch the palette hides it. + when: () => isOnMainBranch(), run: async (ctx) => { ctx.closeSpotlight() try { diff --git a/src/admin/spotlight/groupAccent.ts b/src/admin/spotlight/groupAccent.ts index f7a47d835..8b8dc3a08 100644 --- a/src/admin/spotlight/groupAccent.ts +++ b/src/admin/spotlight/groupAccent.ts @@ -37,6 +37,7 @@ const GROUP_ACCENT: Record = { account: 'peach', settings: 'lilac', preview: 'cyan', + branches: 'mint', ai: 'violet', help: 'gold', recent: 'rose', diff --git a/src/admin/spotlight/matcher.ts b/src/admin/spotlight/matcher.ts index e36e654cd..31bb60234 100644 --- a/src/admin/spotlight/matcher.ts +++ b/src/admin/spotlight/matcher.ts @@ -37,6 +37,7 @@ const GROUP_ORDER: CommandGroup[] = [ 'account', 'settings', 'preview', + 'branches', 'ai', 'help', 'results', diff --git a/src/admin/spotlight/providers/branchesProvider.ts b/src/admin/spotlight/providers/branchesProvider.ts new file mode 100644 index 000000000..86b32fc9b --- /dev/null +++ b/src/admin/spotlight/providers/branchesProvider.ts @@ -0,0 +1,40 @@ +/** + * Branches provider — "Switch to " rows for branch names typed in + * the root palette. LOCAL: reads the branch store synchronously (the toolbar + * chip keeps the registry fresh), no HTTP, no debounce. + */ +import { switchBranch, useBranchStore } from '@admin/state/branchStore' +import type { Command, SpotlightProvider } from '../types' + +const MAX_RESULTS = 25 + +export const branchesProvider: SpotlightProvider = { + id: 'branches', + label: 'Branches', + debounceMs: 0, + + search(query, _ctx, signal): Command[] { + if (signal.aborted) return [] + const q = query.trim().toLowerCase() + if (!q) return [] + const { branches, activeBranchId } = useBranchStore.getState() + return branches + .filter((branch) => branch.id !== activeBranchId) + .filter((branch) => branch.name.toLowerCase().includes(q) || branch.id.includes(q)) + .slice(0, MAX_RESULTS) + .map((branch): Command => ({ + id: `branch:${branch.id}`, + title: `Switch to ${branch.name}`, + subtitle: branch.baseBranchId ? `Branch from ${branch.baseBranchId}` : 'The live site', + group: 'branches', + iconName: branch.baseBranchId ? 'git-branch-solid' : 'circle-dot-solid', + keywords: ['branch', 'switch', branch.id], + workspaces: ['any'], + capability: 'site.read', + run: (ctx) => { + ctx.closeSpotlight() + switchBranch(branch.id) + }, + })) + }, +} diff --git a/src/admin/spotlight/scopes/rootScope.ts b/src/admin/spotlight/scopes/rootScope.ts index 432d117bc..baaf4dbaf 100644 --- a/src/admin/spotlight/scopes/rootScope.ts +++ b/src/admin/spotlight/scopes/rootScope.ts @@ -16,6 +16,7 @@ import { contentProvider } from '../providers/contentProvider' import { mediaProvider } from '../providers/mediaProvider' import { dataProvider } from '../providers/dataProvider' import { pluginPagesProvider } from '../providers/pluginPagesProvider' +import { branchesProvider } from '../providers/branchesProvider' export const rootScope: Scope = { id: 'root', @@ -27,5 +28,6 @@ export const rootScope: Scope = { mediaProvider, dataProvider, pluginPagesProvider, + branchesProvider, ], } diff --git a/src/admin/spotlight/types.ts b/src/admin/spotlight/types.ts index 2dd66b2bc..155147831 100644 --- a/src/admin/spotlight/types.ts +++ b/src/admin/spotlight/types.ts @@ -27,6 +27,7 @@ export type CommandGroup = | 'account' | 'settings' | 'preview' + | 'branches' | 'ai' | 'help' | 'recent' // synthetic — only when query is empty diff --git a/src/admin/state/activeBranch.ts b/src/admin/state/activeBranch.ts new file mode 100644 index 000000000..35a6918b4 --- /dev/null +++ b/src/admin/state/activeBranch.ts @@ -0,0 +1,70 @@ +/** + * The active branch id — the one piece of branch state the admin ENTRY needs + * before anything else loads: every request must carry the branch header + * from the first fetch, so the header provider is installed at boot from + * this tiny module. The store (`branchStore.ts`) builds on it; nothing here + * pulls in Zustand, the persistence layer, or the UI. + * + * Persistence is per TAB (`sessionStorage`), so two tabs can sit on two + * branches; a `?branch=` query param on any admin URL overrides the stored + * value so links into a branch are shareable. Main needs no header. + */ +import { MAIN_BRANCH_ID, isValidBranchId } from '@core/branches' +import { registerRequestHeaderProvider } from '@core/http' + +export const ACTIVE_BRANCH_STORAGE_KEY = 'instatic-active-branch' +export const BRANCH_QUERY_PARAM = 'branch' +/** The request header carrying the branch; the server reads it case-insensitively. */ +export const BRANCH_HEADER = 'X-Instatic-Branch' + +function readStoredBranch(): string { + if (typeof window === 'undefined') return MAIN_BRANCH_ID + try { + const url = new URL(window.location.href) + const fromUrl = url.searchParams.get(BRANCH_QUERY_PARAM) + if (fromUrl !== null) { + // Consumed once: leaving it in the URL would re-seed the branch on + // every reload, undoing a later switch to main or a deletion. + url.searchParams.delete(BRANCH_QUERY_PARAM) + window.history.replaceState(window.history.state, '', url) + } + if (fromUrl && isValidBranchId(fromUrl)) { + window.sessionStorage.setItem(ACTIVE_BRANCH_STORAGE_KEY, fromUrl) + return fromUrl + } + const stored = window.sessionStorage.getItem(ACTIVE_BRANCH_STORAGE_KEY) + return stored && isValidBranchId(stored) ? stored : MAIN_BRANCH_ID + } catch { + // sessionStorage can throw in privacy modes — main is always safe. + return MAIN_BRANCH_ID + } +} + +let activeBranchId: string = readStoredBranch() + +/** The branch this tab addresses right now. */ +export function currentBranchId(): string { + return activeBranchId +} + +/** Record the tab's branch; the header provider reads it on the next request. */ +export function rememberBranchId(branchId: string): void { + activeBranchId = branchId + if (typeof window === 'undefined') return + try { + if (branchId === MAIN_BRANCH_ID) window.sessionStorage.removeItem(ACTIVE_BRANCH_STORAGE_KEY) + else window.sessionStorage.setItem(ACTIVE_BRANCH_STORAGE_KEY, branchId) + } catch { + // Persistence is a convenience; the in-memory value still drives requests. + } +} + +/** The branch header every admin request should carry; empty on main. */ +export function activeBranchHeaders(): Record { + return activeBranchId === MAIN_BRANCH_ID ? {} : { [BRANCH_HEADER]: activeBranchId } +} + +/** Wire the header into the HTTP layer. Called once by the admin entry. */ +export function installBranchRequestHeaders(): void { + registerRequestHeaderProvider(activeBranchHeaders) +} diff --git a/src/admin/state/branchStore.ts b/src/admin/state/branchStore.ts new file mode 100644 index 000000000..17e3ce9b4 --- /dev/null +++ b/src/admin/state/branchStore.ts @@ -0,0 +1,308 @@ +/** + * Active branch — which site branch this tab is editing, plus the branch + * registry and the switcher's UI state. + * + * Every admin request carries the branch as the `X-Instatic-Branch` header + * (registered as an ambient header provider at boot), every collab doc id + * minted by the editor carries it, and the branch-scoped workspaces (Site, + * Content, Data) remount when it changes so their data reloads. + * + * The active id itself is owned by `activeBranch.ts` (installed at boot so + * the very first request carries the header); this store mirrors it for + * React and adds the registry and UI state on top. + * + * Publishing and scheduling only exist on main. `useBranchPublishGate` is + * the one place every publish control asks "am I on a branch, and what do + * I tell the user" — the controls disable with that reason inline instead + * of letting a click reach the server's 409. + */ +import { create } from 'zustand' +import { + MAIN_BRANCH_ID, + type ApplyMergeBody, + type CreateBranchBody, + type MergeDirection, + type MergePlan, + type SiteBranch, +} from '@core/branches' +import { registerApiErrorListener } from '@core/http' +import { + applyCmsBranchMerge, + createCmsBranch, + deleteCmsBranch, + listCmsBranches, + renameCmsBranch, +} from '@core/persistence' +import { pushToast } from '@ui/components/Toast' +import { BRANCH_HEADER, currentBranchId, rememberBranchId } from './activeBranch' + +const BRANCH_NOT_FOUND_CODE = 'branch_not_found' + +/** The inline reason every publish/schedule control shows on a branch. */ +export const BRANCH_PUBLISH_REASON = 'Publishing happens on main. Merge this branch first.' + +export type BranchSwitcherMode = 'closed' | 'list' | 'create' + +interface BranchState { + activeBranchId: string + /** The registry, main first then most recently updated. */ + branches: SiteBranch[] + /** False until the first successful registry load. */ + branchesLoaded: boolean + switcher: BranchSwitcherMode + manageOpen: boolean + /** Branch to start renaming when the manage dialog opens. */ + manageRenamingId: string | null + /** + * Bumped when a merge or update rewrote the active branch's content in + * place; the branch-scoped workspaces key on it so they reload. + */ + epoch: number + setActiveBranch: (branchId: string) => void + setBranches: (branches: SiteBranch[]) => void + openSwitcher: (mode?: Exclude) => void + closeSwitcher: () => void + openManage: (renamingId?: string) => void + closeManage: () => void + bumpEpoch: () => void +} + +/** Main first, then most recently updated. */ +export function sortBranches(branches: readonly SiteBranch[]): SiteBranch[] { + return [...branches].sort((a, b) => { + if (a.id === MAIN_BRANCH_ID) return -1 + if (b.id === MAIN_BRANCH_ID) return 1 + return Date.parse(b.updatedAt) - Date.parse(a.updatedAt) + }) +} + +export const useBranchStore = create((set) => ({ + activeBranchId: currentBranchId(), + branches: [], + branchesLoaded: false, + switcher: 'closed', + manageOpen: false, + manageRenamingId: null, + epoch: 0, + setActiveBranch: (branchId) => { + rememberBranchId(branchId) + set({ activeBranchId: branchId }) + }, + setBranches: (branches) => set({ branches: sortBranches(branches), branchesLoaded: true }), + openSwitcher: (mode = 'list') => set({ switcher: mode }), + closeSwitcher: () => set({ switcher: 'closed' }), + openManage: (renamingId) => set({ manageOpen: true, manageRenamingId: renamingId ?? null }), + closeManage: () => set({ manageOpen: false, manageRenamingId: null }), + bumpEpoch: () => set((state) => ({ epoch: state.epoch + 1 })), +})) + +/** + * Branches this tab is deleting itself (a delete, or a merge that deletes). + * The server tombstones the branch and resets its documents before the + * request returns, so the collab socket's `gone` usually lands first — for + * these ids it is expected, not news: the tab leaves quietly and the flow + * that asked refreshes the registry and reports once. + */ +const leaving = new Set() + +/** + * The active branch is gone (deleted from another tab, by another user, or + * under this very tab): drop back to main once, refresh the registry, and + * say so. Shared by the HTTP 404 listener and the collab socket's `gone`. + */ +export function fallBackToMain(branchId: string): void { + const { activeBranchId, setActiveBranch } = useBranchStore.getState() + if (activeBranchId !== branchId || branchId === MAIN_BRANCH_ID) return + setActiveBranch(MAIN_BRANCH_ID) + if (leaving.has(branchId)) return + void refreshBranchesAfterMutation() + pushToast({ + kind: 'info', + title: 'Branch no longer exists', + body: `The branch "${branchId}" was deleted. You are back on main.`, + }) +} + +/** + * The active branch can disappear under this tab (deleted from another tab + * or by another user): the next request 404s with the `branch_not_found` + * code and the tab drops back to main. Registered once, when the + * authenticated admin loads this module. + */ +registerApiErrorListener((error, request) => { + if (error.code !== BRANCH_NOT_FOUND_CODE) return + // Judge by the branch the request was sent for: a request still in flight + // for a branch this tab has since left must not kick it off the branch it + // is on now. The hand-rolled fetch sites report no headers; they sent the + // branch that is active. + fallBackToMain(request.headers?.[BRANCH_HEADER] ?? useBranchStore.getState().activeBranchId) +}) + +// --------------------------------------------------------------------------- +// Registry operations. Every mutation applies its own result to the registry +// at once, then re-reads the registry so the switcher, strip, and dialog all +// read one consistent list; that re-read can fail without the mutation — +// which already happened — being reported as failed. +// --------------------------------------------------------------------------- + +export async function refreshBranches(signal?: AbortSignal): Promise { + const branches = await listCmsBranches(signal) + useBranchStore.getState().setBranches(branches) + return branches +} + +/** The post-mutation re-read: logged on failure, never thrown. */ +async function refreshBranchesAfterMutation(): Promise { + try { + await refreshBranches() + } catch (err) { + console.error('[branches] failed to refresh branches:', err) + } +} + +function upsertBranch(branch: SiteBranch): void { + const state = useBranchStore.getState() + state.setBranches([...state.branches.filter((entry) => entry.id !== branch.id), branch]) +} + +/** The branch is gone: leave it if this tab is on it, and drop it from the registry. */ +function leaveDeletedBranch(branchId: string): void { + const state = useBranchStore.getState() + if (state.activeBranchId === branchId) state.setActiveBranch(MAIN_BRANCH_ID) + state.setBranches(state.branches.filter((entry) => entry.id !== branchId)) +} + +/** Switch this tab to a branch. A no-op when already there. */ +export function switchBranch(branchId: string): void { + const state = useBranchStore.getState() + if (state.activeBranchId === branchId) return + state.setActiveBranch(branchId) +} + +/** Fork a branch and switch this tab onto it. */ +export async function createBranch(input: CreateBranchBody): Promise { + const branch = await createCmsBranch(input) + upsertBranch(branch) + switchBranch(branch.id) + await refreshBranchesAfterMutation() + return branch +} + +export async function renameBranch(branchId: string, name: string): Promise { + const branch = await renameCmsBranch(branchId, { name }) + upsertBranch(branch) + await refreshBranchesAfterMutation() + return branch +} + +/** + * Delete a branch. Callers wrap this in `runStepUp` — the server re-verifies + * the actor. When the deleted branch is the active one the tab returns to + * main before the registry refreshes, so no request goes out for it. + */ +export async function deleteBranch(branchId: string): Promise { + leaving.add(branchId) + try { + await deleteCmsBranch(branchId) + } finally { + leaving.delete(branchId) + } + leaveDeletedBranch(branchId) + await refreshBranchesAfterMutation() +} + +export interface BranchMergeResult { + plan: MergePlan + branchDeleted: boolean +} + +/** + * Merge a branch into main, or update it from main. Callers wrap this in + * `runStepUp`. A merge that deletes the branch returns this tab to main; + * otherwise the active branch's content moved (an update rewrote it, a + * merge mirrored the result back) and every branch-scoped workspace reloads. + */ +export async function mergeBranch( + branchId: string, + direction: MergeDirection, + body: ApplyMergeBody, +): Promise { + if (body.deleteBranch) leaving.add(branchId) + let result: BranchMergeResult + try { + result = await applyCmsBranchMerge(branchId, direction, body) + } finally { + leaving.delete(branchId) + } + if (result.branchDeleted) leaveDeletedBranch(branchId) + else useBranchStore.getState().bumpEpoch() + await refreshBranchesAfterMutation() + return result +} + +// --------------------------------------------------------------------------- +// Hooks +// --------------------------------------------------------------------------- + +export function useActiveBranchId(): string { + return useBranchStore((state) => state.activeBranchId) +} + +export function useBranches(): SiteBranch[] { + return useBranchStore((state) => state.branches) +} + +/** `:` — the key a branch-scoped workspace remounts on. */ +export function useBranchWorkspaceKey(): string { + return useBranchStore((state) => `${state.activeBranchId}:${state.epoch}`) +} + +/** + * The active branch's registry entry. Before the registry loads (or when the + * tab was opened straight onto a branch id) the entry is synthesized from the + * id so the chip and strip never flash "main". + */ +export function useActiveBranch(): SiteBranch { + return useBranchStore((state) => { + const found = state.branches.find((branch) => branch.id === state.activeBranchId) + if (found) return found + return placeholderBranch(state.activeBranchId) + }) +} + +const placeholderCache = new Map() + +function placeholderBranch(id: string): SiteBranch { + let branch = placeholderCache.get(id) + if (!branch) { + const now = new Date(0).toISOString() + branch = { + id, + name: id, + baseBranchId: id === MAIN_BRANCH_ID ? null : MAIN_BRANCH_ID, + createdByUserId: null, + createdAt: now, + updatedAt: now, + } + placeholderCache.set(id, branch) + } + return branch +} + +export interface BranchPublishGate { + /** True while the tab edits a branch other than main. */ + onBranch: boolean + /** The inline reason to show on disabled publish/schedule controls; null on main. */ + reason: string | null +} + +export function useBranchPublishGate(): BranchPublishGate { + const branch = useActiveBranch() + if (branch.id === MAIN_BRANCH_ID) return { onBranch: false, reason: null } + return { onBranch: true, reason: BRANCH_PUBLISH_REASON } +} + +/** Non-hook read for command palettes and other imperative gates. */ +export function isOnMainBranch(): boolean { + return useBranchStore.getState().activeBranchId === MAIN_BRANCH_ID +} diff --git a/src/core/branches/ids.ts b/src/core/branches/ids.ts new file mode 100644 index 000000000..2c341bed3 --- /dev/null +++ b/src/core/branches/ids.ts @@ -0,0 +1,70 @@ +/** + * Branch identity — the id scheme every branch-aware layer agrees on. + * + * A site branch is a full copy of the site's content (shell, tables, rows) + * that shares the same LOGICAL ids as `main`. Logical ids are what every + * reference inside stored JSON points at (component refs, relation cells, + * loop table ids), what the HTTP API exchanges, and what the editor holds + * in its store. They never change when a branch is forked or merged. + * + * Storage keeps one row per (branch, logical id). The row's PHYSICAL primary + * key is derived, never chosen: on `main` it equals the logical id, so every + * pre-branch row keeps its key and every foreign key that points at a main + * row (versions, redirects, usage refs) stays valid; on any other branch it + * is the branch id and the logical id joined by `:`. Branch ids therefore + * cannot contain `:` — see `BRANCH_ID_PATTERN`. + */ + +export const MAIN_BRANCH_ID = 'main' + +/** Logical id of the one site shell row every branch carries. */ +export const SITE_SHELL_LOGICAL_ID = 'default' + +/** Lowercase slug: letters, digits, dashes, dots; 1–64 chars; never `:`. */ +export const BRANCH_ID_PATTERN = /^[a-z0-9][a-z0-9.-]{0,63}$/ + +export const BRANCH_NAME_MAX_LENGTH = 80 + +export function isValidBranchId(value: string): boolean { + return BRANCH_ID_PATTERN.test(value) +} + +export function isMainBranch(branchId: string): boolean { + return branchId === MAIN_BRANCH_ID +} + +/** + * Derive a branch id from a human name: lowercase, dashes for runs of + * anything that is not a letter, digit, or dot, trimmed and clamped to the + * pattern's length. Returns `''` when nothing usable remains. + */ +export function slugifyBranchName(input: string): string { + return input + .trim() + .toLowerCase() + .replace(/[^a-z0-9.]+/g, '-') + .replace(/-{2,}/g, '-') + .replace(/^[-.]+|[-.]+$/g, '') + .slice(0, 64) + .replace(/[-.]+$/g, '') +} + +/** + * The physical primary key of a logical id on a branch. Identity on `main`, + * `:` elsewhere. Pure and total — callers never need to + * consult storage to address a row. + */ +export function physicalId(branchId: string, logicalId: string): string { + return branchId === MAIN_BRANCH_ID ? logicalId : `${branchId}:${logicalId}` +} + +/** + * Inverse of `physicalId` for the given branch. A physical id that does not + * carry the branch's prefix is returned unchanged — that only happens for + * main, where the two coincide. + */ +export function logicalIdOf(branchId: string, physical: string): string { + if (branchId === MAIN_BRANCH_ID) return physical + const prefix = `${branchId}:` + return physical.startsWith(prefix) ? physical.slice(prefix.length) : physical +} diff --git a/src/core/branches/index.ts b/src/core/branches/index.ts new file mode 100644 index 000000000..fc2b646ef --- /dev/null +++ b/src/core/branches/index.ts @@ -0,0 +1,44 @@ +/** + * @core/branches — branch identity, id scheme, and wire schemas shared by the + * server, the admin client, and the collab engine. Barrel-gated: import ONLY + * from `@core/branches` outside this folder. + */ +export { + BRANCH_ID_PATTERN, + BRANCH_NAME_MAX_LENGTH, + MAIN_BRANCH_ID, + SITE_SHELL_LOGICAL_ID, + isMainBranch, + isValidBranchId, + logicalIdOf, + physicalId, + slugifyBranchName, +} from './ids' +export { jsonEquals, mergeJson, type JsonMergeResult } from './threeWayMerge' +export { + BranchEnvelopeSchema, + BranchListEnvelopeSchema, + BranchPreviewLinkEnvelopeSchema, + BranchPreviewSchema, + BranchPreviewStateEnvelopeSchema, + CreateBranchBodySchema, + RenameBranchBodySchema, + SiteBranchSchema, + ApplyMergeBodySchema, + ApplyMergeEnvelopeSchema, + MergeChangeSchema, + MergeDirectionSchema, + MergePlanEnvelopeSchema, + MergePlanSchema, + MergeResolutionSchema, + type ApplyMergeBody, + type BranchListEnvelope, + type BranchPreview, + type CreateBranchBody, + type MergeChange, + type MergeDirection, + type MergePlan, + type MergeResolution, + type RenameBranchBody, + type SiteBranch, +} from './schemas' diff --git a/src/core/branches/schemas.ts b/src/core/branches/schemas.ts new file mode 100644 index 000000000..11577c5df --- /dev/null +++ b/src/core/branches/schemas.ts @@ -0,0 +1,106 @@ +/** + * Wire shapes shared by the branch endpoints and the admin client. TypeBox + * is the source of truth; every type below is derived from its schema. + */ +import { Type, type Static } from '@core/utils/typeboxHelpers' +import { BRANCH_NAME_MAX_LENGTH } from './ids' + +export const SiteBranchSchema = Type.Object({ + /** Branch slug — immutable, part of every physical row id off `main`. */ + id: Type.String(), + /** Display name; editable. */ + name: Type.String(), + /** Branch this one was forked from, `null` for `main`. */ + baseBranchId: Type.Union([Type.String(), Type.Null()]), + createdByUserId: Type.Union([Type.String(), Type.Null()]), + createdAt: Type.String(), + updatedAt: Type.String(), +}) +export type SiteBranch = Static + +export const BranchListEnvelopeSchema = Type.Object({ + branches: Type.Array(SiteBranchSchema), +}) +export type BranchListEnvelope = Static + +export const BranchEnvelopeSchema = Type.Object({ + branch: SiteBranchSchema, +}) + +export const CreateBranchBodySchema = Type.Object({ + name: Type.String({ minLength: 1, maxLength: BRANCH_NAME_MAX_LENGTH }), + /** Explicit slug; derived from `name` when omitted. */ + id: Type.Optional(Type.String()), + /** Branch to fork; defaults to `main`. */ + fromBranchId: Type.Optional(Type.String()), +}, { additionalProperties: false }) +export type CreateBranchBody = Static + +export const RenameBranchBodySchema = Type.Object({ + name: Type.String({ minLength: 1, maxLength: BRANCH_NAME_MAX_LENGTH }), +}, { additionalProperties: false }) +export type RenameBranchBody = Static + +/** An issued preview link. The token itself is only ever returned at creation. */ +export const BranchPreviewSchema = Type.Object({ + id: Type.String(), + branchId: Type.String(), + createdByUserId: Type.Union([Type.String(), Type.Null()]), + createdAt: Type.String(), +}) +export type BranchPreview = Static + +export const BranchPreviewStateEnvelopeSchema = Type.Object({ + preview: Type.Union([BranchPreviewSchema, Type.Null()]), +}) + +export const BranchPreviewLinkEnvelopeSchema = Type.Object({ + url: Type.String(), + preview: BranchPreviewSchema, +}) + +// --------------------------------------------------------------------------- +// Merge / update plans +// --------------------------------------------------------------------------- + +export const MergeDirectionSchema = Type.Union([Type.Literal('merge'), Type.Literal('update')]) +export type MergeDirection = Static + +export const MergeResolutionSchema = Type.Union([Type.Literal('into'), Type.Literal('from')]) +export type MergeResolution = Static + +export const MergeChangeSchema = Type.Object({ + key: Type.String(), + kind: Type.Union([Type.Literal('row'), Type.Literal('table'), Type.Literal('site')]), + logicalId: Type.String(), + label: Type.String(), + tableId: Type.Union([Type.String(), Type.Null()]), + tableName: Type.Union([Type.String(), Type.Null()]), + action: Type.Union([Type.Literal('create'), Type.Literal('update'), Type.Literal('delete')]), + conflicts: Type.Array(Type.String()), +}) +export type MergeChange = Static + +export const MergePlanSchema = Type.Object({ + branchId: Type.String(), + direction: MergeDirectionSchema, + from: Type.String(), + into: Type.String(), + changes: Type.Array(MergeChangeSchema), + conflictCount: Type.Number(), +}) +export type MergePlan = Static + +export const MergePlanEnvelopeSchema = Type.Object({ plan: MergePlanSchema }) + +export const ApplyMergeBodySchema = Type.Object({ + resolutions: Type.Optional(Type.Record(Type.String(), MergeResolutionSchema)), + /** Merge only: delete the branch once its changes are on main. */ + deleteBranch: Type.Optional(Type.Boolean()), +}) +export type ApplyMergeBody = Static + +export const ApplyMergeEnvelopeSchema = Type.Object({ + plan: MergePlanSchema, + branchDeleted: Type.Boolean(), +}) diff --git a/src/core/branches/threeWayMerge.ts b/src/core/branches/threeWayMerge.ts new file mode 100644 index 000000000..24939f407 --- /dev/null +++ b/src/core/branches/threeWayMerge.ts @@ -0,0 +1,60 @@ +/** + * Three-way merge of JSON values. + * + * `base` is what both sides started from, `ours` is the side receiving the + * merge, `theirs` the side being merged in. Plain objects merge key by key; + * everything else (arrays, scalars, page trees inside a cell) is atomic — + * a value either moved on one side only, on both sides identically, or it + * conflicts. Conflicts keep `ours` and are reported by path so a reviewer + * can decide; nothing here ever invents a value neither side wrote. + */ +import { canonicalJson } from '@core/utils/canonicalJson' + +export interface JsonMergeResult { + value: unknown + /** Paths (`a.b.c`) where both sides changed the same value differently. */ + conflicts: string[] +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function same(a: unknown, b: unknown): boolean { + if (a === undefined || b === undefined) return a === b + return canonicalJson(a) === canonicalJson(b) +} + +function joinPath(path: string, key: string): string { + return path ? `${path}.${key}` : key +} + +function mergeValue(base: unknown, ours: unknown, theirs: unknown, path: string, conflicts: string[]): unknown { + if (same(ours, theirs)) return ours + if (same(theirs, base)) return ours + if (same(ours, base)) return theirs + if (isPlainObject(ours) && isPlainObject(theirs)) { + const baseObject = isPlainObject(base) ? base : {} + const merged: Record = {} + const keys = new Set([...Object.keys(ours), ...Object.keys(theirs), ...Object.keys(baseObject)]) + for (const key of keys) { + const value = mergeValue(baseObject[key], ours[key], theirs[key], joinPath(path, key), conflicts) + if (value !== undefined) merged[key] = value + } + return merged + } + conflicts.push(path || '(root)') + return ours +} + +/** Merge `theirs` into `ours` given their common `base`. */ +export function mergeJson(base: unknown, ours: unknown, theirs: unknown): JsonMergeResult { + const conflicts: string[] = [] + const value = mergeValue(base, ours, theirs, '', conflicts) + return { value, conflicts } +} + +/** True when two values serialize identically. */ +export function jsonEquals(a: unknown, b: unknown): boolean { + return same(a, b) +} diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index 85ce52aa6..51e72cd3b 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -28,6 +28,9 @@ export const CORE_CAPABILITIES = [ 'site.structure.edit', 'site.content.edit', 'site.style.edit', + // Branches — create, rename, delete, merge, update, and share preview links. + // Listing and switching branches only need `site.read`. + 'site.branches.manage', 'pages.edit', 'pages.publish', 'content.create', diff --git a/src/core/collab/applyPatches.ts b/src/core/collab/applyPatches.ts index ed5af6f2b..4e2a5cc16 100644 --- a/src/core/collab/applyPatches.ts +++ b/src/core/collab/applyPatches.ts @@ -26,7 +26,7 @@ import type { Patches } from 'mutative' import type { BaseNode, Page, SiteDocument } from '@core/page-tree' import type { VisualComponent } from '@core/visualComponents' import type { SavedLayout } from '@core/layouts' -import { encodeCollabDocId, SITE_DOC_ID } from './docIds' +import { encodeCollabDocId, siteDocId } from './docIds' import { dataMap, inlineTextPropOf, metaMap, rostersMap, SHELL_PER_ENTRY_KEYS, shellMap, treeMap } from './schema' import { buildBreakpointOverridesMap, buildNodeMap, buildPropsMap } from './nodeY' import { populateComponentDoc, populateLayoutDoc, populatePageDoc } from './seed' @@ -360,6 +360,8 @@ export function applySitePatchesToDocs( nextSite: SiteDocument, docs: CollabDocSet, origin: unknown, + /** Branch the site document belongs to — every touched doc id carries it. */ + branchId: string, ): string[] { const touchedDocs: string[] = [] const touch = (docId: string): void => { @@ -395,8 +397,8 @@ export function applySitePatchesToDocs( } if (shellHeads.size > 0 || shellEntryTargets.size > 0 || collectionsWithMembershipOps.length > 0) { - const siteDoc = docs.ensure(SITE_DOC_ID) - touch(SITE_DOC_ID) + const siteDoc = docs.ensure(siteDocId(branchId)) + touch(siteDocId(branchId)) siteDoc.transact(() => { const shell = shellMap(siteDoc) for (const head of shellHeads) { @@ -443,8 +445,8 @@ export function applySitePatchesToDocs( // Client-created row: populate a fresh doc (single author — safe). const kind = COLLECTION_KIND[col] rosterWork.push(() => { - const rowDoc = docs.ensure(encodeCollabDocId({ kind, rowId: id })) - touch(encodeCollabDocId({ kind, rowId: id })) + const rowDoc = docs.ensure(encodeCollabDocId({ kind, branchId, rowId: id })) + touch(encodeCollabDocId({ kind, branchId, rowId: id })) rowDoc.transact(() => repopulateRowDoc(rowDoc, kind, nextById.get(id)!), origin) }) } @@ -478,8 +480,8 @@ export function applySitePatchesToDocs( // Wholesale collection replacement (imports) → repopulate every row doc. if (colPatches.some((p) => patchPath(p).length === 1)) { for (const [id, row] of nextById) { - const rowDoc = docs.ensure(encodeCollabDocId({ kind, rowId: id })) - touch(encodeCollabDocId({ kind, rowId: id })) + const rowDoc = docs.ensure(encodeCollabDocId({ kind, branchId, rowId: id })) + touch(encodeCollabDocId({ kind, branchId, rowId: id })) rowDoc.transact(() => repopulateRowDoc(rowDoc, kind, row), origin) } continue @@ -500,8 +502,8 @@ export function applySitePatchesToDocs( const id = (row as Row).id if (rest.length === 0) { if (preById.get(id) !== nextById.get(id)) { - const rowDoc = docs.ensure(encodeCollabDocId({ kind, rowId: id })) - touch(encodeCollabDocId({ kind, rowId: id })) + const rowDoc = docs.ensure(encodeCollabDocId({ kind, branchId, rowId: id })) + touch(encodeCollabDocId({ kind, branchId, rowId: id })) rowDoc.transact(() => repopulateRowDoc(rowDoc, kind, row as Row), origin) } continue @@ -514,8 +516,8 @@ export function applySitePatchesToDocs( for (const [id, subPaths] of rowSubPaths) { const nextRow = nextById.get(id) if (!nextRow) continue - const rowDoc = docs.ensure(encodeCollabDocId({ kind, rowId: id })) - touch(encodeCollabDocId({ kind, rowId: id })) + const rowDoc = docs.ensure(encodeCollabDocId({ kind, branchId, rowId: id })) + touch(encodeCollabDocId({ kind, branchId, rowId: id })) if (kind === 'layout') { // Whole-snapshot LWW — any layout content change rewrites the snapshot. rowDoc.transact(() => repopulateRowDoc(rowDoc, 'layout', nextRow), origin) diff --git a/src/core/collab/docIds.ts b/src/core/collab/docIds.ts index 540dd22b6..7989424c6 100644 --- a/src/core/collab/docIds.ts +++ b/src/core/collab/docIds.ts @@ -1,33 +1,57 @@ /** - * Collab document addressing — one Yjs document per logical row/shell. + * Collab document addressing — one Yjs document per logical row/shell PER + * BRANCH. * * The doc id is the unit the whole collaboration stack speaks: the client * provider binds by doc id, the server relay registers and persists by doc * id, the wire protocol prefixes every frame with it, and the * `collab_documents` table keys on it. + * + * Shape: `::` for rows, `site:` for + * the shell. Branch ids never contain `:` (see `@core/branches`), so the + * first two segments are unambiguous; the row id may contain anything. */ +import { MAIN_BRANCH_ID, isValidBranchId } from '@core/branches' export type CollabDocKind = 'site' | 'page' | 'component' | 'layout' -export interface CollabDocId { - kind: CollabDocKind - /** The backing row id; the shell uses the fixed site row id `default`. */ - rowId: string +export type CollabDocId = + | { kind: 'site'; branchId: string } + | { kind: Exclude; branchId: string; rowId: string } + +const ROW_KINDS: readonly Exclude[] = ['page', 'component', 'layout'] + +/** The shell doc id of a branch. */ +export function siteDocId(branchId: string): string { + return `site:${branchId}` } -export const SITE_DOC_ID = 'site:default' +/** The main branch's shell doc id — the only doc every install has. */ +export const MAIN_SITE_DOC_ID = siteDocId(MAIN_BRANCH_ID) export function encodeCollabDocId(id: CollabDocId): string { - return `${id.kind}:${id.rowId}` + if (id.kind === 'site') return siteDocId(id.branchId) + return `${id.kind}:${id.branchId}:${id.rowId}` } -const KINDS: readonly CollabDocKind[] = ['site', 'page', 'component', 'layout'] - export function parseCollabDocId(raw: string): CollabDocId | null { - const sep = raw.indexOf(':') - if (sep <= 0) return null - const kind = raw.slice(0, sep) - const rowId = raw.slice(sep + 1) - if (!rowId || !KINDS.includes(kind as CollabDocKind)) return null - return { kind: kind as CollabDocKind, rowId } + const first = raw.indexOf(':') + if (first <= 0) return null + const kind = raw.slice(0, first) + const rest = raw.slice(first + 1) + if (kind === 'site') { + return rest && isValidBranchId(rest) ? { kind: 'site', branchId: rest } : null + } + if (!ROW_KINDS.includes(kind as Exclude)) return null + const second = rest.indexOf(':') + if (second <= 0) return null + const branchId = rest.slice(0, second) + const rowId = rest.slice(second + 1) + if (!rowId || !isValidBranchId(branchId)) return null + return { kind: kind as Exclude, branchId, rowId } +} + +/** True when `docId` is the shell doc of any branch. */ +export function isSiteDocId(docId: string): boolean { + return parseCollabDocId(docId)?.kind === 'site' } diff --git a/src/core/collab/index.ts b/src/core/collab/index.ts index 2f815bf8c..c1fb63adc 100644 --- a/src/core/collab/index.ts +++ b/src/core/collab/index.ts @@ -9,8 +9,10 @@ */ export { encodeCollabDocId, + isSiteDocId, + MAIN_SITE_DOC_ID, parseCollabDocId, - SITE_DOC_ID, + siteDocId, type CollabDocId, type CollabDocKind, } from './docIds' diff --git a/src/core/collab/protocol.ts b/src/core/collab/protocol.ts index 2e4e753db..359aa4ea6 100644 --- a/src/core/collab/protocol.ts +++ b/src/core/collab/protocol.ts @@ -61,10 +61,12 @@ export const FRAME_PONG = 4 * held for that doc has been discarded. * refused — the write-policy guard rejected the update. * oversize — the frame exceeded the sync payload ceiling. + * gone — the doc's branch no longer exists; do not rebind, leave the + * branch instead. */ -export type ResetReason = 'rewritten' | 'stale' | 'refused' | 'oversize' +export type ResetReason = 'rewritten' | 'stale' | 'refused' | 'oversize' | 'gone' -const RESET_REASONS: readonly ResetReason[] = ['rewritten', 'stale', 'refused', 'oversize'] +const RESET_REASONS: readonly ResetReason[] = ['rewritten', 'stale', 'refused', 'oversize', 'gone'] export interface CollabFrame { docId: string diff --git a/src/core/data/bundleSchema.ts b/src/core/data/bundleSchema.ts index 9dbcfe98d..aca5cfb19 100644 --- a/src/core/data/bundleSchema.ts +++ b/src/core/data/bundleSchema.ts @@ -33,6 +33,7 @@ */ import { Type, type Static } from '@core/utils/typeboxHelpers' +import { BRANCH_ID_PATTERN } from '@core/branches' import { DataTableSchema, DataRowSchema, DataTableKindSchema } from './schemas' import { SiteShellSchema } from '@core/page-tree' @@ -207,6 +208,11 @@ export const ExportRequestSchema = Type.Object({ includeMediaFolders: Type.Optional(Type.Boolean()), /** Include published-URL redirects (old route → row). Default true. */ includeRedirects: Type.Optional(Type.Boolean()), + /** + * Site branch to export. The download is a form POST, which cannot carry + * the branch header every other admin request uses. Omit for main. + */ + branchId: Type.Optional(Type.String({ pattern: BRANCH_ID_PATTERN.source })), }) export type ExportRequest = Static diff --git a/src/core/data/schemas.ts b/src/core/data/schemas.ts index 5752740be..68269840d 100644 --- a/src/core/data/schemas.ts +++ b/src/core/data/schemas.ts @@ -668,3 +668,19 @@ export type DataMetaField = Static export type DataMetaRepeaterItemField = Static export type DataMetaTable = Static export type DataMeta = Static + +// --------------------------------------------------------------------------- +// DataRowVersionSummary — one published version of a row, as listed by the +// version-history endpoint (content lives server-side until restored). +// --------------------------------------------------------------------------- + +export const DataRowVersionSummarySchema = Type.Object({ + id: Type.String(), + rowId: Type.String(), + versionNumber: Type.Number(), + slug: Type.String(), + publishedAt: Type.String(), + publishedByUserId: Type.Union([Type.String(), Type.Null()]), + publishedByName: Type.Union([Type.String(), Type.Null()]), +}) +export type DataRowVersionSummary = Static diff --git a/src/core/http/apiClient.ts b/src/core/http/apiClient.ts index 506699b75..913b97e60 100644 --- a/src/core/http/apiClient.ts +++ b/src/core/http/apiClient.ts @@ -25,6 +25,7 @@ import type { TSchema, Static } from '@sinclair/typebox' import { Type } from '@core/utils/typeboxHelpers' import { parseJsonResponse } from '@core/utils/jsonValidate' +import { ambientRequestHeaders } from './requestHeaders' export type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise @@ -35,24 +36,77 @@ export type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promis * {@link responseErrorMessage} instead of throwing. */ const ErrorEnvelopeSchema = Type.Object( - { error: Type.Optional(Type.Unknown()) }, + { error: Type.Optional(Type.Unknown()), code: Type.Optional(Type.String()) }, { additionalProperties: true }, ) /** * The single error type thrown for every failed HTTP call. Carries the HTTP - * status so UI can branch on it (e.g. 403 → "no access", 404 → "not found"). + * status so UI can branch on it (e.g. 403 → "no access", 404 → "not found") + * and the envelope's machine-readable `code` when the server sent one + * (e.g. `branch_not_found`, which the branch store keys on). */ export class ApiError extends Error { readonly status: number + readonly code: string | null - constructor(message: string, status: number) { + constructor(message: string, status: number, code: string | null = null) { super(message) this.name = 'ApiError' this.status = status + this.code = code } } +/** What the transport knew about the request that failed. */ +export interface ApiErrorRequest { + /** + * The headers the request was sent with (ambient ones included), or null + * when the caller performed its own `fetch` and only handed over the + * `Response` — the transport never saw that request. + */ + headers: Readonly> | null +} + +export type ApiErrorListener = (error: ApiError, request: ApiErrorRequest) => void + +const apiErrorListeners = new Set() + +/** + * Observe every {@link ApiError} the transport throws. The listener runs + * before the caller sees the rejection; it must not throw. Used for + * cross-cutting reactions (the active branch vanishing under the tab) that + * no single call site owns. + */ +export function registerApiErrorListener(listener: ApiErrorListener): () => void { + apiErrorListeners.add(listener) + return () => { + apiErrorListeners.delete(listener) + } +} + +function notifyApiError(error: ApiError, request: ApiErrorRequest): ApiError { + for (const listener of apiErrorListeners) listener(error, request) + return error +} + +interface ResponseErrorEnvelope { + message: string + code: string | null +} + +async function responseErrorEnvelope(res: Response, fallback: string): Promise { + let code: string | null = null + try { + const body = await parseJsonResponse(res.clone(), ErrorEnvelopeSchema) + code = body.code ?? null + if (typeof body.error === 'string' && body.error.trim()) return { message: body.error, code } + } catch { + // Not a JSON error envelope — fall through to text. + } + return { message: await responseErrorMessage(res, fallback), code } +} + /** True for an aborted fetch (user cancellation / superseded request). */ export function isAbortError(err: unknown): boolean { return ( @@ -92,7 +146,8 @@ export async function responseErrorMessage(res: Response, fallback: string): Pro */ export async function assertOk(res: Response, fallback: string): Promise { if (!res.ok) { - throw new ApiError(await responseErrorMessage(res, fallback), res.status) + const envelope = await responseErrorEnvelope(res, fallback) + throw notifyApiError(new ApiError(envelope.message, res.status, envelope.code), { headers: null }) } } @@ -108,7 +163,8 @@ export async function readEnvelope( fallback: string, ): Promise> { if (!res.ok) { - throw new ApiError(await responseErrorMessage(res, fallback), res.status) + const envelope = await responseErrorEnvelope(res, fallback) + throw notifyApiError(new ApiError(envelope.message, res.status, envelope.code), { headers: null }) } return parseJsonResponse(res, schema) } @@ -193,7 +249,8 @@ async function requestResponse( const init: RequestInit = { method, credentials } if (signal) init.signal = signal - const finalHeaders: Record = { ...headers } + // Ambient headers (the active branch) sit underneath the caller's own. + const finalHeaders: Record = { ...ambientRequestHeaders(), ...headers } if (body !== undefined) { if (body instanceof FormData) { init.body = body @@ -207,10 +264,8 @@ async function requestResponse( const res = await fetchImpl(buildUrl(path, query), init) if (!res.ok) { - throw new ApiError( - await responseErrorMessage(res, fallbackMessage ?? `Request failed: ${res.status}`), - res.status, - ) + const envelope = await responseErrorEnvelope(res, fallbackMessage ?? `Request failed: ${res.status}`) + throw notifyApiError(new ApiError(envelope.message, res.status, envelope.code), { headers: finalHeaders }) } return res } diff --git a/src/core/http/index.ts b/src/core/http/index.ts index 7cd706b9d..060d1aa6b 100644 --- a/src/core/http/index.ts +++ b/src/core/http/index.ts @@ -11,5 +11,14 @@ export { responseErrorMessage, ApiError, isAbortError, + registerApiErrorListener, + type ApiErrorListener, + type ApiErrorRequest, type FetchLike, } from './apiClient' +export { + ambientRequestHeaders, + registerRequestHeaderProvider, + withAmbientHeaders, + type RequestHeaderProvider, +} from './requestHeaders' diff --git a/src/core/http/requestHeaders.ts b/src/core/http/requestHeaders.ts new file mode 100644 index 000000000..ed869511d --- /dev/null +++ b/src/core/http/requestHeaders.ts @@ -0,0 +1,45 @@ +/** + * Ambient request headers — headers every admin request carries without each + * call site naming them. Today that is the branch header: the admin shell + * registers a provider that reads the active branch, and `apiRequest` plus + * the few hand-rolled `fetch` sites (streams, uploads) merge the provider's + * headers into their own. Explicit per-call headers always win. + */ + +export type RequestHeaderProvider = () => Record + +let provider: RequestHeaderProvider | null = null + +export function registerRequestHeaderProvider(next: RequestHeaderProvider | null): void { + provider = next +} + +/** The ambient headers at this moment — `{}` before the shell registers a provider. */ +export function ambientRequestHeaders(): Record { + return provider ? provider() : {} +} + +/** + * Merge the ambient headers underneath `init.headers`. For the hand-rolled + * `fetch` sites that cannot go through `apiRequest` (NDJSON streams, XHR + * uploads, FormData bodies with their own content type). The caller's header + * container keeps its shape: a plain object stays a plain object. + */ +export function withAmbientHeaders(init: RequestInit = {}): RequestInit { + const ambient = ambientRequestHeaders() + if (Object.keys(ambient).length === 0) return init + const own = init.headers + if (own instanceof Headers) { + const merged = new Headers(own) + for (const [name, value] of Object.entries(ambient)) { + if (!merged.has(name)) merged.set(name, value) + } + return { ...init, headers: merged } + } + if (Array.isArray(own)) { + const present = new Set(own.map(([name]) => name.toLowerCase())) + const extra = Object.entries(ambient).filter(([name]) => !present.has(name.toLowerCase())) + return { ...init, headers: [...own, ...extra] } + } + return { ...init, headers: { ...ambient, ...(own ?? {}) } } +} diff --git a/src/core/loops/sources/dataRows.ts b/src/core/loops/sources/dataRows.ts index 7a49d6c83..944b38391 100644 --- a/src/core/loops/sources/dataRows.ts +++ b/src/core/loops/sources/dataRows.ts @@ -19,6 +19,7 @@ * - tableId (required) — the data table to iterate */ +import { MAIN_BRANCH_ID, physicalId } from '@core/branches' import type { LoopEntitySource, LoopFetchResult, LoopItem, LoopSourceDb } from '@core/loops/types' import { cellFilterSql, cellOrderSql, parseCellFilter, parseCellOrder, type CellFilter } from '../cellFilter' import { isoDate } from '../../utils/isoDate' @@ -203,7 +204,7 @@ async function fetchPage( const offsetParam = positionalParam(db, 3 + before) const { rows } = await db.unsafe( `select data_row_versions.id as version_id, - data_rows.id as row_id, + data_rows.logical_id as row_id, data_rows.table_id, data_tables.slug as table_slug, data_tables.kind as table_kind, @@ -340,9 +341,17 @@ async function fetchDataKindPage( db: LoopSourceDb, orderBy: OrderColumn, direction: 'asc' | 'desc', - opts: { tableId: string; limit: number; offset: number; filter: CellFilter | null; orderCellField: string | null }, + opts: { + tableId: string + limit: number + offset: number + filter: CellFilter | null + orderCellField: string | null + /** Post-type drafts (a branch): skip rows explicitly taken offline. */ + excludeUnpublished: boolean + }, ): Promise { - const { tableId, limit, offset, filter, orderCellField } = opts + const { tableId, limit, offset, filter, orderCellField, excludeUnpublished } = opts const sortKey: 'createdAt' | 'updatedAt' | 'slug' = orderBy === 'updatedAt' ? 'updatedAt' : orderBy === 'slug' ? 'slug' : 'createdAt' const column = 'data_rows.cells_json' @@ -363,7 +372,7 @@ async function fetchDataKindPage( // Same safety contract as `fetchPage`: the ORDER BY text comes only from // the closed map above; every runtime value is a positional parameter. const { rows } = await db.unsafe( - `select data_rows.id as row_id, + `select data_rows.logical_id as row_id, data_rows.table_id, data_tables.slug as table_slug, data_tables.route_base as table_route_base, @@ -382,6 +391,7 @@ async function fetchDataKindPage( where data_rows.table_id = ${positionalParam(db, 1)} and data_rows.deleted_at is null and data_tables.deleted_at is null + ${excludeUnpublished ? "and data_rows.status <> 'unpublished'" : ''} ${cell ? `and ${cell.sql}` : ''} order by ${orderColumn} ${direction}, data_rows.id ${direction} limit ${limitParam} offset ${offsetParam}`, @@ -414,6 +424,12 @@ export async function fetchPublishedDataRowItems( offset: number /** Optional condition on one of the row's own cells. */ cellFilter?: CellFilter | null + /** + * Read post-type rows as DRAFTS instead of through their published + * versions — a branch has no versions (publishing is main-only), so its + * loops show what the branch would publish. + */ + drafts?: boolean }, ): Promise { if (!opts.tableId) return { items: [], totalItems: 0 } @@ -440,7 +456,8 @@ export async function fetchPublishedDataRowItems( : 'publishedAt' const direction: 'asc' | 'desc' = opts.direction === 'asc' ? 'asc' : 'desc' - if (table.kind === 'data') { + const excludeUnpublished = table.kind !== 'data' && opts.drafts === true + if (table.kind === 'data' || opts.drafts) { // The count must apply the same condition, or pagination advertises rows // the page query filters out. const dataCountCell = cellFilter @@ -451,6 +468,7 @@ export async function fetchPublishedDataRowItems( from data_rows where data_rows.table_id = ${positionalParam(db, 1)} and data_rows.deleted_at is null + ${excludeUnpublished ? "and data_rows.status <> 'unpublished'" : ''} ${dataCountCell ? `and ${dataCountCell.sql}` : ''}`, [opts.tableId, ...(dataCountCell?.params ?? [])], ) @@ -463,6 +481,7 @@ export async function fetchPublishedDataRowItems( offset: opts.offset, filter: cellFilter, orderCellField, + excludeUnpublished, }) const mediaPathMap = await resolveMediaIdsToPaths(db, collectMediaIds(sqlRows, fields)) return { @@ -574,8 +593,12 @@ export const DataRowsSource: LoopEntitySource = { ], async fetch(ctx): Promise { + // The loop stores the table's logical id; the SQL below addresses the + // physical table row of the branch being rendered. + const logicalTableId = typeof ctx.filters.tableId === 'string' ? ctx.filters.tableId : '' return fetchPublishedDataRowItems(ctx.db, { - tableId: typeof ctx.filters.tableId === 'string' ? ctx.filters.tableId : '', + tableId: logicalTableId ? physicalId(ctx.branchId ?? MAIN_BRANCH_ID, logicalTableId) : '', + drafts: (ctx.branchId ?? MAIN_BRANCH_ID) !== MAIN_BRANCH_ID, orderBy: ctx.orderBy, direction: ctx.direction, limit: ctx.limit, diff --git a/src/core/loops/types.ts b/src/core/loops/types.ts index b77a0e983..810d5cdbd 100644 --- a/src/core/loops/types.ts +++ b/src/core/loops/types.ts @@ -131,6 +131,13 @@ export interface SourceFetchContext { * because cookies would fragment the Layer B cache per visitor. */ request?: SourceRequestContext + /** + * Branch whose rows the source reads. Publishing and public rendering run + * on `main`; the editor's runtime preview and branch previews pass the + * branch being viewed so `data.rows` addresses that branch's tables (see + * `@core/branches`). Absent means main. + */ + branchId?: string } /** diff --git a/src/core/persistence/cms.ts b/src/core/persistence/cms.ts index ee622678c..0024c7f51 100644 --- a/src/core/persistence/cms.ts +++ b/src/core/persistence/cms.ts @@ -7,7 +7,7 @@ import type { } from './types' import { SaveConflictError, SaveConflictsEnvelopeSchema } from './saveConflict' import { parseJsonResponse } from '@core/utils/jsonValidate' -import { assertOk, readEnvelope, type FetchLike } from '@core/http' +import { assertOk, readEnvelope, withAmbientHeaders, type FetchLike } from '@core/http' import { CmsSiteEnvelopeSchema, CmsSiteDocumentSaveEnvelopeSchema, @@ -23,7 +23,7 @@ import { savedLayoutFromRow } from '@core/data/layoutFromRow' import type { VisualComponent } from '@core/visualComponents' import type { SavedLayout } from '@core/layouts' -const defaultFetch: FetchLike = (input, init) => globalThis.fetch(input, init) +const defaultFetch: FetchLike = (input, init) => globalThis.fetch(input, withAmbientHeaders(init)) export class CmsAdapter implements IPersistenceAdapter { private readonly fetchImpl: FetchLike diff --git a/src/core/persistence/cmsBranches.ts b/src/core/persistence/cmsBranches.ts new file mode 100644 index 000000000..203867b2c --- /dev/null +++ b/src/core/persistence/cmsBranches.ts @@ -0,0 +1,109 @@ +/** + * Client-side persistence layer for site branches: + * GET /admin/api/cms/branches + * POST /admin/api/cms/branches + * PATCH /admin/api/cms/branches/:id + * DELETE /admin/api/cms/branches/:id + * + * These calls address the registry, not a branch's content, so they never + * depend on the active-branch header (the server ignores it here). + */ +import { apiRequest } from '@core/http' +import { + ApplyMergeEnvelopeSchema, + BranchEnvelopeSchema, + BranchListEnvelopeSchema, + BranchPreviewLinkEnvelopeSchema, + BranchPreviewStateEnvelopeSchema, + MergePlanEnvelopeSchema, + type ApplyMergeBody, + type BranchPreview, + type CreateBranchBody, + type MergeDirection, + type MergePlan, + type RenameBranchBody, + type SiteBranch, +} from '@core/branches' + +const BRANCHES_PATH = '/admin/api/cms/branches' + +export async function listCmsBranches(signal?: AbortSignal): Promise { + const payload = await apiRequest(BRANCHES_PATH, { + schema: BranchListEnvelopeSchema, + signal, + fallbackMessage: 'Failed to load branches', + }) + return payload.branches +} + +export async function createCmsBranch(body: CreateBranchBody): Promise { + const payload = await apiRequest(BRANCHES_PATH, { + method: 'POST', + body, + schema: BranchEnvelopeSchema, + fallbackMessage: 'Failed to create branch', + }) + return payload.branch +} + +export async function renameCmsBranch(id: string, body: RenameBranchBody): Promise { + const payload = await apiRequest(`${BRANCHES_PATH}/${encodeURIComponent(id)}`, { + method: 'PATCH', + body, + schema: BranchEnvelopeSchema, + fallbackMessage: 'Failed to rename branch', + }) + return payload.branch +} + +export async function deleteCmsBranch(id: string): Promise { + await apiRequest(`${BRANCHES_PATH}/${encodeURIComponent(id)}`, { + method: 'DELETE', + fallbackMessage: 'Failed to delete branch', + }) +} + +export async function getCmsBranchPreview(id: string): Promise { + const payload = await apiRequest(`${BRANCHES_PATH}/${encodeURIComponent(id)}/preview`, { + schema: BranchPreviewStateEnvelopeSchema, + fallbackMessage: 'Failed to load the preview link', + }) + return payload.preview +} + +/** Issue a fresh preview link (retiring the previous one) and return its URL. */ +export async function issueCmsBranchPreview(id: string): Promise<{ url: string; preview: BranchPreview }> { + return apiRequest(`${BRANCHES_PATH}/${encodeURIComponent(id)}/preview`, { + method: 'POST', + schema: BranchPreviewLinkEnvelopeSchema, + fallbackMessage: 'Failed to create the preview link', + }) +} + +export async function revokeCmsBranchPreview(id: string): Promise { + await apiRequest(`${BRANCHES_PATH}/${encodeURIComponent(id)}/preview`, { + method: 'DELETE', + fallbackMessage: 'Failed to revoke the preview link', + }) +} + +export async function getCmsBranchMergePlan(id: string, direction: MergeDirection): Promise { + const payload = await apiRequest(`${BRANCHES_PATH}/${encodeURIComponent(id)}/${direction}`, { + schema: MergePlanEnvelopeSchema, + fallbackMessage: direction === 'merge' ? 'Failed to plan the merge' : 'Failed to plan the update', + }) + return payload.plan +} + +export async function applyCmsBranchMerge( + id: string, + direction: MergeDirection, + body: ApplyMergeBody, +): Promise<{ plan: MergePlan; branchDeleted: boolean }> { + return apiRequest(`${BRANCHES_PATH}/${encodeURIComponent(id)}/${direction}`, { + method: 'POST', + body, + schema: ApplyMergeEnvelopeSchema, + fallbackMessage: direction === 'merge' ? 'Failed to merge the branch' : 'Failed to update the branch', + }) +} diff --git a/src/core/persistence/cmsData.ts b/src/core/persistence/cmsData.ts index 7d755df86..2288e763a 100644 --- a/src/core/persistence/cmsData.ts +++ b/src/core/persistence/cmsData.ts @@ -1,5 +1,6 @@ import { Type } from '@sinclair/typebox' import type { + DataRowVersionSummary, DataTable, DataTableListItem, DataRow, @@ -12,6 +13,7 @@ import type { } from '@core/data/schemas' import type { DeletedRowSummary } from '@core/data/schemas' import { + DataRowVersionSummarySchema, DataMetaSchema, DataRowSchema, DataTableListItemSchema, @@ -21,7 +23,7 @@ import { } from '@core/data/schemas' import type { LoopItem } from '@core/loops/types' import { LoopItemSchema } from '@core/loops/types' -import { apiRequest, assertOk, ApiError, type FetchLike } from '@core/http' +import { apiRequest, assertOk, ApiError, withAmbientHeaders, type FetchLike } from '@core/http' // --------------------------------------------------------------------------- // Envelope schemas @@ -428,13 +430,14 @@ export async function previewCmsDataRow( ): Promise { const fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis) const basePath = options.basePath ?? '/admin/api/cms' - const res = await fetchImpl(`${basePath}/data/rows/${encodeURIComponent(rowId)}/preview`, { + // Own fetch (HTML response), so the branch header is merged in by hand. + const res = await fetchImpl(`${basePath}/data/rows/${encodeURIComponent(rowId)}/preview`, withAmbientHeaders({ method: 'POST', credentials: 'include', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ cells: options.cells ?? {} }), signal: options.signal, - }) + })) // The preview endpoint returns an HTML document on success; assertOk reads the // standard `{ error }` envelope (then raw text, then fallback) on failure. await assertOk(res, `CMS data row preview failed with ${res.status}`) @@ -478,3 +481,33 @@ export async function getDataMeta( }) return body.meta } + +const VersionsEnvelope = Type.Object({ versions: Type.Array(DataRowVersionSummarySchema) }) + +/** Every published version of a row, newest first. */ +export async function listCmsDataRowVersions( + rowId: string, + signal?: AbortSignal, + basePath = '/admin/api/cms', +): Promise { + const body = await apiRequest(`${basePath}/data/rows/${encodeURIComponent(rowId)}/versions`, { + schema: VersionsEnvelope, + signal, + fallbackMessage: 'Failed to load version history', + }) + return body.versions +} + +/** Copy a published version back into the row's draft on the active branch. */ +export async function restoreCmsDataRowVersion( + rowId: string, + versionId: string, + basePath = '/admin/api/cms', +): Promise { + const body = await apiRequest( + `${basePath}/data/rows/${encodeURIComponent(rowId)}/versions/${encodeURIComponent(versionId)}/restore`, + { method: 'POST', schema: RowEnvelope, fallbackMessage: 'Failed to restore the version' }, + ) + if (!body.row) throw new ApiError('Failed to restore the version', 500) + return body.row +} diff --git a/src/core/persistence/cmsFonts.ts b/src/core/persistence/cmsFonts.ts index 94a601b05..1fc3ad6ea 100644 --- a/src/core/persistence/cmsFonts.ts +++ b/src/core/persistence/cmsFonts.ts @@ -16,7 +16,7 @@ */ import type { FontEntry } from '@core/fonts' -import { apiRequest, type FetchLike } from '@core/http' +import { apiRequest, withAmbientHeaders, type FetchLike } from '@core/http' import { type CmsFontEstimateDto, CmsFontEntryEnvelopeSchema, @@ -25,7 +25,7 @@ import { type GoogleFontFamilyDto, } from './responseSchemas' -const defaultFetch: FetchLike = (input, init) => globalThis.fetch(input, init) +const defaultFetch: FetchLike = (input, init) => globalThis.fetch(input, withAmbientHeaders(init)) export async function listCmsGoogleFonts( fetchImpl: FetchLike = defaultFetch, diff --git a/src/core/persistence/cmsTransfer.ts b/src/core/persistence/cmsTransfer.ts index 4e18b1e35..afa2a0d82 100644 --- a/src/core/persistence/cmsTransfer.ts +++ b/src/core/persistence/cmsTransfer.ts @@ -21,7 +21,7 @@ import { type SiteBundleArchiveManifest, } from '@core/data/bundleArchive' import { parseValue, formatValueErrors, compiled } from '@core/utils/typeboxHelpers' -import { apiRequest, readEnvelope } from '@core/http' +import { apiRequest, readEnvelope, withAmbientHeaders } from '@core/http' const ZIP_LOCAL_FILE_HEADER = 0x04034b50 const ZIP_STORED_METHOD = 0 @@ -67,7 +67,8 @@ export class SiteBundleParseError extends Error { * * The export endpoint returns a zip attachment, not the standard `{ data, * error }` envelope — so this helper intentionally does not use `apiRequest` - * / `readEnvelope`. + * / `readEnvelope`. A form POST cannot carry the ambient branch header + * either, so the request names its branch in `branchId` (omitted on main). */ export function submitSiteBundleExport(opts: ExportRequest): void { if (typeof document === 'undefined' || !document.body) { @@ -167,12 +168,12 @@ export async function getExportSummary(signal?: AbortSignal): Promise { - const res = await fetch('/admin/api/cms/import/preview', { + const res = await fetch('/admin/api/cms/import/preview', withAmbientHeaders({ method: 'POST', credentials: 'include', headers: { 'content-type': 'application/json' }, body: JSON.stringify(bundle), - }) + })) return readEnvelope(res, BundlePreviewSchema, 'Failed to preview bundle') } @@ -192,12 +193,12 @@ export async function importSiteBundle( bundle: SiteBundle, strategy: ImportStrategy, ): Promise { - const res = await fetch(`/admin/api/cms/import?strategy=${encodeURIComponent(strategy)}`, { + const res = await fetch(`/admin/api/cms/import?strategy=${encodeURIComponent(strategy)}`, withAmbientHeaders({ method: 'POST', credentials: 'include', headers: { 'content-type': 'application/json' }, body: JSON.stringify(bundle), - }) + })) return readEnvelope(res, ImportResultSchema, 'Failed to import bundle') } @@ -209,12 +210,12 @@ export async function importSiteBundleArchive( const params = new URLSearchParams({ strategy }) if (selection) params.set('selection', JSON.stringify(selection)) - const res = await fetch(`/admin/api/cms/import/archive?${params.toString()}`, { + const res = await fetch(`/admin/api/cms/import/archive?${params.toString()}`, withAmbientHeaders({ method: 'POST', credentials: 'include', headers: { 'content-type': 'application/zip' }, body: archiveFile, - }) + })) return readEnvelope(res, ImportResultSchema, 'Failed to import bundle') } diff --git a/src/core/persistence/index.ts b/src/core/persistence/index.ts index 27ae9be94..791cb4e98 100644 --- a/src/core/persistence/index.ts +++ b/src/core/persistence/index.ts @@ -25,6 +25,8 @@ export { updateCmsDataRowStatus, updateCmsDataRowTable, updateCmsDataTable, + listCmsDataRowVersions, + restoreCmsDataRowVersion, } from './cmsData' export { @@ -95,3 +97,14 @@ export type { } from './cmsAuth' // usePersistence moved to src/editor/hooks/usePersistence.ts (Constraint #179 — no React in core) +export { + listCmsBranches, + createCmsBranch, + renameCmsBranch, + deleteCmsBranch, + getCmsBranchPreview, + issueCmsBranchPreview, + revokeCmsBranchPreview, + getCmsBranchMergePlan, + applyCmsBranchMerge, +} from './cmsBranches' diff --git a/src/core/publisher/render.ts b/src/core/publisher/render.ts index f4b6eaad7..2174b9432 100644 --- a/src/core/publisher/render.ts +++ b/src/core/publisher/render.ts @@ -110,6 +110,13 @@ interface PublishPageOptions { * four CSS files cacheable. */ cssEmission?: 'inline' | 'external' + /** + * How request-dependent nodes render. `'holes'` (default) emits + * `` placeholders the Layer C runtime hydrates per request; + * `'inline'` renders them in place from the supplied `loopData` — for a + * one-off render that already carries its request context (branch previews). + */ + dynamicNodes?: 'holes' | 'inline' /** * Pre-built site CSS bundle. Required when `cssEmission === 'external'`. * Computed once per published-snapshot via `buildSiteCssBundle(site, registry)`. @@ -540,7 +547,9 @@ export function publishPage( // Layer C: classify every node as static or dynamic before walking the tree. // Dynamic node ids are threaded into the RenderConfig so renderNode can // emit placeholders instead of recursing. - const dynamicNodeIds = findDynamicNodeIds(page, site, registry) + const dynamicNodeIds = options.dynamicNodes === 'inline' + ? new Set() + : findDynamicNodeIds(page, site, registry) // Composed once per page render: the walker reads it through the config, // and the builder interpolates {source.field} tokens in the diff --git a/src/core/utils/canonicalJson.ts b/src/core/utils/canonicalJson.ts new file mode 100644 index 000000000..39313238f --- /dev/null +++ b/src/core/utils/canonicalJson.ts @@ -0,0 +1,17 @@ +/** + * Deterministic JSON serialisation — object keys sorted at every depth, so two + * structurally equal values always produce the same string. The input to + * every content hash (publish snapshots, branch bases). + */ +export function canonicalJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(canonicalJson).join(',')}]` + } + if (value && typeof value === 'object') { + const record = value as Record + return `{${Object.keys(record).sort().map((key) => + `${JSON.stringify(key)}:${canonicalJson(record[key])}` + ).join(',')}}` + } + return JSON.stringify(value) +} diff --git a/src/admin/pages/site/panels/AgentPanel/relativeTime.ts b/src/core/utils/relativeTime.ts similarity index 100% rename from src/admin/pages/site/panels/AgentPanel/relativeTime.ts rename to src/core/utils/relativeTime.ts diff --git a/tests/e2e/branches.e2e.ts b/tests/e2e/branches.e2e.ts new file mode 100644 index 000000000..9356275d0 --- /dev/null +++ b/tests/e2e/branches.e2e.ts @@ -0,0 +1,213 @@ +import { mkdir } from 'node:fs/promises' +import { expect, test, type Page } from '@playwright/test' +import { ANONYMOUS_STATE, completeStepUp, createPage, login, openSiteEditor, openSitePanel } from './helpers' + +/** + * Site branches — the toolbar switcher, the context strip, publish gating, + * the manage dialog, and the palette commands (BRANCH-001 … BRANCH-004). + * + * Every step also captures evidence under `.tmp/evidence/branches-*.png` so + * the switcher can be reviewed visually after a run. + */ + +const EVIDENCE_DIR = '.tmp/evidence' +const VIEWPORT = { width: 1440, height: 900 } +const TOP_CLIP = { x: 0, y: 0, width: 1440, height: 96 } + +async function shot(page: Page, name: string, clip: 'top' | 'full' = 'top'): Promise { + await page.screenshot({ + path: `${EVIDENCE_DIR}/branches-${name}.png`, + clip: clip === 'top' ? TOP_CLIP : undefined, + fullPage: false, + }) +} + +test.use({ viewport: VIEWPORT }) + +test.beforeAll(async () => { + await mkdir(EVIDENCE_DIR, { recursive: true }) +}) + +test('create a branch from the toolbar, edit on it, and return to main (BRANCH-001)', async ({ page }) => { + await openSiteEditor(page) + const chip = page.getByTestId('branch-chip') + await expect(chip).toBeVisible() + await expect(page.getByTestId('branch-strip')).toHaveCount(0) + // Not gated by a branch (main may already be published by an earlier spec). + await expect(page.getByTestId('toolbar-publish-btn')).not.toHaveAccessibleName(/Cannot publish/) + await shot(page, '1-main') + + await chip.click() + const menu = page.getByRole('menu', { name: 'Branches' }) + await expect(menu).toBeVisible() + await expect(page.getByTestId('branch-row-main')).toBeVisible() + // The platform pill renders its whole word, tinted, not a clipped dot. + const livePill = page.getByTestId('branch-row-main').locator('span[data-size]', { hasText: 'Live' }) + await expect(livePill).toHaveText('Live') + expect((await livePill.boundingBox())?.width ?? 0).toBeGreaterThan(30) + await shot(page, '2-palette', 'full') + + await page.getByTestId('branch-create-action').click() + await expect(page.getByTestId('branch-create-form')).toBeVisible() + await page.getByTestId('branch-create-name').fill('Spring Redesign') + await expect(page.getByText('Will be created as spring-redesign')).toBeVisible() + await shot(page, '3-creator', 'full') + + await page.getByTestId('branch-create-submit').click() + const strip = page.getByTestId('branch-strip') + await expect(strip).toBeVisible() + await expect(strip).toContainText('Spring Redesign') + // The chip stays icon-only: the strip right above it already names the branch. + await expect(chip).not.toContainText('Spring Redesign') + await expect(strip).toContainText('from main') + // Publishing only exists on main — disabled with the reason inline. + await expect(page.getByTestId('toolbar-publish-btn')).toBeDisabled() + // No neutral "On " status: the strip already names the branch. + await expect(page.getByText('On Spring Redesign')).toHaveCount(0) + await expect(page.getByText('A change was reverted')).toHaveCount(0) + await page.waitForTimeout(600) + await shot(page, '4-on-branch') + + // The branch survives a reload of this tab. + await page.reload() + await openSiteEditor(page) + await expect(page.getByTestId('branch-strip')).toContainText('Spring Redesign') + + // Back to main from the strip. + await page.getByTestId('branch-strip-more').click() + await page.getByTestId('branch-strip-switch-main').click() + await expect(page.getByTestId('branch-strip')).toHaveCount(0) + // Not gated by a branch (main may already be published by an earlier spec). + await expect(page.getByTestId('toolbar-publish-btn')).not.toHaveAccessibleName(/Cannot publish/) +}) + +test('search switches, the palette command opens the switcher, and main is offered back (BRANCH-002)', async ({ page }) => { + await openSiteEditor(page) + await page.getByTestId('branch-chip').click() + await page.getByRole('combobox').fill('spring') + await expect(page.getByTestId('branch-row-spring-redesign')).toBeVisible() + await page.keyboard.press('Enter') + await expect(page.getByTestId('branch-strip')).toContainText('Spring Redesign') + + await page.keyboard.press('Meta+k') + const spotlight = page.getByRole('dialog', { name: /command/i }).or(page.getByTestId('spotlight')) + await expect(spotlight.first()).toBeVisible() + await page.keyboard.type('switch to main') + await page.getByRole('option', { name: 'Switch to main', exact: true }).click() + await expect(page.getByTestId('branch-strip')).toHaveCount(0) +}) + +// Deleting a branch steps up, and step-up rotates the session token — so +// these two run on their own fresh login instead of the shared owner state. +test.describe('step-up flows', () => { + test.use({ storageState: ANONYMOUS_STATE }) + +test('rename and delete through the manage dialog (BRANCH-003)', async ({ page }) => { + await login(page) + await openSiteEditor(page) + await page.getByTestId('branch-chip').click() + await page.getByTestId('branch-manage-action').click() + const dialog = page.getByRole('dialog', { name: 'Branches' }) + await expect(dialog).toBeVisible() + await expect(page.getByTestId('branch-manage-row-spring-redesign')).toBeVisible() + await shot(page, '5-manage', 'full') + + // Search narrows the list; clearing it brings every branch back. + await page.getByTestId('branch-manage-search').fill('spring') + await expect(page.getByTestId('branch-manage-row-main')).toHaveCount(0) + await expect(page.getByTestId('branch-manage-row-spring-redesign')).toBeVisible() + await page.getByTestId('branch-manage-search').fill('') + await expect(page.getByTestId('branch-manage-row-main')).toBeVisible() + + await page.getByTestId('branch-manage-rename-spring-redesign').click() + await page.getByTestId('branch-manage-rename-input').fill('Spring 2027') + await page.keyboard.press('Enter') + await expect(page.getByTestId('branch-manage-row-spring-redesign')).toContainText('Spring 2027') + + await page.getByTestId('branch-manage-delete-spring-redesign').click() + await page.getByTestId('branch-delete-confirm').click() + await completeStepUp(page) + await expect(page.getByTestId('branch-manage-row-spring-redesign')).toHaveCount(0) + await page.getByRole('button', { name: 'Done' }).click() + await expect(page.getByTestId('branch-strip')).toHaveCount(0) +}) + +test('share a preview link and open it as a visitor (BRANCH-004)', async ({ page, context }) => { + await login(page) + await openSiteEditor(page) + await page.getByTestId('branch-chip').click() + await page.getByTestId('branch-create-action').click() + await page.getByTestId('branch-create-name').fill('Preview Link') + await page.getByTestId('branch-create-submit').click() + await expect(page.getByTestId('branch-strip')).toBeVisible() + + const issued = page.waitForResponse((res) => res.url().includes('/preview') && res.request().method() === 'POST') + await page.getByTestId('branch-strip-share').click() + const { url } = (await (await issued).json()) as { url: string } + expect(url).toContain('/_instatic/preview/') + await expect(page.getByTestId('branch-strip-preview-active')).toBeVisible() + await page.waitForTimeout(400) + await shot(page, '6-preview-shared') + + // A visitor with the link, no admin session. + const visitor = await context.browser()!.newContext({ viewport: VIEWPORT }) + const visitorPage = await visitor.newPage() + await visitorPage.goto(url) + await expect(visitorPage.getByRole('status')).toContainText('Previewing branch Preview Link') + await visitorPage.screenshot({ path: `${EVIDENCE_DIR}/branches-7-visitor-preview.png`, fullPage: false }) + await visitorPage.getByRole('link', { name: 'Exit preview' }).click() + await expect(visitorPage.getByRole('status')).toHaveCount(0) + await visitor.close() + + // Revoke from the strip; the link is dead afterwards. + await page.getByTestId('branch-strip-more').click() + await page.getByTestId('branch-strip-revoke').click() + await expect(page.getByTestId('branch-strip-preview-active')).toHaveCount(0) + const visitorAfter = await context.browser()!.newContext({ viewport: VIEWPORT }) + const deadPage = await visitorAfter.newPage() + await deadPage.goto(url) + await expect(deadPage.getByRole('status')).toHaveCount(0) + await visitorAfter.close() + + // Clean up so the manage test's expectations hold on reruns. + await page.getByTestId('branch-strip-more').click() + await page.getByTestId('branch-strip-delete').click() + await page.getByTestId('branch-delete-confirm').click() + await completeStepUp(page) + await expect(page.getByTestId('branch-strip')).toHaveCount(0) +}) + +test('merge a branch into main from the review dialog (BRANCH-005)', async ({ page }) => { + await login(page) + await openSiteEditor(page) + await page.getByTestId('branch-chip').click() + await page.getByTestId('branch-create-action').click() + await page.getByTestId('branch-create-name').fill('Merge Me') + await page.getByTestId('branch-create-submit').click() + await expect(page.getByTestId('branch-strip')).toBeVisible() + + // A page that exists only on the branch. + await createPage(page, 'Branch Page', 'branch-page') + // Creating a page inside the bind round trip must not be reset as stale. + await expect(page.getByText('A change was reverted')).toHaveCount(0) + + await page.getByTestId('branch-strip-merge').click() + const dialog = page.getByRole('dialog', { name: 'Merge Merge Me into main' }) + await expect(dialog).toBeVisible() + await expect(page.getByTestId('branch-merge-summary')).toContainText('1 change') + // A page that exists only on the branch cannot conflict with main. + await expect(dialog.getByText(/Both sides changed|Deleted on one side/)).toHaveCount(0) + await expect(dialog.getByText('Branch Page')).toBeVisible() + await page.waitForTimeout(400) + await shot(page, '8-merge-review', 'full') + + await page.getByTestId('branch-merge-apply').click() + await completeStepUp(page) + await expect(dialog).toBeHidden() + // The branch was deleted after merging, so the tab is back on main … + await expect(page.getByTestId('branch-strip')).toHaveCount(0) + // … where the page now exists. + await openSitePanel(page) + await expect(page.getByRole('treeitem', { name: 'Open page Branch Page' })).toBeVisible() +}) +}) diff --git a/tests/e2e/version-history.e2e.ts b/tests/e2e/version-history.e2e.ts new file mode 100644 index 000000000..13f50c0a7 --- /dev/null +++ b/tests/e2e/version-history.e2e.ts @@ -0,0 +1,36 @@ +import { expect, test } from '@playwright/test' +import { ANONYMOUS_STATE, createPage, login, openSiteEditor, publishDraft } from './helpers' + +/** + * Version history — published versions of the active page listed from the + * publish menu, and one restored into the draft (VERSION-001). + * + * Publishing steps up, which rotates the session token, so this spec runs + * on its own fresh login instead of the shared owner state. It publishes a + * page of its own, so the history it asserts on does not depend on what + * earlier specs published. + */ + +test.use({ storageState: ANONYMOUS_STATE }) + +test('lists published versions of the active page and restores one (VERSION-001)', async ({ page }) => { + await login(page) + await openSiteEditor(page) + await createPage(page, 'Version History Page', `version-history-${Date.now()}`) + await page.getByRole('treeitem', { name: 'Open page Version History Page' }).click() + await publishDraft(page) + + await page.getByTestId('toolbar-publish-actions-trigger').click() + await page.getByTestId('toolbar-version-history-action').click() + const dialog = page.getByRole('dialog', { name: 'Version history' }) + await expect(dialog).toBeVisible() + const latest = page.getByTestId('version-row-1') + await expect(latest).toBeVisible() + await expect(latest).toContainText('Latest') + + await page.getByTestId('version-restore-1').click() + await expect(latest).toContainText('Replace the current draft') + await page.getByTestId('version-restore-confirm-1').click() + await expect(dialog).toBeHidden() + await expect(page.getByRole('status').filter({ hasText: 'Restored version 1' })).toBeVisible() +}) diff --git a/vendor/pixel-art-icons/dist/icons/archive-restore-solid.d.ts b/vendor/pixel-art-icons/dist/icons/archive-restore-solid.d.ts new file mode 100644 index 000000000..884627e80 --- /dev/null +++ b/vendor/pixel-art-icons/dist/icons/archive-restore-solid.d.ts @@ -0,0 +1,3 @@ +import React from 'react'; +import type { IconProps } from '../types'; +export declare function ArchiveRestoreSolidIcon({ size, color, className, style }: IconProps): React.ReactElement; diff --git a/vendor/pixel-art-icons/dist/icons/archive-restore-solid.js b/vendor/pixel-art-icons/dist/icons/archive-restore-solid.js new file mode 100644 index 000000000..b892413e5 --- /dev/null +++ b/vendor/pixel-art-icons/dist/icons/archive-restore-solid.js @@ -0,0 +1,4 @@ +import { jsx as _jsx } from "react/jsx-runtime"; +export function ArchiveRestoreSolidIcon({ size = 24, color = 'currentColor', className, style }) { + return (_jsx("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: color, xmlns: "http://www.w3.org/2000/svg", className: className, style: style, children: _jsx("path", { d: "M21 20h-2v2h-6v-5h4v-2h-2v-2h-2v-2h-2v2H9v2H7v2h4v5H5v-2H3V9h18v11Zm0-16h2v3H1V4h2V2h18v2Z" }) })); +} diff --git a/vendor/pixel-art-icons/dist/icons/circle-dot-solid.d.ts b/vendor/pixel-art-icons/dist/icons/circle-dot-solid.d.ts new file mode 100644 index 000000000..62c116c6a --- /dev/null +++ b/vendor/pixel-art-icons/dist/icons/circle-dot-solid.d.ts @@ -0,0 +1,3 @@ +import React from 'react'; +import type { IconProps } from '../types'; +export declare function CircleDotSolidIcon({ size, color, className, style }: IconProps): React.ReactElement; diff --git a/vendor/pixel-art-icons/dist/icons/circle-dot-solid.js b/vendor/pixel-art-icons/dist/icons/circle-dot-solid.js new file mode 100644 index 000000000..77be7064c --- /dev/null +++ b/vendor/pixel-art-icons/dist/icons/circle-dot-solid.js @@ -0,0 +1,4 @@ +import { jsx as _jsx } from "react/jsx-runtime"; +export function CircleDotSolidIcon({ size = 24, color = 'currentColor', className, style }) { + return (_jsx("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: color, xmlns: "http://www.w3.org/2000/svg", className: className, style: style, children: _jsx("path", { d: "M18 4h2v2h2v12h-2v2h-2v2H6v-2H4v-2H2V6h2V4h2V2h12v2Zm-7 9h2v-2h-2v2Z" }) })); +} diff --git a/vendor/pixel-art-icons/dist/icons/git-branch-solid.d.ts b/vendor/pixel-art-icons/dist/icons/git-branch-solid.d.ts new file mode 100644 index 000000000..7befe13c7 --- /dev/null +++ b/vendor/pixel-art-icons/dist/icons/git-branch-solid.d.ts @@ -0,0 +1,3 @@ +import React from 'react'; +import type { IconProps } from '../types'; +export declare function GitBranchSolidIcon({ size, color, className, style }: IconProps): React.ReactElement; diff --git a/vendor/pixel-art-icons/dist/icons/git-branch-solid.js b/vendor/pixel-art-icons/dist/icons/git-branch-solid.js new file mode 100644 index 000000000..32dacf248 --- /dev/null +++ b/vendor/pixel-art-icons/dist/icons/git-branch-solid.js @@ -0,0 +1,4 @@ +import { jsx as _jsx } from "react/jsx-runtime"; +export function GitBranchSolidIcon({ size = 24, color = 'currentColor', className, style }) { + return (_jsx("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: color, xmlns: "http://www.w3.org/2000/svg", className: className, style: style, children: _jsx("path", { d: "M8 16h2v4H8v2H4v-2H2v-4h2v-2h4v2Zm9 3h-5v-2h5v2Zm2-2h-2v-5h2v5ZM7 12H5V2h2v10Zm13-8h2v4h-2v2h-4V8h-2V4h2V2h4v2Z" }) })); +} diff --git a/vendor/pixel-art-icons/dist/icons/git-merge-solid.d.ts b/vendor/pixel-art-icons/dist/icons/git-merge-solid.d.ts new file mode 100644 index 000000000..cf775cbbc --- /dev/null +++ b/vendor/pixel-art-icons/dist/icons/git-merge-solid.d.ts @@ -0,0 +1,3 @@ +import React from 'react'; +import type { IconProps } from '../types'; +export declare function GitMergeSolidIcon({ size, color, className, style }: IconProps): React.ReactElement; diff --git a/vendor/pixel-art-icons/dist/icons/git-merge-solid.js b/vendor/pixel-art-icons/dist/icons/git-merge-solid.js new file mode 100644 index 000000000..f5af7a39c --- /dev/null +++ b/vendor/pixel-art-icons/dist/icons/git-merge-solid.js @@ -0,0 +1,4 @@ +import { jsx as _jsx } from "react/jsx-runtime"; +export function GitMergeSolidIcon({ size = 24, color = 'currentColor', className, style }) { + return (_jsx("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: color, xmlns: "http://www.w3.org/2000/svg", className: className, style: style, children: _jsx("path", { d: "M7 22H5V12h2v10Zm13-6h2v4h-2v2h-4v-2h-2v-4h2v-2h4v2Zm-6-2h-2v-2h2v2Zm-2-2h-2v-2h2v2ZM8 4h2v4H8v2H4V8H2V4h2V2h4v2Z" }) })); +} diff --git a/vendor/pixel-art-icons/dist/icons/share-solid.d.ts b/vendor/pixel-art-icons/dist/icons/share-solid.d.ts new file mode 100644 index 000000000..f94ee6385 --- /dev/null +++ b/vendor/pixel-art-icons/dist/icons/share-solid.d.ts @@ -0,0 +1,3 @@ +import React from 'react'; +import type { IconProps } from '../types'; +export declare function ShareSolidIcon({ size, color, className, style }: IconProps): React.ReactElement; diff --git a/vendor/pixel-art-icons/dist/icons/share-solid.js b/vendor/pixel-art-icons/dist/icons/share-solid.js new file mode 100644 index 000000000..3e5e40b5f --- /dev/null +++ b/vendor/pixel-art-icons/dist/icons/share-solid.js @@ -0,0 +1,4 @@ +import { jsx as _jsx } from "react/jsx-runtime"; +export function ShareSolidIcon({ size = 24, color = 'currentColor', className, style }) { + return (_jsx("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: color, xmlns: "http://www.w3.org/2000/svg", className: className, style: style, children: _jsx("path", { d: "M9 18H15V12H20V14H22V20H20V22H4V20H2V14H4V12H9V18ZM13 4H15V6H17V8H13V16H11V8H7V6H9V4H11V2H13V4Z" }) })); +} diff --git a/vendor/pixel-art-icons/icons/archive-restore-solid.tsx b/vendor/pixel-art-icons/icons/archive-restore-solid.tsx new file mode 100644 index 000000000..00a5e705a --- /dev/null +++ b/vendor/pixel-art-icons/icons/archive-restore-solid.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import type { IconProps } from '../types'; + +export function ArchiveRestoreSolidIcon({ size = 24, color = 'currentColor', className, style }: IconProps): React.ReactElement { + return ( + + + + ); +} diff --git a/vendor/pixel-art-icons/icons/circle-dot-solid.tsx b/vendor/pixel-art-icons/icons/circle-dot-solid.tsx new file mode 100644 index 000000000..6c1a2d3ce --- /dev/null +++ b/vendor/pixel-art-icons/icons/circle-dot-solid.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import type { IconProps } from '../types'; + +export function CircleDotSolidIcon({ size = 24, color = 'currentColor', className, style }: IconProps): React.ReactElement { + return ( + + + + ); +} diff --git a/vendor/pixel-art-icons/icons/git-branch-solid.tsx b/vendor/pixel-art-icons/icons/git-branch-solid.tsx new file mode 100644 index 000000000..3e44cbd9b --- /dev/null +++ b/vendor/pixel-art-icons/icons/git-branch-solid.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import type { IconProps } from '../types'; + +export function GitBranchSolidIcon({ size = 24, color = 'currentColor', className, style }: IconProps): React.ReactElement { + return ( + + + + ); +} diff --git a/vendor/pixel-art-icons/icons/git-merge-solid.tsx b/vendor/pixel-art-icons/icons/git-merge-solid.tsx new file mode 100644 index 000000000..c094bea14 --- /dev/null +++ b/vendor/pixel-art-icons/icons/git-merge-solid.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import type { IconProps } from '../types'; + +export function GitMergeSolidIcon({ size = 24, color = 'currentColor', className, style }: IconProps): React.ReactElement { + return ( + + + + ); +} diff --git a/vendor/pixel-art-icons/icons/share-solid.tsx b/vendor/pixel-art-icons/icons/share-solid.tsx new file mode 100644 index 000000000..ae1639d82 --- /dev/null +++ b/vendor/pixel-art-icons/icons/share-solid.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import type { IconProps } from '../types'; + +export function ShareSolidIcon({ size = 24, color = 'currentColor', className, style }: IconProps): React.ReactElement { + return ( + + + + ); +} From 9a0bca635ae11bf00bd37dac09160ce9c1e0e8c7 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Thu, 3 Sep 2026 18:27:07 +0200 Subject: [PATCH 02/16] feat(branches): merge review page with page renders, file diffs, threads and requests Review a branch before merging: files become merge entities, the plan carries per-change detail (fields, page tree diffs, file text), merge requests and comments live on the branch, and /admin/branches/:id/review shows one timeline per change with before/after page renders whose highlights come from the tree diff. --- server/branches/changeDetail.ts | 240 +++++++ server/branches/contentHash.ts | 19 +- server/branches/entities.ts | 16 +- server/branches/merge.ts | 91 ++- server/branches/review.ts | 112 +++ server/db/migrations-pg.ts | 34 + server/db/migrations-sqlite.ts | 34 + server/handlers/cms/branches.ts | 192 ++++- server/publish/branchReviewRender.ts | 64 ++ server/repositories/audit.ts | 4 + server/repositories/branchReviews.ts | 220 ++++++ src/__tests__/core/utils/lineDiff.test.ts | 22 + src/__tests__/server/branchReview.test.ts | 269 +++++++ src/admin/AuthenticatedAdmin.tsx | 5 + src/admin/access.ts | 4 + .../AdminWorkspaceCanvasLayout.tsx | 5 +- .../branches/BranchReviewPage.module.css | 293 ++++++++ src/admin/pages/branches/BranchReviewPage.tsx | 675 ++++++++++++++++++ src/admin/pages/branches/PageCompare.tsx | 259 +++++++ src/admin/pages/branches/ReviewChangeCard.tsx | 196 +++++ src/admin/pages/branches/ReviewThread.tsx | 127 ++++ src/admin/pages/branches/reviewFormat.ts | 87 +++ src/admin/pages/branches/useBranchReview.ts | 107 +++ src/admin/router.tsx | 1 + .../AdminSectionNavigation.tsx | 2 +- .../BranchSwitcher/BranchContextStrip.tsx | 26 +- src/admin/workspace.ts | 2 + src/core/branches/index.ts | 32 + src/core/branches/schemas.ts | 167 ++++- src/core/http/apiClient.ts | 13 + src/core/http/index.ts | 1 + src/core/persistence/cmsBranches.ts | 68 ++ src/core/persistence/index.ts | 6 + src/core/utils/lineDiff.ts | 60 ++ tests/e2e/branch-review.e2e.ts | 262 +++++++ 35 files changed, 3653 insertions(+), 62 deletions(-) create mode 100644 server/branches/changeDetail.ts create mode 100644 server/branches/review.ts create mode 100644 server/publish/branchReviewRender.ts create mode 100644 server/repositories/branchReviews.ts create mode 100644 src/__tests__/core/utils/lineDiff.test.ts create mode 100644 src/__tests__/server/branchReview.test.ts create mode 100644 src/admin/pages/branches/BranchReviewPage.module.css create mode 100644 src/admin/pages/branches/BranchReviewPage.tsx create mode 100644 src/admin/pages/branches/PageCompare.tsx create mode 100644 src/admin/pages/branches/ReviewChangeCard.tsx create mode 100644 src/admin/pages/branches/ReviewThread.tsx create mode 100644 src/admin/pages/branches/reviewFormat.ts create mode 100644 src/admin/pages/branches/useBranchReview.ts create mode 100644 src/core/utils/lineDiff.ts create mode 100644 tests/e2e/branch-review.e2e.ts diff --git a/server/branches/changeDetail.ts b/server/branches/changeDetail.ts new file mode 100644 index 000000000..38b7ea628 --- /dev/null +++ b/server/branches/changeDetail.ts @@ -0,0 +1,240 @@ +/** + * What a planned change looks like, for the review page: which fields moved + * (as display text), which page nodes were added, changed or removed, how a + * table's schema differs, or a file's text on both sides. Computed from the + * same content projections the merge compares, so the review never + * disagrees with the plan. + * + * "before" is the receiving side (`into`), "after" the contributing side + * (`from`) — main and the branch for a merge, the other way round for an + * update. + */ +import { canonicalJson } from '@core/utils/canonicalJson' +import type { + MergeChangeDetail, + MergeFieldChange, + MergeSchemaField, + MergeTreeDiff, +} from '@core/branches' +import type { BranchEntityKind, FileContent, RowContent, SiteContent, TableContent } from './contentHash' + +const PREVIEW_LIMIT = 240 + +/** Tables whose `body` cell is a node tree. */ +const TREE_TABLES = new Set(['pages', 'components', 'layouts']) + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function displayValue(value: unknown): { text: string | null; structured: boolean } { + if (value === undefined || value === null) return { text: null, structured: false } + if (typeof value === 'string') return { text: value, structured: false } + if (typeof value === 'number' || typeof value === 'boolean') return { text: String(value), structured: false } + const json = canonicalJson(value) + return { text: json.length > PREVIEW_LIMIT ? `${json.slice(0, PREVIEW_LIMIT)}…` : json, structured: true } +} + +interface FieldChangeOptions { + /** Prefix that turns a key into the conflict path the merge reports. */ + prefix: string + conflicts: ReadonlySet + skip?: ReadonlySet + labels?: Readonly> +} + +function fieldChanges( + before: Record, + after: Record, + options: FieldChangeOptions, +): MergeFieldChange[] { + const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort() + const out: MergeFieldChange[] = [] + for (const key of keys) { + if (options.skip?.has(key)) continue + const a = before[key] + const b = after[key] + if (canonicalJson(a ?? null) === canonicalJson(b ?? null)) continue + const shownBefore = displayValue(a) + const shownAfter = displayValue(b) + out.push({ + id: key, + label: options.labels?.[key] ?? key, + before: shownBefore.text, + after: shownAfter.text, + structured: shownBefore.structured || shownAfter.structured, + conflict: options.conflicts.has(`${options.prefix}${key}`), + }) + } + return out +} + +function nodeSignature(node: unknown): string { + if (!isRecord(node)) return canonicalJson(node ?? null) + const { children: _children, ...rest } = node + return canonicalJson(rest) +} + +function nodeLabel(node: unknown): string { + if (!isRecord(node)) return 'node' + if (typeof node.name === 'string' && node.name.trim()) return node.name.trim() + if (typeof node.moduleId === 'string') return node.moduleId.replace(/^base\./, '') + return 'node' +} + +/** Node-level diff of two `{ nodes, rootNodeId }` trees; null when neither side has one. */ +export function treeDiff(before: unknown, after: unknown): MergeTreeDiff | null { + const beforeNodes = isRecord(before) && isRecord(before.nodes) ? before.nodes : null + const afterNodes = isRecord(after) && isRecord(after.nodes) ? after.nodes : null + if (!beforeNodes && !afterNodes) return null + const a = beforeNodes ?? {} + const b = afterNodes ?? {} + const diff: MergeTreeDiff = { added: [], changed: [], removed: [], labels: {} } + for (const id of Object.keys(b)) { + if (!(id in a)) { + diff.added.push(id) + diff.labels[id] = nodeLabel(b[id]) + } else if (nodeSignature(a[id]) !== nodeSignature(b[id])) { + diff.changed.push(id) + diff.labels[id] = nodeLabel(b[id]) + } + } + for (const id of Object.keys(a)) { + if (!(id in b)) { + diff.removed.push(id) + diff.labels[id] = nodeLabel(a[id]) + } + } + return diff +} + +function schemaFieldSummary(field: unknown): { id: string; label: string; type: string } | null { + if (!isRecord(field) || typeof field.id !== 'string') return null + const label = typeof field.label === 'string' && field.label.trim() ? field.label : field.id + const type = typeof field.type === 'string' ? field.type : '' + return { id: field.id, label, type } +} + +function schemaDiff(before: readonly unknown[], after: readonly unknown[]): MergeSchemaField[] { + const beforeById = new Map() + for (const field of before) { + const summary = schemaFieldSummary(field) + if (summary) beforeById.set(summary.id, field) + } + const out: MergeSchemaField[] = [] + const seen = new Set() + for (const field of after) { + const summary = schemaFieldSummary(field) + if (!summary) continue + seen.add(summary.id) + const previous = beforeById.get(summary.id) + const status = previous === undefined + ? 'new' + : canonicalJson(previous) === canonicalJson(field) ? 'same' : 'changed' + out.push({ ...summary, status }) + } + for (const field of before) { + const summary = schemaFieldSummary(field) + if (summary && !seen.has(summary.id)) out.push({ ...summary, status: 'removed' }) + } + return out +} + +const ROW_LABELS: Record = { title: 'Title', slug: 'Slug', body: 'Body' } +const TABLE_LABELS: Record = { + name: 'Name', + slug: 'Slug', + kind: 'Kind', + routeBase: 'Route base', + singularLabel: 'Singular label', + pluralLabel: 'Plural label', + primaryFieldId: 'Primary field', +} +const SITE_LABELS: Record = { + name: 'Site name', + settings: 'Settings', + breakpoints: 'Breakpoints', + styleRules: 'Style rules', + conditions: 'Conditions', + explorer: 'Explorer organization', + packageJson: 'package.json', + runtime: 'Runtime', +} + +/** + * Describe the difference between the two sides of one entity. Either side + * may be absent (a creation or a deletion). + */ +export function describeChange( + kind: BranchEntityKind, + tableId: string | null, + before: unknown | undefined, + after: unknown | undefined, + conflicts: readonly string[], +): MergeChangeDetail { + const conflictSet = new Set(conflicts) + switch (kind) { + case 'row': { + const a = (before ?? null) as RowContent | null + const b = (after ?? null) as RowContent | null + const hasTree = tableId !== null && TREE_TABLES.has(tableId) + const fields = fieldChanges( + { ...(a?.cells ?? {}), slug: a?.slug }, + { ...(b?.cells ?? {}), slug: b?.slug }, + { + prefix: 'cells.', + conflicts: new Set([...conflictSet, ...(conflictSet.has('slug') ? ['cells.slug'] : [])]), + skip: hasTree ? new Set(['body']) : undefined, + labels: ROW_LABELS, + }, + ) + return { + kind: 'row', + fields, + tree: hasTree ? treeDiff(a?.cells.body, b?.cells.body) : null, + } + } + case 'table': { + const a = (before ?? null) as TableContent | null + const b = (after ?? null) as TableContent | null + const { fields: beforeFields = [], ...beforeSettings } = a ?? {} + const { fields: afterFields = [], ...afterSettings } = b ?? {} + return { + kind: 'table', + fields: fieldChanges(beforeSettings, afterSettings, { prefix: '', conflicts: conflictSet, labels: TABLE_LABELS }), + schema: schemaDiff(beforeFields, afterFields), + } + } + case 'site': { + const a = (before ?? null) as SiteContent | null + const b = (after ?? null) as SiteContent | null + const shellConflicts = new Set() + for (const path of conflictSet) { + shellConflicts.add(path.startsWith('shell.') ? path.slice('shell.'.length) : path) + } + return { + kind: 'site', + fields: fieldChanges( + { name: a?.name, ...(a?.shell ?? {}) }, + { name: b?.name, ...(b?.shell ?? {}) }, + { prefix: '', conflicts: shellConflicts, labels: SITE_LABELS }, + ), + } + } + case 'file': { + const a = (before ?? null) as FileContent | null + const b = (after ?? null) as FileContent | null + const type = b?.type ?? a?.type ?? 'script' + const binary = type === 'asset' + return { + kind: 'file', + path: b?.path ?? a?.path ?? '', + pathBefore: a && b && a.path !== b.path ? a.path : null, + fileType: type, + before: binary ? null : (a?.content ?? null), + after: binary ? null : (b?.content ?? null), + binary, + } + } + } +} diff --git a/server/branches/contentHash.ts b/server/branches/contentHash.ts index 226f482ea..134bd5477 100644 --- a/server/branches/contentHash.ts +++ b/server/branches/contentHash.ts @@ -12,9 +12,11 @@ import type { Static, TSchema } from '@sinclair/typebox' import { Type, safeParseValue } from '@core/utils/typeboxHelpers' import { canonicalJson } from '@core/utils/canonicalJson' import { DataFieldSchema, DataTableKindSchema, type DataRow, type DataTable } from '@core/data/schemas' +import { SiteFileSchema, type SiteFile } from '@core/files/schemas' +import type { MergeEntityKind } from '@core/branches' import type { SiteShell } from '@core/page-tree' -export type BranchEntityKind = 'row' | 'table' | 'site' +export type BranchEntityKind = MergeEntityKind export interface RowContent { tableId: string @@ -33,11 +35,15 @@ export interface TableContent { fields: DataTable['fields'] } +/** The shell minus identity, timestamps, and files (files are entities of their own). */ export interface SiteContent { name: string - shell: Omit + shell: Omit } +/** A site file minus its id (the logical id) and timestamps (never merged). */ +export type FileContent = Omit + export function rowContent(row: Pick): RowContent { return { tableId: row.tableId, cells: row.cells, slug: row.slug } } @@ -56,10 +62,15 @@ export function tableContent(table: DataTable): TableContent { } export function siteContent(shell: SiteShell): SiteContent { - const { id: _id, name, createdAt: _createdAt, updatedAt: _updatedAt, ...rest } = shell + const { id: _id, name, createdAt: _createdAt, updatedAt: _updatedAt, files: _files, ...rest } = shell return { name, shell: rest } } +export function fileContent(file: SiteFile): FileContent { + const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...rest } = file + return rest +} + export function contentHash(value: unknown): string { return createHash('sha256').update(canonicalJson(value)).digest('hex') } @@ -91,6 +102,8 @@ export const SiteContentSchema = Type.Object({ shell: Type.Record(Type.String(), Type.Unknown()), }) +export const FileContentSchema = Type.Omit(SiteFileSchema, ['id', 'createdAt', 'updatedAt']) + /** Parse merged content back into its typed shape; throws on drift. */ export function parseContent(schema: T, value: unknown, what: string): Static { const parsed = safeParseValue(schema, value) diff --git a/server/branches/entities.ts b/server/branches/entities.ts index 427f581b7..fb3e71de8 100644 --- a/server/branches/entities.ts +++ b/server/branches/entities.ts @@ -1,13 +1,13 @@ /** - * The mergeable entities of a branch — the site shell, every table, every - * row — in one keyed map, with the content projection the merge compares + * The mergeable entities of a branch — the site shell, every site file, + * every table, every row — in one keyed map, with the content projection the merge compares * and hashes. Shared by fork (to record bases) and merge (to plan). */ import { SITE_SHELL_LOGICAL_ID } from '@core/branches' import type { DataRow, DataTable } from '@core/data/schemas' import type { DbClient } from '../db/client' import type { BranchScope } from './scope' -import { rowContent, siteContent, tableContent, type BranchEntityKind } from './contentHash' +import { fileContent, rowContent, siteContent, tableContent, type BranchEntityKind } from './contentHash' import { listDataRows, listDataTables } from '../repositories/data' import { getDraftSite } from '../repositories/site' @@ -45,6 +45,16 @@ export async function collectBranchEntities(db: DbClient, scope: BranchScope): P tableName: null, content: siteContent(shell), }) + for (const file of shell.files) { + entities.set(entityKey('file', file.id), { + kind: 'file', + logicalId: file.id, + label: file.path, + tableId: null, + tableName: null, + content: fileContent(file), + }) + } } const tables = await listDataTables(db, scope) for (const table of tables) { diff --git a/server/branches/merge.ts b/server/branches/merge.ts index f16f72b81..845ea93f5 100644 --- a/server/branches/merge.ts +++ b/server/branches/merge.ts @@ -2,7 +2,7 @@ * Merging a branch into main, and updating a branch from main. * * Both are the same three-way comparison run in opposite directions. Every - * entity (the site shell, each table, each row) is compared on three sides: + * entity (the site shell, each site file, each table, each row) is compared on three sides: * the BASE — main's content when the branch and main last agreed (fork, or * the latest merge/update; kept in `site_branch_bases`) — the side receiving * changes (`into`), and the side contributing them (`from`). @@ -19,11 +19,20 @@ * update. Row publish status is never part of the content: a merge changes * drafts, never what is live. */ -import { MAIN_BRANCH_ID, mergeJson } from '@core/branches' +import { + MAIN_BRANCH_ID, + mergeJson, + type MergeChange, + type MergeDirection, + type MergePlan, + type MergeResolution, +} from '@core/branches' import { validateSite } from '@core/persistence/validate' +import type { SiteFile } from '@core/files/schemas' import type { DbClient } from '../db/client' import { MAIN_SCOPE, isMainScope, type BranchScope } from './scope' import { + FileContentSchema, RowContentSchema, SiteContentSchema, TableContentSchema, @@ -31,6 +40,7 @@ import { parseContent, type BranchEntityKind, } from './contentHash' +import { describeChange } from './changeDetail' import { collectBranchEntities, type BranchEntity } from './entities' import { deleteBranchBases, listBranchBases, upsertBranchBases, type BranchBase } from '../repositories/branchBases' import { touchBranch } from '../repositories/branches' @@ -60,33 +70,8 @@ import { } from '../publish/contentEvents' import { runPublishFlush } from '../publish/publishFlush' -/** `merge`: branch → main. `update`: main → branch. */ -export type MergeDirection = 'merge' | 'update' -/** Which side wins a conflicting entity. */ -export type MergeResolution = 'into' | 'from' -export type MergeAction = 'create' | 'update' | 'delete' - -export interface MergeChange { - /** `:` — the key resolutions are addressed by. */ - key: string - kind: BranchEntityKind - logicalId: string - label: string - tableId: string | null - tableName: string | null - action: MergeAction - /** Field paths both sides changed differently; non-empty means a decision is needed. */ - conflicts: string[] -} - -export interface MergePlan { - branchId: string - direction: MergeDirection - from: string - into: string - changes: MergeChange[] - conflictCount: number -} +export type { MergeChange, MergeDirection, MergePlan, MergeResolution } from '@core/branches' +export type MergeAction = MergeChange['action'] interface Work { change: MergeChange @@ -124,14 +109,21 @@ function scopesFor(branchId: string, direction: MergeDirection): { from: BranchS return direction === 'merge' ? { from: branch, into: MAIN_SCOPE } : { from: MAIN_SCOPE, into: branch } } -/** Site first, then table creates/updates, rows, and table deletes last. */ +/** Site first, then files, table creates/updates, rows, and table deletes last. */ function changeOrder(change: MergeChange): number { if (change.kind === 'site') return 0 - if (change.kind === 'table') return change.action === 'delete' ? 3 : 1 - return 2 + if (change.kind === 'file') return 1 + if (change.kind === 'table') return change.action === 'delete' ? 4 : 2 + return 3 } -function describe(entity: BranchEntity, action: MergeAction, conflicts: string[]): MergeChange { +function describe( + entity: BranchEntity, + action: MergeAction, + conflicts: string[], + ours: BranchEntity | undefined, + theirs: BranchEntity | undefined, +): MergeChange { return { key: `${entity.kind}:${entity.logicalId}`, kind: entity.kind, @@ -141,6 +133,7 @@ function describe(entity: BranchEntity, action: MergeAction, conflicts: string[] tableName: entity.tableName, action, conflicts, + detail: describeChange(entity.kind, entity.tableId, ours?.content, theirs?.content, conflicts), } } @@ -196,22 +189,22 @@ export async function planBranchMerge( if (!theirs) { if (!base || !ours) continue const conflicts = base.contentHash === oursHash ? [] : [DELETED_MARKER] - work.push({ change: describe(ours, 'delete', conflicts), ours, theirs, result: null }) + work.push({ change: describe(ours, 'delete', conflicts, ours, theirs), ours, theirs, result: null }) continue } if (!ours) { if (base && base.contentHash === theirsHash) continue const conflicts = base ? [DELETED_MARKER] : [] - work.push({ change: describe(theirs, 'create', conflicts), ours, theirs, result: theirs.content }) + work.push({ change: describe(theirs, 'create', conflicts, ours, theirs), ours, theirs, result: theirs.content }) continue } if (base && base.contentHash === theirsHash) continue if (base && base.contentHash === oursHash) { - work.push({ change: describe(theirs, 'update', []), ours, theirs, result: theirs.content }) + work.push({ change: describe(theirs, 'update', [], ours, theirs), ours, theirs, result: theirs.content }) continue } const merged = mergeJson(base?.content, ours.content, theirs.content) - work.push({ change: describe(theirs, 'update', merged.conflicts), ours, theirs, result: merged.value }) + work.push({ change: describe(theirs, 'update', merged.conflicts, ours, theirs), ours, theirs, result: merged.value }) } work.sort((a, b) => changeOrder(a.change) - changeOrder(b.change) || a.change.label.localeCompare(b.change.label)) @@ -285,6 +278,30 @@ async function writeEntity( notices.shell = true return } + if (kind === 'file') { + const current = await getDraftSite(tx, scope) + if (!current) return + const now = Date.now() + const others = current.files.filter((file) => file.id !== logicalId) + let files: SiteFile[] + if (result === null) { + if (others.length === current.files.length) return + files = others + } else { + const content = parseContent(FileContentSchema, result, 'file') + const existing = current.files.find((file) => file.id === logicalId) + files = [ + ...others, + existing + ? { ...existing, ...content, updatedAt: now } + : { id: logicalId, ...content, createdAt: now, updatedAt: now }, + ] + } + const shell = validateSite({ ...current, files, updatedAt: now }) + await saveDraftSite(tx, scope, shell, actorUserId, { collabInternal: true }) + notices.shell = true + return + } if (kind === 'table') { if (result === null) { const deleted = await softDeleteDataTable(tx, scope, logicalId, actorUserId) diff --git a/server/branches/review.ts b/server/branches/review.ts new file mode 100644 index 000000000..4dc0876c3 --- /dev/null +++ b/server/branches/review.ts @@ -0,0 +1,112 @@ +/** + * Merge review — the request/decline/comment lifecycle around a branch's + * merge, and the branch content hash that tells whether a request is + * still about what the branch holds now. + * + * Who may do what is decided in the handler; this module only holds the + * rules that do not depend on the caller: one open request per branch, a + * decline needs a note, a merge closes the open request. + */ +import type { BranchMergeRequest, BranchReviewComment, BranchReviewState, SiteBranch } from '@core/branches' +import { createHash } from 'node:crypto' +import type { DbClient } from '../db/client' +import { contentHash } from './contentHash' +import { collectBranchEntities } from './entities' +import { + getLatestMergeRequest, + getOpenMergeRequest, + insertMergeRequest, + insertReviewComment, + listReviewComments, + resolveMergeRequest, +} from '../repositories/branchReviews' + +export class MergeRequestAlreadyOpenError extends Error { + constructor() { + super('This branch already has an open merge request') + this.name = 'MergeRequestAlreadyOpenError' + } +} + +export class NoOpenMergeRequestError extends Error { + constructor() { + super('This branch has no open merge request') + this.name = 'NoOpenMergeRequestError' + } +} + +/** + * One hash over every entity of the branch. A request records it; when the + * branch's hash differs later, the request is about an older draft. + */ +export async function branchContentHash(db: DbClient, branchId: string): Promise { + const entities = await collectBranchEntities(db, { branchId }) + const digest = createHash('sha256') + for (const key of [...entities.keys()].sort()) { + digest.update(key).update('\n').update(contentHash(entities.get(key)!.content)).update('\n') + } + return digest.digest('hex') +} + +export async function readBranchReviewState(db: DbClient, branch: SiteBranch): Promise { + const [request, comments, hash] = await Promise.all([ + getLatestMergeRequest(db, branch.id), + listReviewComments(db, branch.id), + branchContentHash(db, branch.id), + ]) + return { branch, request, comments, contentHash: hash } +} + +export async function openMergeRequest( + db: DbClient, + input: { branchId: string; requestedByUserId: string; note: string }, +): Promise { + if (await getOpenMergeRequest(db, input.branchId)) throw new MergeRequestAlreadyOpenError() + return insertMergeRequest(db, { + branchId: input.branchId, + requestedByUserId: input.requestedByUserId, + note: input.note.trim(), + contentHash: await branchContentHash(db, input.branchId), + }) +} + +export async function closeMergeRequest( + db: DbClient, + branchId: string, + input: { status: 'declined' | 'merged' | 'withdrawn'; resolvedByUserId: string | null; note: string }, +): Promise { + const open = await getOpenMergeRequest(db, branchId) + if (!open) throw new NoOpenMergeRequestError() + const closed = await resolveMergeRequest(db, open.id, { + status: input.status, + resolvedByUserId: input.resolvedByUserId, + resolutionNote: input.note.trim(), + }) + if (!closed) throw new NoOpenMergeRequestError() + return closed +} + +/** A merge closes the open request as merged; nothing happens without one. */ +export async function markMergeRequestMerged( + db: DbClient, + branchId: string, + resolvedByUserId: string | null, +): Promise { + const open = await getOpenMergeRequest(db, branchId) + if (!open) return null + return resolveMergeRequest(db, open.id, { status: 'merged', resolvedByUserId, resolutionNote: '' }) +} + +export async function addReviewComment( + db: DbClient, + input: { branchId: string; authorUserId: string; entityKey: string; body: string }, +): Promise { + const open = await getOpenMergeRequest(db, input.branchId) + return insertReviewComment(db, { + branchId: input.branchId, + requestId: open?.id ?? null, + entityKey: input.entityKey, + authorUserId: input.authorUserId, + body: input.body.trim(), + }) +} diff --git a/server/db/migrations-pg.ts b/server/db/migrations-pg.ts index b44387415..7b679459e 100644 --- a/server/db/migrations-pg.ts +++ b/server/db/migrations-pg.ts @@ -1291,4 +1291,38 @@ export const pgMigrations: Migration[] = [ and not (capabilities_json ? 'site.branches.manage'); `, }, + { + id: '027_site_branch_reviews', + sql: ` + create table if not exists site_branch_merge_requests ( + id text primary key, + branch_id text not null references site_branches(id) on delete cascade, + requested_by_user_id text references users(id) on delete set null, + note text not null default '', + content_hash text not null default '', + status text not null default 'open', + resolved_by_user_id text references users(id) on delete set null, + resolved_at timestamptz, + resolution_note text not null default '', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() + ); + + create index if not exists site_branch_merge_requests_branch_idx + on site_branch_merge_requests (branch_id, status, created_at desc); + + create table if not exists site_branch_review_comments ( + id text primary key, + branch_id text not null references site_branches(id) on delete cascade, + request_id text references site_branch_merge_requests(id) on delete set null, + entity_key text not null default '', + author_user_id text references users(id) on delete set null, + body text not null, + created_at timestamptz not null default now() + ); + + create index if not exists site_branch_review_comments_branch_idx + on site_branch_review_comments (branch_id, created_at); + `, + }, ] diff --git a/server/db/migrations-sqlite.ts b/server/db/migrations-sqlite.ts index 43ad9ad30..6263d6348 100644 --- a/server/db/migrations-sqlite.ts +++ b/server/db/migrations-sqlite.ts @@ -1375,4 +1375,38 @@ export const sqliteMigrations: Migration[] = [ ); `, }, + { + id: '027_site_branch_reviews', + sql: ` + create table if not exists site_branch_merge_requests ( + id text primary key, + branch_id text not null references site_branches(id) on delete cascade, + requested_by_user_id text references users(id) on delete set null, + note text not null default '', + content_hash text not null default '', + status text not null default 'open', + resolved_by_user_id text references users(id) on delete set null, + resolved_at text, + resolution_note text not null default '', + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ); + + create index if not exists site_branch_merge_requests_branch_idx + on site_branch_merge_requests (branch_id, status, created_at desc); + + create table if not exists site_branch_review_comments ( + id text primary key, + branch_id text not null references site_branches(id) on delete cascade, + request_id text references site_branch_merge_requests(id) on delete set null, + entity_key text not null default '', + author_user_id text references users(id) on delete set null, + body text not null, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ); + + create index if not exists site_branch_review_comments_branch_idx + on site_branch_review_comments (branch_id, created_at); + `, + }, ] diff --git a/server/handlers/cms/branches.ts b/server/handlers/cms/branches.ts index 0708f20af..47fc10763 100644 --- a/server/handlers/cms/branches.ts +++ b/server/handlers/cms/branches.ts @@ -11,10 +11,16 @@ * GET /admin/api/cms/branches/:id/preview the active preview link (site.read) * POST /admin/api/cms/branches/:id/preview issue a new preview link (site.branches.manage) * DELETE /admin/api/cms/branches/:id/preview revoke the preview link (site.branches.manage) - * GET /admin/api/cms/branches/:id/merge plan merging into main (site.branches.manage) + * GET /admin/api/cms/branches/:id/merge plan merging into main (site.read) * POST /admin/api/cms/branches/:id/merge merge into main (site.branches.manage + step-up) - * GET /admin/api/cms/branches/:id/update plan updating from main (site.branches.manage) + * GET /admin/api/cms/branches/:id/update plan updating from main (site.read) * POST /admin/api/cms/branches/:id/update update from main (site.branches.manage) + * GET /admin/api/cms/branches/:id/review request + comments + content hash (site.read) + * POST /admin/api/cms/branches/:id/review/request ask for a merge (site.read) + * POST /admin/api/cms/branches/:id/review/withdraw withdraw the open request (requester or site.branches.manage) + * POST /admin/api/cms/branches/:id/review/decline decline with a note (site.branches.manage) + * POST /admin/api/cms/branches/:id/review/comments comment on a change (site.read) + * GET /admin/api/cms/branches/:id/review/render one page as main or the branch renders it (site.read) * * Main is fixed: it cannot be renamed or deleted. Every mutation lands in * the audit log. @@ -23,12 +29,27 @@ import { ApplyMergeBodySchema, BRANCH_NAME_MAX_LENGTH, CreateBranchBodySchema, + CreateMergeRequestBodySchema, + CreateReviewCommentBodySchema, + DeclineMergeRequestBodySchema, RenameBranchBodySchema, isMainBranch, isValidBranchId, slugifyBranchName, type MergeDirection, } from '@core/branches' +import { + MergeRequestAlreadyOpenError, + NoOpenMergeRequestError, + addReviewComment, + closeMergeRequest, + markMergeRequestMerged, + openMergeRequest, + readBranchReviewState, +} from '../../branches/review' +import { renderBranchReviewPage } from '../../publish/branchReviewRender' +import { getOpenMergeRequest } from '../../repositories/branchReviews' +import { userHasCapability } from '../../auth/authz' import type { DbClient } from '../../db/client' import type { BranchScope } from '../../branches/scope' import { forkBranch } from '../../branches/fork' @@ -80,6 +101,24 @@ export async function handleBranchesRoutes( if (req.method === 'DELETE') return handlePreviewRevoke(req, db, branchId) return methodNotAllowed() } + if (segments[1] === 'review') { + if (segments.length === 2) { + if (req.method === 'GET') return handleReviewState(req, db, branchId) + return methodNotAllowed() + } + if (segments.length === 3 && req.method === 'POST') { + if (segments[2] === 'request') return handleReviewRequest(req, db, branchId) + if (segments[2] === 'withdraw') return handleReviewWithdraw(req, db, branchId) + if (segments[2] === 'decline') return handleReviewDecline(req, db, branchId) + if (segments[2] === 'comments') return handleReviewComment(req, db, branchId) + return null + } + if (segments.length === 3 && segments[2] === 'render') { + if (req.method === 'GET') return handleReviewRender(req, db, branchId, url) + return methodNotAllowed() + } + return null + } if (segments.length === 2 && (segments[1] === 'merge' || segments[1] === 'update')) { const direction: MergeDirection = segments[1] if (req.method === 'GET') return handleMergePlan(req, db, branchId, direction) @@ -95,7 +134,9 @@ async function handleMergePlan( branchId: string, direction: MergeDirection, ): Promise { - const user = await requireCapability(req, db, 'site.branches.manage') + // Reading the plan is reading content both sides hold; the review page + // shows it to whoever can read the site. Applying it stays a manager power. + const user = await requireCapability(req, db, 'site.read') if (user instanceof Response) return user if (isMainBranch(branchId)) return badRequest('Main is the live site; it is what branches merge into') if (!(await getBranch(db, branchId))) return branchNotFound(branchId) @@ -150,6 +191,8 @@ async function handleMergeApply( metadata: { name: branch.name, changes: plan.changes.length, conflicts: plan.conflictCount }, ...requestAuditContext(req), }) + // The open merge request, if any, is what this merge answered. + if (direction === 'merge') await markMergeRequestMerged(db, branchId, user.id) let branchDeleted = false if (direction === 'merge' && body.deleteBranch) { @@ -211,6 +254,149 @@ async function handlePreviewRevoke(req: Request, db: DbClient, branchId: string) return jsonResponse({ ok: true }) } +// --------------------------------------------------------------------------- +// Merge review +// --------------------------------------------------------------------------- + +async function handleReviewState(req: Request, db: DbClient, branchId: string): Promise { + const user = await requireCapability(req, db, 'site.read') + if (user instanceof Response) return user + if (isMainBranch(branchId)) return badRequest('Main is the live site; it is what branches merge into') + const branch = await getBranch(db, branchId) + if (!branch) return branchNotFound(branchId) + return jsonResponse(await readBranchReviewState(db, branch)) +} + +async function handleReviewRequest(req: Request, db: DbClient, branchId: string): Promise { + const user = await requireCapability(req, db, 'site.read') + if (user instanceof Response) return user + if (isMainBranch(branchId)) return badRequest('Main is the live site; it is what branches merge into') + const branch = await getBranch(db, branchId) + if (!branch) return branchNotFound(branchId) + const body = await readValidatedBody(req, CreateMergeRequestBodySchema) + if (!body) return badRequest('Invalid merge request payload') + let request + try { + request = await openMergeRequest(db, { branchId, requestedByUserId: user.id, note: body.note }) + } catch (err) { + if (err instanceof MergeRequestAlreadyOpenError) { + return jsonResponse({ error: err.message, code: 'merge_request_open' }, { status: 409 }) + } + throw err + } + await createAuditEvent(db, { + actorUserId: user.id, + action: 'branch.review.request', + targetType: 'branch', + targetId: branchId, + metadata: { name: branch.name, requestId: request.id }, + ...requestAuditContext(req), + }) + return jsonResponse({ request }, { status: 201 }) +} + +async function handleReviewWithdraw(req: Request, db: DbClient, branchId: string): Promise { + const user = await requireCapability(req, db, 'site.read') + if (user instanceof Response) return user + const branch = await getBranch(db, branchId) + if (!branch) return branchNotFound(branchId) + const open = await getOpenMergeRequest(db, branchId) + if (!open) return jsonResponse({ error: 'This branch has no open merge request' }, { status: 409 }) + // The requester takes their own request back; a branch manager can too. + if (open.requestedBy?.id !== user.id && !userHasCapability(user, 'site.branches.manage')) { + return jsonResponse({ error: 'Only the requester or a branch manager can withdraw this request' }, { status: 403 }) + } + let request + try { + request = await closeMergeRequest(db, branchId, { status: 'withdrawn', resolvedByUserId: user.id, note: '' }) + } catch (err) { + if (err instanceof NoOpenMergeRequestError) return jsonResponse({ error: err.message }, { status: 409 }) + throw err + } + await createAuditEvent(db, { + actorUserId: user.id, + action: 'branch.review.withdraw', + targetType: 'branch', + targetId: branchId, + metadata: { name: branch.name, requestId: request.id }, + ...requestAuditContext(req), + }) + return jsonResponse({ request }) +} + +async function handleReviewDecline(req: Request, db: DbClient, branchId: string): Promise { + const user = await requireCapability(req, db, 'site.branches.manage') + if (user instanceof Response) return user + const branch = await getBranch(db, branchId) + if (!branch) return branchNotFound(branchId) + const body = await readValidatedBody(req, DeclineMergeRequestBodySchema) + if (!body || body.note.trim().length === 0) return badRequest('A decline needs a note the requester can act on') + let request + try { + request = await closeMergeRequest(db, branchId, { status: 'declined', resolvedByUserId: user.id, note: body.note }) + } catch (err) { + if (err instanceof NoOpenMergeRequestError) return jsonResponse({ error: err.message }, { status: 409 }) + throw err + } + await createAuditEvent(db, { + actorUserId: user.id, + action: 'branch.review.decline', + targetType: 'branch', + targetId: branchId, + metadata: { name: branch.name, requestId: request.id }, + ...requestAuditContext(req), + }) + return jsonResponse({ request }) +} + +async function handleReviewComment(req: Request, db: DbClient, branchId: string): Promise { + const user = await requireCapability(req, db, 'site.read') + if (user instanceof Response) return user + if (isMainBranch(branchId)) return badRequest('Main is the live site; it is what branches merge into') + const branch = await getBranch(db, branchId) + if (!branch) return branchNotFound(branchId) + const body = await readValidatedBody(req, CreateReviewCommentBodySchema) + if (!body || body.body.trim().length === 0) return badRequest('A comment needs some text') + const comment = await addReviewComment(db, { + branchId, + authorUserId: user.id, + entityKey: body.entityKey, + body: body.body, + }) + await createAuditEvent(db, { + actorUserId: user.id, + action: 'branch.review.comment', + targetType: 'branch', + targetId: branchId, + metadata: { name: branch.name, commentId: comment.id, entityKey: comment.entityKey }, + ...requestAuditContext(req), + }) + return jsonResponse({ comment }, { status: 201 }) +} + +async function handleReviewRender(req: Request, db: DbClient, branchId: string, url: URL): Promise { + const user = await requireCapability(req, db, 'site.read') + if (user instanceof Response) return user + if (isMainBranch(branchId)) return badRequest('Main is the live site; it is what branches merge into') + if (!(await getBranch(db, branchId))) return branchNotFound(branchId) + const rowId = url.searchParams.get('row')?.trim() ?? '' + const side = url.searchParams.get('side') + if (!rowId || (side !== 'main' && side !== 'branch')) { + return badRequest('Pass ?row=&side=main|branch') + } + const html = await renderBranchReviewPage(db, branchId, side, rowId) + if (html === null) return jsonResponse({ error: `No page "${rowId}" on ${side}` }, { status: 404 }) + return new Response(html, { + headers: { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'no-store', + 'x-robots-tag': 'noindex', + // The review embeds this in a sandboxed iframe of its own origin only. + 'content-security-policy': "frame-ancestors 'self'", + }, + }) +} + function branchNotFound(branchId: string): Response { return jsonResponse({ error: `Branch "${branchId}" does not exist` }, { status: 404 }) } diff --git a/server/publish/branchReviewRender.ts b/server/publish/branchReviewRender.ts new file mode 100644 index 000000000..91104a656 --- /dev/null +++ b/server/publish/branchReviewRender.ts @@ -0,0 +1,64 @@ +/** + * Before/after page renders for the merge review — one page, from main's + * draft or the branch's draft, as HTML for a sandboxed iframe. + * + * Composed the way the branch preview composes a page (template chain, + * draft loops, inlined CSS), with two differences: every node's root + * element carries `uid=""`, so the review can outline the nodes + * the plan says changed, and no runtime scripts are bundled — the frame + * is sandboxed without scripts, so bundling would be wasted work. + */ +import '../../src/modules/base' +import '@core/loops/sources' +import { registry } from '@core/module-engine' +import { publishPage } from '@core/publisher' +import { composeTemplateChain, resolveTemplateChain } from '@core/templates' +import { buildRouteFrame } from '@core/templates/contextFrames' +import type { SourceRequestContext } from '@core/loops/types' +import type { DbClient } from '../db/client' +import { MAIN_SCOPE, type BranchScope } from '../branches/scope' +import { getDraftSiteDocument } from '../repositories/publish' +import { prefetchLoopData } from './loopPrefetch' +import { prefetchMediaAssets } from './mediaPrefetch' +import { getPublishVersion } from './publishState' + +export type ReviewRenderSide = 'main' | 'branch' + +/** + * HTML of the page row `rowId` as `side` holds it, or null when that side + * has no such page (a page only the branch created has no main render). + */ +export async function renderBranchReviewPage( + db: DbClient, + branchId: string, + side: ReviewRenderSide, + rowId: string, +): Promise { + const scope: BranchScope = side === 'main' ? MAIN_SCOPE : { branchId } + const site = await getDraftSiteDocument(db, scope) + if (!site) return null + const page = site.pages.find((candidate) => candidate.id === rowId) + if (!page) return null + + const chain = resolveTemplateChain(site, { kind: 'page' }) + const merged = composeTemplateChain(chain, { kind: 'page', page }) + const url = new URL(`http://localhost/${page.slug}`) + const templateContext = { entryStack: [], route: buildRouteFrame(url.toString()) } + const request: SourceRequestContext = { + query: {}, + path: url.pathname, + slug: page.slug || null, + cookies: {}, + } + const loopData = await prefetchLoopData(merged, site, db, url, { branchId: scope.branchId, request }) + const mediaAssets = await prefetchMediaAssets(merged, site, registry, db, { templateContext, loopData }) + const rendered = publishPage(merged, site, registry, { + templateContext, + loopData, + mediaAssets, + dynamicNodes: 'inline', + annotateNodeIds: true, + publishVersion: getPublishVersion(), + }) + return rendered.html +} diff --git a/server/repositories/audit.ts b/server/repositories/audit.ts index 53bbb5ed9..5c2013276 100644 --- a/server/repositories/audit.ts +++ b/server/repositories/audit.ts @@ -40,6 +40,10 @@ const AuditActionSchema = Type.Union([ Type.Literal('branch.update'), Type.Literal('branch.preview.share'), Type.Literal('branch.preview.revoke'), + Type.Literal('branch.review.request'), + Type.Literal('branch.review.withdraw'), + Type.Literal('branch.review.decline'), + Type.Literal('branch.review.comment'), Type.Literal('version.restore'), Type.Literal('plugin.install'), Type.Literal('plugin.update'), diff --git a/server/repositories/branchReviews.ts b/server/repositories/branchReviews.ts new file mode 100644 index 000000000..8d3bab74b --- /dev/null +++ b/server/repositories/branchReviews.ts @@ -0,0 +1,220 @@ +/** + * Merge requests and review comments on a branch — + * `site_branch_merge_requests` and `site_branch_review_comments`. + * + * A request is the "please merge this" object: one may be open per branch + * at a time, and it ends as merged, declined, or withdrawn. Comments belong + * to the branch (they outlive a declined request) and name the change they + * are about through `entity_key` (`''` is the request itself). Both tables + * cascade with the branch. + */ +import { nanoid } from 'nanoid' + +/** + * Time-sortable ids: comments and requests are listed in creation order, + * and two rows written in the same millisecond must still sort the way they + * were written. The process counter breaks that tie; the random tail keeps + * ids unguessable across processes. + */ +let idCounter = 0 +function sortableId(): string { + idCounter = (idCounter + 1) % 46_656 + return `${Date.now().toString(36).padStart(9, '0')}${idCounter.toString(36).padStart(3, '0')}${nanoid(8)}` +} +import type { BranchMergeRequest, BranchReviewComment, MergeRequestStatus, ReviewUserLabel } from '@core/branches' +import { isoDate, isoDateOrNull } from '@core/utils/isoDate' +import { placeholder, type DbClient } from '../db/client' +import { computeGravatarHash } from './users' + +interface UserLabelColumns { + user_id: string | null + user_email: string | null + user_display_name: string | null + user_avatar_path: string | null +} + +interface RequestRow { + id: string + branch_id: string + note: string + content_hash: string + status: MergeRequestStatus + resolved_at: string | Date | null + resolution_note: string + created_at: string | Date + updated_at: string | Date + requester_id: string | null + requester_email: string | null + requester_display_name: string | null + requester_avatar_path: string | null + resolver_id: string | null + resolver_email: string | null + resolver_display_name: string | null + resolver_avatar_path: string | null +} + +interface CommentRow extends UserLabelColumns { + id: string + branch_id: string + request_id: string | null + entity_key: string + body: string + created_at: string | Date +} + +function userLabel(id: string | null, email: string | null, displayName: string | null, avatarPath: string | null): ReviewUserLabel | null { + if (!id || !email) return null + return { + id, + email, + displayName: displayName?.trim() || email, + avatarUrl: avatarPath ?? null, + gravatarHash: computeGravatarHash(email), + } +} + +function mapRequest(row: RequestRow): BranchMergeRequest { + return { + id: row.id, + branchId: row.branch_id, + requestedBy: userLabel(row.requester_id, row.requester_email, row.requester_display_name, row.requester_avatar_path), + note: row.note, + contentHash: row.content_hash, + status: row.status, + resolvedBy: userLabel(row.resolver_id, row.resolver_email, row.resolver_display_name, row.resolver_avatar_path), + resolvedAt: isoDateOrNull(row.resolved_at), + resolutionNote: row.resolution_note, + createdAt: isoDate(row.created_at), + updatedAt: isoDate(row.updated_at), + } +} + +const REQUEST_SELECT = ` + select r.id, r.branch_id, r.note, r.content_hash, r.status, r.resolved_at, r.resolution_note, + r.created_at, r.updated_at, + requester.id as requester_id, requester.email as requester_email, + requester.display_name as requester_display_name, requester_media.public_path as requester_avatar_path, + resolver.id as resolver_id, resolver.email as resolver_email, + resolver.display_name as resolver_display_name, resolver_media.public_path as resolver_avatar_path + from site_branch_merge_requests r + left join users requester on requester.id = r.requested_by_user_id + left join media_assets requester_media on requester_media.id = requester.avatar_media_id + left join users resolver on resolver.id = r.resolved_by_user_id + left join media_assets resolver_media on resolver_media.id = resolver.avatar_media_id +` + +/** + * Run the joined request select with a trailing clause. The clause uses + * `placeholder(db.dialect, n)` so the one SQL string runs on both dialects. + */ +async function selectRequests(db: DbClient, clause: string, params: unknown[]): Promise { + const { rows } = await db.unsafe(`${REQUEST_SELECT} ${clause}`, params) + return rows.map(mapRequest) +} + +/** The newest request on the branch, whatever its status. */ +export async function getLatestMergeRequest(db: DbClient, branchId: string): Promise { + const rows = await selectRequests( + db, + `where r.branch_id = ${placeholder(db.dialect, 1)} order by r.created_at desc, r.id desc limit 1`, + [branchId], + ) + return rows[0] ?? null +} + +export async function getOpenMergeRequest(db: DbClient, branchId: string): Promise { + const rows = await selectRequests( + db, + `where r.branch_id = ${placeholder(db.dialect, 1)} and r.status = 'open' order by r.created_at desc, r.id desc limit 1`, + [branchId], + ) + return rows[0] ?? null +} + +export async function getMergeRequestById(db: DbClient, id: string): Promise { + const rows = await selectRequests(db, `where r.id = ${placeholder(db.dialect, 1)} limit 1`, [id]) + return rows[0] ?? null +} + +export async function insertMergeRequest( + db: DbClient, + input: { branchId: string; requestedByUserId: string; note: string; contentHash: string }, +): Promise { + const id = sortableId() + await db` + insert into site_branch_merge_requests (id, branch_id, requested_by_user_id, note, content_hash, status) + values (${id}, ${input.branchId}, ${input.requestedByUserId}, ${input.note}, ${input.contentHash}, 'open') + ` + const request = await getMergeRequestById(db, id) + if (!request) throw new Error('[branches] merge request vanished after insert') + return request +} + +/** Close the request; returns null when it is not open any more. */ +export async function resolveMergeRequest( + db: DbClient, + id: string, + input: { status: Exclude; resolvedByUserId: string | null; resolutionNote: string }, +): Promise { + const { rows } = await db<{ id: string }>` + update site_branch_merge_requests + set status = ${input.status}, + resolved_by_user_id = ${input.resolvedByUserId}, + resolved_at = current_timestamp, + resolution_note = ${input.resolutionNote}, + updated_at = current_timestamp + where id = ${id} + and status = 'open' + returning id + ` + if (rows.length === 0) return null + return getMergeRequestById(db, id) +} + +function mapComment(row: CommentRow): BranchReviewComment { + return { + id: row.id, + branchId: row.branch_id, + requestId: row.request_id ?? null, + entityKey: row.entity_key, + author: userLabel(row.user_id, row.user_email, row.user_display_name, row.user_avatar_path), + body: row.body, + createdAt: isoDate(row.created_at), + } +} + +const COMMENT_SELECT = ` + select c.id, c.branch_id, c.request_id, c.entity_key, c.body, c.created_at, + u.id as user_id, u.email as user_email, u.display_name as user_display_name, + m.public_path as user_avatar_path + from site_branch_review_comments c + left join users u on u.id = c.author_user_id + left join media_assets m on m.id = u.avatar_media_id +` + +async function selectComments(db: DbClient, clause: string, params: unknown[]): Promise { + const { rows } = await db.unsafe(`${COMMENT_SELECT} ${clause}`, params) + return rows.map(mapComment) +} + +export async function listReviewComments(db: DbClient, branchId: string): Promise { + return selectComments( + db, + `where c.branch_id = ${placeholder(db.dialect, 1)} order by c.created_at asc, c.id asc`, + [branchId], + ) +} + +export async function insertReviewComment( + db: DbClient, + input: { branchId: string; requestId: string | null; entityKey: string; authorUserId: string; body: string }, +): Promise { + const id = sortableId() + await db` + insert into site_branch_review_comments (id, branch_id, request_id, entity_key, author_user_id, body) + values (${id}, ${input.branchId}, ${input.requestId}, ${input.entityKey}, ${input.authorUserId}, ${input.body}) + ` + const [comment] = await selectComments(db, `where c.id = ${placeholder(db.dialect, 1)} limit 1`, [id]) + if (!comment) throw new Error('[branches] review comment vanished after insert') + return comment +} diff --git a/src/__tests__/core/utils/lineDiff.test.ts b/src/__tests__/core/utils/lineDiff.test.ts new file mode 100644 index 000000000..95309cd86 --- /dev/null +++ b/src/__tests__/core/utils/lineDiff.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'bun:test' +import { countDiffLines, diffLines } from '@core/utils/lineDiff' + +describe('diffLines', () => { + it('keeps unchanged lines and marks additions and removals with both line numbers', () => { + const rows = diffLines('a\nb\nc\n', 'a\nx\nc\nd\n') + expect(rows.map((row) => [row.type, row.before, row.after, row.text])).toEqual([ + ['same', 1, 1, 'a'], + ['del', 2, null, 'b'], + ['add', null, 2, 'x'], + ['same', 3, 3, 'c'], + ['add', null, 4, 'd'], + ]) + expect(countDiffLines(rows)).toEqual({ additions: 2, deletions: 1 }) + }) + + it('treats an empty side as all added or all removed', () => { + expect(diffLines('', 'one\ntwo').map((row) => row.type)).toEqual(['add', 'add']) + expect(diffLines('one', '').map((row) => row.type)).toEqual(['del']) + expect(diffLines('', '')).toEqual([]) + }) +}) diff --git a/src/__tests__/server/branchReview.test.ts b/src/__tests__/server/branchReview.test.ts new file mode 100644 index 000000000..2aa8d41c9 --- /dev/null +++ b/src/__tests__/server/branchReview.test.ts @@ -0,0 +1,269 @@ +/** + * Merge review — site files as merge entities, per-change detail (fields, + * page tree diffs, file text), the request/comment/decline lifecycle over + * HTTP with its capability gates, and the before/after page render. + */ +import { afterEach, describe, expect, it } from 'bun:test' +import { MAIN_SCOPE } from '../../../server/branches/scope' +import { applyBranchMerge, planBranchMerge } from '../../../server/branches/merge' +import { getDataRow, listDataRows, saveDataRowDraft } from '../../../server/repositories/data' +import { getDraftSite, saveDraftSite } from '../../../server/repositories/site' +import { + createCapabilityTestHarness, + expectForbidden, + readJson, + type CapabilityTestHarness, +} from '../helpers/capabilityHarness' +import type { SiteFile } from '@core/files/schemas' +import type { BranchMergeRequest, BranchReviewComment, BranchReviewState, MergePlan } from '@core/branches' + +const BRANCHES = '/admin/api/cms/branches' + +async function forkViaApi(harness: CapabilityTestHarness, owner: string, name: string): Promise { + const res = await harness.cms(BRANCHES, { method: 'POST', cookie: owner, json: { name } }) + expect(res.status).toBe(201) + return (await readJson<{ branch: { id: string } }>(res)).branch.id +} + +function themeFile(content: string, overrides: Partial = {}): SiteFile { + return { + id: 'file-theme', + path: 'src/styles/theme.css', + type: 'style', + content, + createdAt: 1, + updatedAt: 1, + ...overrides, + } +} + +describe('merge review', () => { + let harness: CapabilityTestHarness | null = null + + afterEach(async () => { + await harness?.cleanup() + harness = null + }) + + it('treats site files as their own entities: adds, merges, and conflicts per file', async () => { + harness = await createCapabilityTestHarness() + const owner = await harness.setupOwner() + const branchId = await forkViaApi(harness, owner, 'Files') + const branch = { branchId } + + const shell = (await getDraftSite(harness.db, branch))! + await saveDraftSite(harness.db, branch, { ...shell, files: [...shell.files, themeFile(':root { --brand: red; }')] }) + + const created = await planBranchMerge(harness.db, branchId, 'merge') + expect(created.plan.changes.map((change) => [change.kind, change.action, change.label])).toEqual([ + ['file', 'create', 'src/styles/theme.css'], + ]) + const detail = created.plan.changes[0]!.detail + expect(detail.kind).toBe('file') + if (detail.kind === 'file') { + expect(detail.before).toBeNull() + expect(detail.after).toBe(':root { --brand: red; }') + expect(detail.binary).toBe(false) + } + + await applyBranchMerge(harness.db, { branchId, direction: 'merge', resolutions: {}, actorUserId: null }) + const mainShell = (await getDraftSite(harness.db, MAIN_SCOPE))! + expect(mainShell.files.find((file) => file.id === 'file-theme')?.content).toBe(':root { --brand: red; }') + expect((await planBranchMerge(harness.db, branchId, 'merge')).plan.changes).toEqual([]) + + // Both sides edit the same file's content: one conflict, on that file only. + const mainNow = (await getDraftSite(harness.db, MAIN_SCOPE))! + await saveDraftSite(harness.db, MAIN_SCOPE, { + ...mainNow, + files: mainNow.files.map((file) => (file.id === 'file-theme' ? { ...file, content: ':root { --brand: blue; }' } : file)), + }) + const branchNow = (await getDraftSite(harness.db, branch))! + await saveDraftSite(harness.db, branch, { + ...branchNow, + files: branchNow.files.map((file) => (file.id === 'file-theme' ? { ...file, content: ':root { --brand: green; }' } : file)), + }) + const conflicted = await planBranchMerge(harness.db, branchId, 'merge') + expect(conflicted.plan.changes.map((change) => [change.kind, change.conflicts])).toEqual([['file', ['content']]]) + // The shell itself did not change — files are not part of its content any more. + expect(conflicted.plan.changes.some((change) => change.kind === 'site')).toBe(false) + + await applyBranchMerge(harness.db, { + branchId, + direction: 'merge', + resolutions: { 'file:file-theme': 'from' }, + actorUserId: null, + }) + expect((await getDraftSite(harness.db, MAIN_SCOPE))!.files.find((file) => file.id === 'file-theme')?.content) + .toBe(':root { --brand: green; }') + + // Removing the file on the branch removes it from main on merge. + const afterMerge = (await getDraftSite(harness.db, branch))! + await saveDraftSite(harness.db, branch, { ...afterMerge, files: afterMerge.files.filter((file) => file.id !== 'file-theme') }) + const removal = await planBranchMerge(harness.db, branchId, 'merge') + expect(removal.plan.changes.map((change) => [change.kind, change.action])).toEqual([['file', 'delete']]) + await applyBranchMerge(harness.db, { branchId, direction: 'merge', resolutions: {}, actorUserId: null }) + expect((await getDraftSite(harness.db, MAIN_SCOPE))!.files.some((file) => file.id === 'file-theme')).toBe(false) + }) + + it('describes a page change as fields plus a node-level tree diff', async () => { + harness = await createCapabilityTestHarness() + const owner = await harness.setupOwner() + const branchId = await forkViaApi(harness, owner, 'Home copy') + const branch = { branchId } + + const [home] = await listDataRows(harness.db, branch, 'pages') + const body = home!.cells.body as { nodes: Record>; rootNodeId: string } + const nodeIds = Object.keys(body.nodes) + expect(nodeIds.length).toBeGreaterThan(0) + const changedNodeId = nodeIds[nodeIds.length - 1]! + const addedNodeId = 'review-added-node' + const nextNodes = { + ...body.nodes, + [changedNodeId]: { ...body.nodes[changedNodeId]!, props: { ...(body.nodes[changedNodeId]!.props as object), reviewed: true } }, + [addedNodeId]: { id: addedNodeId, moduleId: 'base.text', props: { text: 'Added on the branch' }, children: [] }, + } + await saveDataRowDraft(harness.db, branch, home!.id, { + cells: { ...home!.cells, title: 'Home, branch edition', body: { ...body, nodes: nextNodes } }, + slug: home!.slug, + }) + + const { plan } = await planBranchMerge(harness.db, branchId, 'merge') + const change = plan.changes.find((entry) => entry.kind === 'row' && entry.logicalId === home!.id)! + expect(change.detail.kind).toBe('row') + if (change.detail.kind === 'row') { + const title = change.detail.fields.find((field) => field.id === 'title')! + expect(title.after).toBe('Home, branch edition') + expect(title.before).toBe(home!.cells.title) + // The tree is not shown as a JSON blob; it is a node diff. + expect(change.detail.fields.some((field) => field.id === 'body')).toBe(false) + expect(change.detail.tree).not.toBeNull() + expect(change.detail.tree!.changed).toContain(changedNodeId) + expect(change.detail.tree!.added).toEqual([addedNodeId]) + expect(change.detail.tree!.removed).toEqual([]) + expect(change.detail.tree!.labels[addedNodeId]).toBe('text') + } + }) + + it('runs the request, comment, decline, re-request and merge lifecycle with its gates', async () => { + harness = await createCapabilityTestHarness() + const owner = await harness.setupOwner() + const editor = await harness.createRoleUser({ + name: 'Branch editor', + slug: 'branch-editor', + capabilities: ['site.read', 'site.content.edit'], + }) + const outsider = await harness.createRoleUser({ + name: 'No site access', + slug: 'no-site', + capabilities: ['dashboard.read'], + }) + const branchId = await forkViaApi(harness, owner, 'Launch') + const review = `${BRANCHES}/${branchId}/review` + + // Nothing yet: no request, no comments, a content hash. + const empty = await readJson(await harness.cms(review, { cookie: editor.cookie })) + expect(empty.request).toBeNull() + expect(empty.comments).toEqual([]) + expect(empty.contentHash).toHaveLength(64) + await expectForbidden(await harness.cms(review, { cookie: outsider.cookie })) + + // The editor asks for a merge; a second open request is refused. + const requested = await harness.cms(`${review}/request`, { method: 'POST', cookie: editor.cookie, json: { note: 'Launch page ready' } }) + expect(requested.status).toBe(201) + const request = (await readJson<{ request: BranchMergeRequest }>(requested)).request + expect(request.status).toBe('open') + expect(request.note).toBe('Launch page ready') + expect(request.requestedBy?.email).toBe(editor.email) + expect(request.contentHash).toBe(empty.contentHash) + expect((await harness.cms(`${review}/request`, { method: 'POST', cookie: editor.cookie, json: { note: 'again' } })).status).toBe(409) + + // Comments: editor on the request, owner on a change; outsiders cannot. + const onRequest = await harness.cms(`${review}/comments`, { method: 'POST', cookie: editor.cookie, json: { entityKey: '', body: 'Please look at the hero.' } }) + expect(onRequest.status).toBe(201) + const [home] = await listDataRows(harness.db, MAIN_SCOPE, 'pages') + const onPage = await harness.cms(`${review}/comments`, { method: 'POST', cookie: owner, json: { entityKey: `row:${home!.id}`, body: 'Headline is fine.' } }) + expect(onPage.status).toBe(201) + const comment = (await readJson<{ comment: BranchReviewComment }>(onPage)).comment + expect(comment.entityKey).toBe(`row:${home!.id}`) + expect(comment.requestId).toBe(request.id) + expect(comment.author?.gravatarHash).toHaveLength(64) + await expectForbidden(await harness.cms(`${review}/comments`, { method: 'POST', cookie: outsider.cookie, json: { entityKey: '', body: 'hi' } })) + expect((await harness.cms(`${review}/comments`, { method: 'POST', cookie: editor.cookie, json: { entityKey: '', body: ' ' } })).status).toBe(400) + + // Declining needs the manage capability and a note. + await expectForbidden(await harness.cms(`${review}/decline`, { method: 'POST', cookie: editor.cookie, json: { note: 'no' } })) + expect((await harness.cms(`${review}/decline`, { method: 'POST', cookie: owner, json: { note: ' ' } })).status).toBe(400) + const declined = await harness.cms(`${review}/decline`, { method: 'POST', cookie: owner, json: { note: 'Fix the hero copy first.' } }) + expect(declined.status).toBe(200) + expect((await readJson<{ request: BranchMergeRequest }>(declined)).request.status).toBe('declined') + // Only one open request at a time, but a declined one can be followed by a new one. + expect((await harness.cms(`${review}/decline`, { method: 'POST', cookie: owner, json: { note: 'twice' } })).status).toBe(409) + const again = await harness.cms(`${review}/request`, { method: 'POST', cookie: editor.cookie, json: { note: 'Fixed.' } }) + expect(again.status).toBe(201) + + const state = await readJson(await harness.cms(review, { cookie: owner })) + expect(state.request?.status).toBe('open') + expect(state.request?.note).toBe('Fixed.') + expect(state.comments.map((entry) => entry.body)).toEqual(['Please look at the hero.', 'Headline is fine.']) + + // Withdraw: only the requester or a manager; then request once more. + await expectForbidden(await harness.cms(`${review}/withdraw`, { method: 'POST', cookie: outsider.cookie })) + const withdrawn = await harness.cms(`${review}/withdraw`, { method: 'POST', cookie: editor.cookie }) + expect(withdrawn.status).toBe(200) + expect((await readJson<{ request: BranchMergeRequest }>(withdrawn)).request.status).toBe('withdrawn') + expect((await harness.cms(`${review}/request`, { method: 'POST', cookie: editor.cookie, json: { note: 'Third time' } })).status).toBe(201) + + // A merge closes the open request as merged. + const stepped = await harness.stepUp(owner) + const merged = await harness.cms(`${BRANCHES}/${branchId}/merge`, { method: 'POST', cookie: stepped, json: { resolutions: {} } }) + expect(merged.status).toBe(200) + // Step-up rotated the owner's session cookie; keep using the stepped one. + const after = await readJson(await harness.cms(review, { cookie: stepped })) + expect(after.request?.status).toBe('merged') + expect(after.request?.resolvedBy?.email).toBeDefined() + }) + + it('lets a reader load the plan and renders a page for either side with node ids', async () => { + harness = await createCapabilityTestHarness() + const owner = await harness.setupOwner() + const editor = await harness.createRoleUser({ + name: 'Reader', + slug: 'reader', + capabilities: ['site.read'], + }) + const branchId = await forkViaApi(harness, owner, 'Render') + const branch = { branchId } + const [home] = await listDataRows(harness.db, branch, 'pages') + await saveDataRowDraft(harness.db, branch, home!.id, { + cells: { ...home!.cells, title: 'Rendered on the branch' }, + slug: home!.slug, + }) + + // The plan is readable by anyone who can read the site (the review needs it). + const planRes = await harness.cms(`${BRANCHES}/${branchId}/merge`, { cookie: editor.cookie }) + expect(planRes.status).toBe(200) + const { plan } = await readJson<{ plan: MergePlan }>(planRes) + expect(plan.changes.map((change) => change.logicalId)).toContain(home!.id) + + const render = `${BRANCHES}/${branchId}/review/render` + const branchSide = await harness.cms(`${render}?row=${encodeURIComponent(home!.id)}&side=branch`, { cookie: editor.cookie }) + expect(branchSide.status).toBe(200) + expect(branchSide.headers.get('content-type')).toContain('text/html') + expect(branchSide.headers.get('cache-control')).toBe('no-store') + const branchHtml = await branchSide.text() + expect(branchHtml).toContain('Rendered on the branch') + const rootNodeId = (home!.cells.body as { rootNodeId: string }).rootNodeId + const nodeIds = Object.keys((home!.cells.body as { nodes: Record<string, unknown> }).nodes).filter((id) => id !== rootNodeId) + // Every rendered node carries its id for the review's highlights. + for (const id of nodeIds.slice(0, 3)) expect(branchHtml).toContain(`uid="${id}"`) + + const mainSide = await harness.cms(`${render}?row=${encodeURIComponent(home!.id)}&side=main`, { cookie: editor.cookie }) + expect(mainSide.status).toBe(200) + expect(await mainSide.text()).not.toContain('Rendered on the branch') + + expect((await harness.cms(`${render}?row=missing&side=main`, { cookie: editor.cookie })).status).toBe(404) + expect((await harness.cms(`${render}?row=${encodeURIComponent(home!.id)}&side=elsewhere`, { cookie: editor.cookie })).status).toBe(400) + const stranger = await harness.createRoleUser({ name: 'Stranger', slug: 'stranger', capabilities: ['dashboard.read'] }) + await expectForbidden(await harness.cms(`${render}?row=${encodeURIComponent(home!.id)}&side=main`, { cookie: stranger.cookie })) + }) +}) diff --git a/src/admin/AuthenticatedAdmin.tsx b/src/admin/AuthenticatedAdmin.tsx index 656951a1f..bc273ee02 100644 --- a/src/admin/AuthenticatedAdmin.tsx +++ b/src/admin/AuthenticatedAdmin.tsx @@ -109,6 +109,9 @@ const SiteImportModal = lazy(() => import('./modals/SiteImport').then((m) => ({ default: m.SiteImportModal })), ) +const BranchReviewPage = lazy(() => + import('./pages/branches/BranchReviewPage').then((m) => ({ default: m.BranchReviewPage })), +) const SiteExportModal = lazy(() => import('./modals/SiteExport').then((m) => ({ default: m.SiteExportModal })), ) @@ -197,6 +200,7 @@ function pageForSection(section: AdminWorkspace) { section === 'plugins' ? PluginsPage : section === 'users' ? UsersPage : section === 'ai' ? AiPage : + section === 'branchReview' ? SitePage : section === 'pluginPage' ? PluginPage : section === 'account' ? AccountPage : DashboardPage @@ -321,6 +325,7 @@ export default function AuthenticatedAdmin({ section, currentUser }: Authenticat section === 'plugins' ? <PluginsPage /> : section === 'users' ? <UsersPage /> : section === 'ai' ? <AiPage /> : + section === 'branchReview' ? <BranchReviewPage /> : section === 'pluginPage' ? <PluginPage /> : section === 'account' ? <AccountPage /> : <DashboardPage />} diff --git a/src/admin/access.ts b/src/admin/access.ts index 6228521ab..666643390 100644 --- a/src/admin/access.ts +++ b/src/admin/access.ts @@ -282,6 +282,8 @@ export function canAccessWorkspace(user: CmsCurrentUser | null, workspace: Admin return canAccessDataWorkspace(user) case 'media': return canReadMedia(user) + case 'branchReview': + return hasCapability(user, 'site.read') case 'plugins': case 'pluginPage': return canAccessPluginsWorkspace(user) @@ -322,6 +324,8 @@ export function workspacePath(workspace: AdminWorkspace): string { return '/admin/users' case 'ai': return '/admin/ai' + case 'branchReview': + return '/admin/site' case 'pluginPage': return '/admin/plugins' case 'account': diff --git a/src/admin/layouts/AdminWorkspaceCanvasLayout/AdminWorkspaceCanvasLayout.tsx b/src/admin/layouts/AdminWorkspaceCanvasLayout/AdminWorkspaceCanvasLayout.tsx index 277a94062..ece3b9ad7 100644 --- a/src/admin/layouts/AdminWorkspaceCanvasLayout/AdminWorkspaceCanvasLayout.tsx +++ b/src/admin/layouts/AdminWorkspaceCanvasLayout/AdminWorkspaceCanvasLayout.tsx @@ -34,7 +34,7 @@ const SettingsModal = lazy(() => import('@admin/modals/Settings/SettingsModal').then((m) => ({ default: m.SettingsModal })), ) -type WorkspaceCanvasSection = Extract<AdminWorkspace, 'content' | 'data' | 'media'> +type WorkspaceCanvasSection = Extract<AdminWorkspace, 'content' | 'data' | 'media' | 'branchReview'> interface AdminWorkspaceCanvasLayoutProps { workspace: WorkspaceCanvasSection @@ -55,7 +55,8 @@ export function AdminWorkspaceCanvasLayout({ const pluginBackgroundWorkEnabled = canRunPluginBackgroundWork(currentUser) useSiteSummary() - useWorkspaceLayoutPersistence(workspace) + // The merge review has no panels of its own; it reads the content workspace's stored layout. + useWorkspaceLayoutPersistence(workspace === 'branchReview' ? 'content' : workspace) useInstalledEditorPlugins(pluginBackgroundWorkEnabled) usePluginEventBridge(pluginBackgroundWorkEnabled) diff --git a/src/admin/pages/branches/BranchReviewPage.module.css b/src/admin/pages/branches/BranchReviewPage.module.css new file mode 100644 index 000000000..9ea228ba8 --- /dev/null +++ b/src/admin/pages/branches/BranchReviewPage.module.css @@ -0,0 +1,293 @@ +/* BranchReviewPage — the merge review as a timeline of changes. */ + +.canvas { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + height: 100%; + min-height: 0; + overflow: hidden; + background: var(--bg-surface-2); + border-top-left-radius: 16px; + border-top-right-radius: 16px; +} + +.page { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; } +.scroll { flex: 1; min-height: 0; overflow: auto; } + +.header { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: var(--space-l) var(--space-xl) var(--space-m); + border-bottom: 1px solid var(--border-subtle); +} + +.eyebrow { + display: flex; + align-items: center; + gap: var(--space-s); + font-size: var(--text-2xs); + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-subtle); +} + +.title { margin: 0; font-size: var(--text-xl); font-weight: 600; color: var(--text); } + +.meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-xs) var(--space-m); + font-size: var(--text-xs); + color: var(--text-muted); +} + +.meta strong { color: var(--text); font-weight: 600; } +.filters { margin-top: var(--space-xs); } +.filterCount { margin-left: var(--space-2xs); font-family: var(--font-mono); font-size: var(--text-2xs); opacity: 0.8; } + +.footer { + display: flex; + align-items: center; + gap: var(--space-s); + padding: var(--space-s) var(--space-xl); + border-top: 1px solid var(--border-subtle); + background: var(--bg-surface-2); +} + +.footerStatus { flex: 1; font-size: var(--text-xs); color: var(--text-muted); } +.footerToggle { display: inline-flex; align-items: center; gap: var(--space-xs); font-size: var(--text-xs); color: var(--text-muted); } + +.state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-s); + height: 100%; + padding: var(--space-xl); + color: var(--text-subtle); + font-size: var(--text-s); + text-align: center; +} + +.loading { display: flex; flex-direction: column; gap: var(--space-s); padding: var(--space-xl); } + +/* ---------- timeline ---------- */ + +.timeline { display: flex; flex-direction: column; gap: var(--space-xl); padding: var(--space-l) var(--space-xl) var(--space-2xl); } + +.node { + position: relative; + display: grid; + grid-template-columns: 24px 300px minmax(0, 1fr); + gap: var(--space-s) var(--space-m); + align-items: start; +} + +.node::before { + content: ""; + position: absolute; + left: 11px; + top: 28px; + bottom: calc(-1 * var(--space-xl)); + width: 1px; + background: var(--border-subtle); +} + +.node[data-last="true"]::before { display: none; } + +.marker { + position: sticky; + top: var(--space-m); + z-index: 1; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 999px; + background: var(--bg-surface-2); +} + +.left { position: sticky; top: var(--space-m); min-width: 0; padding-top: 2px; } +.right { min-width: 0; } + +.actionBadge { + width: 20px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + font-family: var(--font-mono); + font-size: var(--text-2xs); + font-weight: 700; + background: var(--bg-surface-4); + color: var(--text-muted); + flex-shrink: 0; +} + +.actionBadge[data-action="create"] { background: var(--success-20); color: var(--success-text); } +.actionBadge[data-action="update"] { background: var(--warning-20); color: var(--warning-text); } +.actionBadge[data-action="delete"] { background: var(--danger-20); color: var(--danger-text); } + +.dot { width: 7px; height: 7px; border-radius: 999px; background: var(--border-strong); outline: 4px solid var(--bg-surface-2); } +.spacer { flex: 1; } +.mono { font-family: var(--font-mono); } +.add { color: var(--success-text); } +.del { color: var(--danger-text); margin-left: var(--space-2xs); } +.fileCounts { font-family: var(--font-mono); font-size: var(--text-2xs); font-variant-numeric: tabular-nums; } +.hint { margin: 0; font-size: var(--text-xs); color: var(--text-subtle); line-height: 1.5; } + +/* ---------- thread ---------- */ + +.thread { display: flex; flex-direction: column; background: var(--bg-surface); border: 1px solid var(--border-subtle); border-radius: var(--radius); overflow: hidden; } + +.threadHead { + display: flex; + align-items: center; + gap: var(--space-xs); + min-height: 34px; + padding: var(--space-xs) var(--space-s); + background: var(--bg-surface-3); + font-size: var(--text-xs); + color: var(--text); +} + +.threadTitle { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-mono); } +.threadCount { color: var(--text-subtle); white-space: nowrap; } + +.threadItem { display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: var(--space-xs); padding: var(--space-s); border-top: 1px solid var(--border-subtle); font-size: var(--text-xs); line-height: 1.5; } +.threadAvatar { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; } +.threadItemHead { display: flex; align-items: baseline; gap: var(--space-xs); color: var(--text-subtle); } +.threadItemHead strong { color: var(--text); font-weight: 600; } +.threadItemText { margin: 3px 0 0; color: var(--text); white-space: pre-wrap; overflow-wrap: anywhere; } + +.threadComposer { display: flex; flex-direction: column; gap: var(--space-xs); padding: var(--space-s); border-top: 1px solid var(--border-subtle); background: var(--bg-surface-2); } +.threadComposerRow { display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: var(--space-xs); align-items: start; } +.threadComposerActions { display: flex; align-items: center; gap: var(--space-xs); padding-left: calc(20px + var(--space-xs)); } +.threadComposerHint { font-size: var(--text-2xs); color: var(--text-subtle); } + +.decision { padding: var(--space-s); border-top: 1px solid var(--border-subtle); font-size: var(--text-xs); line-height: 1.5; color: var(--text); } +.decision[data-tone="danger"] { background: var(--danger-5); } +.decision[data-tone="success"] { background: var(--success-10); } +.decision p { margin: 0; white-space: pre-wrap; } +.decisionWho { display: flex; align-items: center; gap: var(--space-xs); margin-bottom: var(--space-xs); color: var(--text-muted); } +.decisionWho strong { color: var(--text); font-weight: 600; } + +/* ---------- cards ---------- */ + +.card { background: var(--bg-surface); border: 1px solid var(--border-subtle); border-radius: var(--radius); overflow: hidden; min-width: 0; } + +.cardHead { + display: flex; + align-items: center; + gap: var(--space-xs); + min-height: 34px; + padding: var(--space-xs) var(--space-s); + background: var(--bg-surface-3); + font-size: var(--text-xs); + color: var(--text-muted); +} + +.cardHead strong { color: var(--text); font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.cardKind { font-size: var(--text-2xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-subtle); white-space: nowrap; } +.cardPath { font-family: var(--font-mono); color: var(--text-subtle); } +.cardEmpty { margin: 0; padding: var(--space-s); font-size: var(--text-xs); color: var(--text-subtle); } + +.requestNote { margin: 0; padding: var(--space-m); font-size: var(--text-s); line-height: 1.55; color: var(--text); white-space: pre-wrap; } +.requestEmpty { padding: var(--space-m); display: flex; flex-direction: column; gap: var(--space-s); font-size: var(--text-xs); color: var(--text-muted); } + +.facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); border-top: 1px solid var(--border-subtle); } +.fact { display: flex; flex-direction: column; gap: var(--space-2xs); padding: var(--space-s) var(--space-m); font-size: var(--text-xs); border-top: 1px solid var(--border-subtle); } +.fact:nth-child(-n + 2) { border-top: 0; } +.fact:nth-child(odd) { border-right: 1px solid var(--border-subtle); } +.factLabel { font-size: var(--text-2xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-subtle); } +.factValue { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2xs) var(--space-xs); color: var(--text); line-height: 1.5; } + +.statusPill { display: inline-flex; align-items: center; gap: 5px; padding: 1px var(--space-xs); border-radius: 999px; border: 1px solid var(--border); font-size: var(--text-2xs); font-weight: 600; white-space: nowrap; color: var(--text-muted); } +.statusPill::before { content: ""; width: 6px; height: 6px; border-radius: 999px; background: currentColor; } +.statusPill[data-tone="warning"] { color: var(--warning-text); background: var(--warning-10); border-color: var(--warning-30); } +.statusPill[data-tone="danger"] { color: var(--danger-text); background: var(--danger-10); border-color: var(--danger-30); } +.statusPill[data-tone="success"] { color: var(--success-text); background: var(--success-10); border-color: var(--success-30); } + +.conflictStrip { display: flex; align-items: center; gap: var(--space-s); padding: var(--space-xs) var(--space-s); border-bottom: 1px solid var(--warning-30); background: var(--warning-10); color: var(--warning-text); font-size: var(--text-xs); } +.conflictStrip[data-resolved="true"] { border-color: var(--border-subtle); background: var(--bg-surface-2); color: var(--text-muted); } +.conflictText { flex: 1; min-width: 0; line-height: 1.45; } +.conflictText strong { font-weight: 600; } + +/* ---------- page compare ---------- */ + +.compareBar { display: flex; align-items: center; gap: var(--space-s); padding: var(--space-xs) var(--space-s); border-bottom: 1px solid var(--border-subtle); font-size: var(--text-xs); color: var(--text-muted); } +.compareToggle { display: inline-flex; align-items: center; gap: var(--space-xs); } +.compareGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-s); padding: var(--space-s); } +.compareGrid[data-single="true"] { grid-template-columns: minmax(0, 1fr); max-width: 720px; } +.compareCol { display: flex; flex-direction: column; gap: var(--space-2xs); min-width: 0; } +.compareLabel { font-size: var(--text-2xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-subtle); } + +.frameHost { position: relative; width: 100%; height: var(--frame-h); overflow: hidden; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle); background: var(--bg-surface-5); } +.frameHost[data-loaded="false"] { background: var(--bg-surface-4); } +.frameError { margin: 0; padding: var(--space-m); font-size: var(--text-xs); color: var(--danger-text); } +.frameStage { position: absolute; top: 0; left: 0; width: 1280px; height: var(--doc-h); transform: scale(var(--frame-scale)); transform-origin: top left; } +.frame { width: 1280px; height: var(--doc-h); border: 0; display: block; background: var(--bg-surface); pointer-events: none; } + +.highlight { position: absolute; left: var(--hl-x); top: var(--hl-y); width: var(--hl-w); height: var(--hl-h); border: 3px solid var(--warning); border-radius: 6px; background: var(--warning-10); pointer-events: none; box-sizing: border-box; } +.highlight[data-tone="added"] { border-color: var(--success); background: var(--success-10); } +.highlight[data-tone="removed"] { border-color: var(--danger-light); background: var(--danger-10); } +.highlightLabel { position: absolute; left: -3px; top: -30px; padding: 3px 10px; border-radius: 4px; background: var(--warning); color: var(--bg-body); font-size: calc(var(--text-s) * 2.4); font-weight: 700; white-space: nowrap; } +.highlight[data-tone="added"] .highlightLabel { background: var(--success); } +.highlight[data-tone="removed"] .highlightLabel { background: var(--danger-light); } + +.swipe { padding: var(--space-s); } +.swipeStack { position: relative; } +.swipeTop { position: absolute; inset: 0; clip-path: inset(0 calc(100% - var(--split)) 0 0); } +.swipeLine { position: absolute; top: 0; bottom: 0; left: var(--split); width: 2px; background: var(--warning); transform: translateX(-1px); pointer-events: none; } +.swipeTagLeft, .swipeTagRight { position: absolute; top: 6px; padding: 1px 6px; border-radius: 3px; font-size: var(--text-3xs); font-weight: 700; color: var(--bg-body); background: var(--text); pointer-events: none; } +.swipeTagLeft { left: 6px; } +.swipeTagRight { right: 6px; } +.swipeRange { width: 100%; margin-top: var(--space-xs); accent-color: var(--warning); } + +.changeList { margin: 0; padding: var(--space-s) var(--space-m) var(--space-s) calc(var(--space-m) + 16px); font-size: var(--text-xs); line-height: 1.6; color: var(--text); } +.changeListEmpty { margin: 0; padding: var(--space-s) var(--space-m); font-size: var(--text-xs); color: var(--text-subtle); } + +/* ---------- field / schema / diff ---------- */ + +.fieldTable { width: 100%; border-collapse: collapse; font-size: var(--text-xs); table-layout: fixed; } +.fieldTable th { text-align: left; padding: var(--space-xs) var(--space-s); font-size: var(--text-2xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-subtle); border-bottom: 1px solid var(--border-subtle); } +.fieldTable th:first-child { width: 140px; } +.fieldTable td { padding: var(--space-xs) var(--space-s); vertical-align: top; border-bottom: 1px solid var(--border-subtle); line-height: 1.5; overflow-wrap: anywhere; } +.fieldTable tr:last-child td { border-bottom: 0; } +.fieldName { color: var(--text-muted); } +.fieldRow[data-conflict="true"] .fieldName { color: var(--warning-text); } +.cellBefore { background: var(--danger-5); color: var(--text-muted); text-decoration: line-through; text-decoration-color: var(--danger-text); } +.cellAfter { background: var(--success-10); } +.cellBefore[data-structured="true"], .cellAfter[data-structured="true"] { font-family: var(--font-mono); font-size: var(--text-2xs); } +.cellEmpty { color: var(--text-subtle); font-style: italic; text-decoration: none; } + +.schemaList { margin: 0; padding: 0; list-style: none; } +.schemaRow { display: flex; align-items: center; gap: var(--space-s); padding: var(--space-xs) var(--space-s); border-top: 1px solid var(--border-subtle); font-size: var(--text-xs); } +.schemaRow:first-child { border-top: 0; } +.schemaType { font-family: var(--font-mono); color: var(--text-subtle); } +.schemaBadge { margin-left: auto; font-size: var(--text-3xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; padding: 1px 6px; border-radius: 999px; } +.schemaBadge[data-status="new"] { background: var(--success-20); color: var(--success-text); } +.schemaBadge[data-status="changed"] { background: var(--warning-20); color: var(--warning-text); } +.schemaBadge[data-status="removed"] { background: var(--danger-20); color: var(--danger-text); } +.schemaBadge[data-status="same"] { color: var(--text-subtle); } + +.diff { font-family: var(--font-mono); font-size: var(--text-xs); line-height: 1.55; overflow: auto; max-height: 480px; } +.diffRow { display: grid; grid-template-columns: 34px 34px 16px minmax(0, 1fr); } +.diffRow[data-type="add"] { background: var(--success-10); } +.diffRow[data-type="del"] { background: var(--danger-5); } +.diffNo { text-align: right; padding-right: 6px; color: var(--text-subtle); user-select: none; } +.diffSign { color: var(--text-subtle); user-select: none; } +.diffRow[data-type="add"] .diffSign { color: var(--success-text); } +.diffRow[data-type="del"] .diffSign { color: var(--danger-text); } +.diffCode { white-space: pre; padding-right: var(--space-s); color: var(--text); } + +.dialogBody { display: flex; flex-direction: column; gap: var(--space-s); } +.dialogHint { margin: 0; font-size: var(--text-xs); color: var(--text-muted); line-height: 1.5; } diff --git a/src/admin/pages/branches/BranchReviewPage.tsx b/src/admin/pages/branches/BranchReviewPage.tsx new file mode 100644 index 000000000..239f4b75a --- /dev/null +++ b/src/admin/pages/branches/BranchReviewPage.tsx @@ -0,0 +1,675 @@ +/** + * BranchReviewPage — the merge review of one branch + * (`/admin/branches/:branchId/review`). + * + * One timeline: the merge request opens it (its note, status, and what + * merging grants), then one node per change the plan lists — a page as + * before/after frames, an entry as a field table, a table as its schema, + * a file as a line diff — each with its own comment thread, and the + * decision closes it. Conflicts are decided in place; the footer's merge + * stays disabled until every one has a side. Merging runs the same + * step-up-gated apply the branch strip used to run from a dialog. + */ +import { useEffect, useState } from 'react' +import { MAIN_BRANCH_ID, type MergeChange, type MergeResolution, type ReviewUserLabel } from '@core/branches' +import { getErrorMessage } from '@core/utils/errorMessage' +import { AdminWorkspaceCanvasLayout } from '@admin/layouts/AdminWorkspaceCanvasLayout/AdminWorkspaceCanvasLayout' +import { useNavigate, useParams } from '@admin/lib/routing' +import { hasCapability } from '@admin/access' +import { useAuthenticatedAdminUser } from '@admin/sessionContext' +import { mergeBranch, refreshBranches, switchBranch, useActiveBranchId, useBranchStore } from '@admin/state/branchStore' +import { StepUpCancelledMessage, useStepUp } from '@admin/shared/StepUp' +import { UserAvatar } from '@admin/shared/UserAvatar/UserAvatar' +import { Button } from '@ui/components/Button' +import { Dialog } from '@ui/components/Dialog' +import { FilterBar } from '@ui/components/FilterBar' +import { Textarea } from '@ui/components/Input' +import { Skeleton } from '@ui/components/Skeleton' +import { Switch } from '@ui/components/Switch' +import { pushToast } from '@ui/components/Toast' +import { CheckIcon } from 'pixel-art-icons/icons/check' +import { GitMergeSolidIcon } from 'pixel-art-icons/icons/git-merge-solid' +import { ReviewChangeCard } from './ReviewChangeCard' +import { ReviewThread } from './ReviewThread' +import { + ACTION_LETTER, + ACTION_WORD, + FILTER_LABELS, + REQUEST_ENTITY_KEY, + REVIEW_FILTERS, + isPageChange, + matchesFilter, + relativeIso, + requestStatusLabel, + requestStatusTone, + type ReviewFilter, +} from './reviewFormat' +import { useBranchReview } from './useBranchReview' +import styles from './BranchReviewPage.module.css' + +export function BranchReviewPage() { + const params = useParams<{ branchId: string }>() + const branchId = params.branchId ?? '' + const branches = useBranchStore((state) => state.branches) + const branchesLoaded = useBranchStore((state) => state.branchesLoaded) + const activeBranchId = useActiveBranchId() + const branch = branches.find((candidate) => candidate.id === branchId) ?? null + + useEffect(() => { + if (!branchesLoaded) void refreshBranches() + }, [branchesLoaded]) + + // Reviewing a branch means being on it: "back to the editor" lands there, + // and the toolbar strip names what is being reviewed. + useEffect(() => { + if (branch && activeBranchId !== branch.id) switchBranch(branch.id) + }, [branch, activeBranchId]) + + if (!branchesLoaded || !branch || branch.id === MAIN_BRANCH_ID) { + return ( + <AdminWorkspaceCanvasLayout + workspace="branchReview" + contentCanvas={( + <div className={styles.canvas}> + {branchesLoaded ? ( + <div className={styles.state} role="alert"> + {branch?.id === MAIN_BRANCH_ID + ? 'Main is the live site; it is what branches merge into.' + : `There is no branch “${branchId}”.`} + </div> + ) : ( + <div className={styles.loading} aria-busy="true" aria-label="Loading the branch"> + <Skeleton width="40%" height={14} radius={999} /> + <Skeleton width="70%" height={14} radius={999} /> + </div> + )} + </div> + )} + /> + ) + } + return <Review key={branch.id} branchId={branch.id} branchName={branch.name} /> +} + +interface ReviewProps { + branchId: string + branchName: string +} + +function Review({ branchId, branchName }: ReviewProps) { + const navigate = useNavigate() + const user = useAuthenticatedAdminUser() + const canManage = hasCapability(user, 'site.branches.manage') + const { runStepUp } = useStepUp() + const data = useBranchReview(branchId) + const [filter, setFilter] = useState<ReviewFilter>('all') + const [resolutions, setResolutions] = useState<Record<string, MergeResolution>>({}) + const [deleteAfter, setDeleteAfter] = useState(true) + const [dialog, setDialog] = useState<'request' | 'decline' | null>(null) + const [busy, setBusy] = useState(false) + + const me: ReviewUserLabel = { + id: user.id, + displayName: user.displayName, + email: user.email, + avatarUrl: user.avatarUrl, + gravatarHash: user.gravatarHash, + } + + const { plan, review } = data + if (data.loadError) { + return ( + <AdminWorkspaceCanvasLayout + workspace="branchReview" + contentCanvas={( + <div className={styles.canvas}> + <div className={styles.state} role="alert"> + <span>{data.loadError}</span> + <Button variant="secondary" size="sm" type="button" onClick={() => { void data.reload() }}> + Try again + </Button> + </div> + </div> + )} + /> + ) + } + if (!plan || !review) { + return ( + <AdminWorkspaceCanvasLayout + workspace="branchReview" + contentCanvas={( + <div className={styles.canvas}> + <div className={styles.loading} aria-busy="true" aria-label="Comparing the branch with main"> + <Skeleton width="40%" height={14} radius={999} /> + <Skeleton width="70%" height={14} radius={999} /> + <Skeleton width="55%" height={14} radius={999} /> + </div> + </div> + )} + /> + ) + } + + // Narrowed copies for the closures below (TS drops the narrowing inside them). + const loadedPlan = plan + const request = review.request + const open = request?.status === 'open' + const stale = open && request.contentHash !== review.contentHash + const commentsFor = (key: string) => review.comments.filter((comment) => comment.entityKey === key) + const unresolved = plan.changes.filter((change) => change.conflicts.length > 0 && !resolutions[change.key]) + const visible = plan.changes.filter((change) => matchesFilter(change, filter, commentsFor(change.key).length)) + const counts = { + pages: plan.changes.filter(isPageChange).length, + entries: plan.changes.filter((change) => change.kind === 'row' && !isPageChange(change)).length, + tables: plan.changes.filter((change) => change.kind === 'table').length, + files: plan.changes.filter((change) => change.kind === 'file').length, + site: plan.changes.filter((change) => change.kind === 'site').length, + } + const filterCount = (id: ReviewFilter): number => + plan.changes.filter((change) => matchesFilter(change, id, commentsFor(change.key).length)).length + + async function withBusy(label: string, action: () => Promise<unknown>): Promise<boolean> { + setBusy(true) + try { + await action() + return true + } catch (err) { + if (err instanceof Error && err.message === StepUpCancelledMessage) return false + console.error(`[branch-review] ${label} failed:`, err) + pushToast({ kind: 'error', title: `Could not ${label}`, body: getErrorMessage(err, 'Unknown review error') }) + // A request that changed under us is the usual cause: show the truth. + void data.reload() + return false + } finally { + setBusy(false) + } + } + + async function merge(): Promise<void> { + if (unresolved.length > 0 || loadedPlan.changes.length === 0) return + const done = await withBusy('merge the branch', async () => { + const result = await runStepUp(() => mergeBranch(branchId, 'merge', { resolutions, deleteBranch: deleteAfter })) + const count = result.plan.changes.length + pushToast({ + kind: 'success', + title: `Merged ${branchName} into main`, + body: `${count} change${count === 1 ? '' : 's'} landed in main's draft. Publish when you're ready.${result.branchDeleted ? ' The branch was deleted.' : ''}`, + }) + }) + if (done) navigate('/admin/site') + } + + const changeCountLabel = `${plan.changes.length} change${plan.changes.length === 1 ? '' : 's'}` + const title = request?.status === 'declined' + ? `Changes requested on ${branchName}` + : request?.status === 'merged' + ? `${branchName} was merged into main` + : `Merge ${branchName} into main` + + return ( + <AdminWorkspaceCanvasLayout + workspace="branchReview" + contentCanvas={( + <div className={styles.canvas} data-testid="branch-review"> + <div className={styles.page}> + <div className={styles.scroll}> + <header className={styles.header}> + <div className={styles.eyebrow}> + <span>Merge review</span> + <span>·</span> + <span>{branchName} → main</span> + </div> + <h1 className={styles.title} data-testid="branch-review-title">{title}</h1> + <div className={styles.meta}> + {request ? ( + <span> + <strong>{request.requestedBy?.displayName ?? 'Removed user'}</strong> requested {relativeIso(request.createdAt)} ago + </span> + ) : ( + <span>No merge request yet</span> + )} + <span>{changeCountLabel}</span> + {plan.conflictCount > 0 && ( + <span className={styles.del}>{plan.conflictCount} conflict{plan.conflictCount === 1 ? '' : 's'}</span> + )} + <span>{review.comments.length} comment{review.comments.length === 1 ? '' : 's'}</span> + {request && <StatusPill status={request.status} unresolved={open ? unresolved.length : 0} />} + </div> + <div className={styles.filters}> + <FilterBar + items={REVIEW_FILTERS.map((id) => ({ + value: id, + label: ( + <> + {FILTER_LABELS[id]} + <span className={styles.filterCount}>{filterCount(id)}</span> + </> + ), + ariaLabel: `${FILTER_LABELS[id]}, ${filterCount(id)}`, + }))} + value={filter} + onValueChange={setFilter} + groupLabel="Filter changes" + /> + </div> + </header> + + <div className={styles.timeline}> + {filter === 'all' && ( + <TimelineNode + id="review-request" + marker={request?.requestedBy ? <UserAvatar user={request.requestedBy} size={22} /> : <span className={styles.dot} />} + left={( + <ReviewThread + title="Conversation" + comments={commentsFor(REQUEST_ENTITY_KEY)} + me={me} + placeholder="Comment on the request" + onPost={(body) => data.comment(REQUEST_ENTITY_KEY, body)} + testId="review-thread-request" + /> + )} + right={( + <section className={styles.card} data-testid="review-request-card"> + {request ? ( + <> + <div className={styles.cardHead}> + {request.requestedBy && <UserAvatar user={request.requestedBy} size={18} />} + <strong>{request.requestedBy?.displayName ?? 'Removed user'}</strong> + <span>asked to merge {branchName} into main</span> + <span>{relativeIso(request.createdAt)}</span> + <span className={styles.spacer} /> + <StatusPill status={request.status} unresolved={open ? unresolved.length : 0} /> + </div> + {request.note ? ( + <p className={styles.requestNote}>{request.note}</p> + ) : ( + <p className={styles.cardEmpty}>No note.</p> + )} + </> + ) : ( + <> + <div className={styles.cardHead}> + <strong>No merge request yet</strong> + </div> + <div className={styles.requestEmpty}> + <span> + {canManage + ? 'You can merge from the bar below, or ask another manager to review by requesting a merge.' + : 'When the branch is ready, request a merge so a branch manager reviews it.'} + </span> + <div> + <Button variant="secondary" size="sm" type="button" onClick={() => setDialog('request')} data-testid="review-request-open"> + Request merge… + </Button> + </div> + </div> + </> + )} + <div className={styles.facts}> + <div className={styles.fact}> + <span className={styles.factLabel}>Changes</span> + <span className={styles.factValue}> + {plan.changes.length === 0 + ? 'Main already has everything on this branch.' + : [ + counts.pages > 0 ? `${counts.pages} page${counts.pages === 1 ? '' : 's'}` : null, + counts.entries > 0 ? `${counts.entries} entr${counts.entries === 1 ? 'y' : 'ies'}` : null, + counts.tables > 0 ? `${counts.tables} table${counts.tables === 1 ? '' : 's'}` : null, + counts.files > 0 ? `${counts.files} file${counts.files === 1 ? '' : 's'}` : null, + counts.site > 0 ? 'site settings' : null, + ].filter(Boolean).join(', ')} + </span> + </div> + <div className={styles.fact}> + <span className={styles.factLabel}>Conflicts</span> + <span className={styles.factValue}> + {plan.conflictCount === 0 + ? 'None. Main did not touch what the branch changed.' + : unresolved.length === 0 + ? `${plan.conflictCount} decided.` + : `${unresolved.length} need${unresolved.length === 1 ? 's' : ''} a decision before merging.`} + </span> + </div> + <div className={styles.fact}> + <span className={styles.factLabel}>Freshness</span> + <span className={styles.factValue}> + {!request + ? 'Compared against main as of now.' + : stale + ? 'The branch changed after this request was made; the changes below are current.' + : 'The branch is unchanged since the request.'} + </span> + </div> + <div className={styles.fact}> + <span className={styles.factLabel}>What merging does</span> + <span className={styles.factValue}> + Writes every change to main's draft and mirrors the result onto the branch. Nothing is published. + </span> + </div> + </div> + </section> + )} + /> + )} + + {plan.changes.length === 0 && ( + <div className={styles.state}>Nothing to review: main already has everything on this branch.</div> + )} + + {visible.map((change) => ( + <TimelineNode + key={change.key} + id={`review-change-${change.key}`} + marker={( + <span className={styles.actionBadge} data-action={change.action} aria-label={ACTION_WORD[change.action]}> + {ACTION_LETTER[change.action]} + </span> + )} + left={( + <ReviewThread + title={change.kind === 'row' && change.tableName && !isPageChange(change) ? `${change.tableName}: ${change.label}` : change.label} + comments={commentsFor(change.key)} + me={me} + placeholder={threadPlaceholder(change)} + onPost={(body) => data.comment(change.key, body)} + testId={`review-thread-${change.key}`} + /> + )} + right={( + <ReviewChangeCard + branchId={branchId} + change={change} + resolution={resolutions[change.key]} + canResolve={canManage && (request === null || open)} + onResolve={(resolution) => setResolutions((current) => ({ ...current, [change.key]: resolution }))} + /> + )} + /> + ))} + + {filter === 'all' && request && ( + <TimelineNode + id="review-decision" + last + marker={request.resolvedBy ? <UserAvatar user={request.resolvedBy} size={22} /> : <span className={styles.dot} />} + left={( + <div className={styles.thread} data-testid="review-decision"> + <div className={styles.threadHead}> + <span className={styles.threadTitle}>Decision</span> + <span className={styles.spacer} /> + <StatusPill status={request.status} unresolved={open ? unresolved.length : 0} /> + </div> + {request.status === 'declined' && ( + <div className={styles.decision} data-tone="danger"> + <div className={styles.decisionWho}> + {request.resolvedBy && <UserAvatar user={request.resolvedBy} size={18} />} + <strong>{request.resolvedBy?.displayName ?? 'A branch manager'}</strong> + <span>declined · {request.resolvedAt ? relativeIso(request.resolvedAt) : ''}</span> + </div> + <p>{request.resolutionNote}</p> + </div> + )} + {request.status === 'merged' && ( + <div className={styles.decision} data-tone="success"> + <div className={styles.decisionWho}> + <CheckIcon size={12} aria-hidden="true" /> + <strong>{request.resolvedBy?.displayName ?? 'A branch manager'}</strong> + <span>merged · {request.resolvedAt ? relativeIso(request.resolvedAt) : ''}</span> + </div> + <p>The changes are in main's draft. Publish main when you are ready.</p> + </div> + )} + {request.status === 'withdrawn' && ( + <div className={styles.decision}> + <p className={styles.hint}>The request was withdrawn. Request again when the branch is ready.</p> + </div> + )} + {open && ( + <div className={styles.decision}> + <p className={styles.hint}> + {canManage ? 'Merge or decline in the bar below.' : 'Waiting for a branch manager.'} + </p> + </div> + )} + </div> + )} + right={( + <section className={styles.card}> + <div className={styles.cardHead}> + <strong>{open ? 'What the decision does' : 'Outcome'}</strong> + </div> + {open ? ( + <div className={styles.facts}> + <div className={styles.fact}> + <span className={styles.factLabel}>Merge</span> + <span className={styles.factValue}>Writes every change to main's draft, mirrors the result to the branch, and deletes the branch if chosen. Asks for a password.</span> + </div> + <div className={styles.fact}> + <span className={styles.factLabel}>Decline</span> + <span className={styles.factValue}>Needs a note. The requester sees it here and can request again after fixing the branch.</span> + </div> + </div> + ) : ( + <p className={styles.requestNote}> + {request.status === 'declined' + ? 'The branch stays as it is. Fix what the note asks for and request a merge again; every comment above stays with the branch.' + : request.status === 'merged' + ? 'Main’s draft now holds these changes. Publish main to make them live.' + : 'Nothing was merged.'} + </p> + )} + </section> + )} + /> + )} + </div> + </div> + + <footer className={styles.footer} data-testid="branch-review-footer"> + {canManage ? ( + <> + <label className={styles.footerToggle}> + <Switch checked={deleteAfter} onCheckedChange={setDeleteAfter} switchSize="sm" aria-label="Delete branch after merging" data-testid="review-delete-toggle" /> + <span>Delete branch after merging</span> + </label> + <span className={styles.footerStatus}> + {plan.changes.length === 0 + ? '' + : unresolved.length > 0 + ? `${unresolved.length} conflict${unresolved.length === 1 ? '' : 's'} still need${unresolved.length === 1 ? 's' : ''} a decision.` + : `Merging writes ${changeCountLabel} to main's draft.`} + </span> + {open && ( + <Button variant="secondary" size="sm" type="button" disabled={busy} onClick={() => setDialog('decline')} data-testid="review-decline-open"> + Decline… + </Button> + )} + <Button + variant="primary" + size="sm" + type="button" + busy={busy} + disabled={busy || plan.changes.length === 0 || unresolved.length > 0} + tooltip={plan.changes.length === 0 ? 'Nothing to merge' : unresolved.length > 0 ? `${unresolved.length} conflict${unresolved.length === 1 ? '' : 's'} still need a decision` : undefined} + onClick={() => { void merge() }} + data-testid="review-merge" + > + <GitMergeSolidIcon size={12} aria-hidden="true" /> + <span>Merge {changeCountLabel}</span> + </Button> + </> + ) : open ? ( + <> + <span className={styles.footerStatus}>Waiting for a branch manager to review.</span> + {request.requestedBy?.id === user.id && ( + <Button + variant="secondary" + size="sm" + type="button" + busy={busy} + onClick={() => { void withBusy('withdraw the request', () => data.withdraw()) }} + data-testid="review-withdraw" + > + Withdraw request + </Button> + )} + </> + ) : ( + <> + <span className={styles.footerStatus}> + {request?.status === 'declined' + ? 'Fix what the note asks for, then request a merge again.' + : request?.status === 'merged' + ? 'This branch was merged.' + : 'Request a merge when the branch is ready for review.'} + </span> + {request?.status !== 'merged' && ( + <Button variant="primary" size="sm" type="button" disabled={busy} onClick={() => setDialog('request')} data-testid="review-request-open"> + {request?.status === 'declined' ? 'Request merge again…' : 'Request merge…'} + </Button> + )} + </> + )} + </footer> + </div> + + {dialog === 'request' && ( + <NoteDialog + eyebrow="Request a merge" + title={`Ask to merge ${branchName} into main`} + hint="Say what changed and why. A branch manager reads the changes below and merges or declines." + placeholder="What is in this branch?" + confirmLabel="Send request" + required={false} + busy={busy} + onClose={() => setDialog(null)} + onConfirm={async (note) => { + const done = await withBusy('request the merge', () => data.request(note)) + if (done) setDialog(null) + }} + testId="review-request" + /> + )} + {dialog === 'decline' && request && ( + <NoteDialog + eyebrow="Decline" + title={`What should ${request.requestedBy?.displayName ?? 'the requester'} change?`} + hint="The note is what the requester sees. Say what to fix." + placeholder="Required" + confirmLabel="Decline with note" + required + tone="danger" + busy={busy} + onClose={() => setDialog(null)} + onConfirm={async (note) => { + const done = await withBusy('decline the request', () => data.decline(note)) + if (done) setDialog(null) + }} + testId="review-decline" + /> + )} + </div> + )} + /> + ) +} + +function threadPlaceholder(change: MergeChange): string { + if (isPageChange(change)) return 'Comment on this page' + if (change.kind === 'row') return 'Comment on this entry' + if (change.kind === 'table') return 'Comment on this table' + if (change.kind === 'file') return 'Comment on this file' + return 'Comment on these settings' +} + +function StatusPill({ status, unresolved }: { status: 'open' | 'declined' | 'merged' | 'withdrawn'; unresolved: number }) { + const label = status === 'open' && unresolved > 0 + ? `${requestStatusLabel(status)} · ${unresolved} conflict${unresolved === 1 ? '' : 's'}` + : requestStatusLabel(status) + return ( + <span className={styles.statusPill} data-tone={requestStatusTone(status)} data-testid="review-status"> + {label} + </span> + ) +} + +interface TimelineNodeProps { + id: string + marker: React.ReactNode + left: React.ReactNode + right: React.ReactNode + last?: boolean +} + +function TimelineNode({ id, marker, left, right, last = false }: TimelineNodeProps) { + return ( + <div id={id} className={styles.node} data-last={last ? 'true' : 'false'}> + <span className={styles.marker}>{marker}</span> + <div className={styles.left}>{left}</div> + <div className={styles.right}>{right}</div> + </div> + ) +} + +interface NoteDialogProps { + eyebrow: string + title: string + hint: string + placeholder: string + confirmLabel: string + required: boolean + tone?: 'danger' + busy: boolean + onClose: () => void + onConfirm: (note: string) => Promise<void> + testId: string +} + +function NoteDialog({ eyebrow, title, hint, placeholder, confirmLabel, required, tone, busy, onClose, onConfirm, testId }: NoteDialogProps) { + const [note, setNote] = useState('') + const canConfirm = !busy && (!required || note.trim().length > 0) + return ( + <Dialog + open + size="md" + tone={tone} + onClose={busy ? () => {} : onClose} + eyebrow={eyebrow} + title={title} + footer={( + <> + <Button variant="secondary" size="sm" type="button" onClick={onClose} disabled={busy}> + Cancel + </Button> + <Button + variant={tone === 'danger' ? 'destructive' : 'primary'} + size="sm" + type="button" + busy={busy} + disabled={!canConfirm} + onClick={() => { void onConfirm(note) }} + data-testid={`${testId}-confirm`} + > + {confirmLabel} + </Button> + </> + )} + > + <div className={styles.dialogBody}> + <p className={styles.dialogHint}>{hint}</p> + <Textarea + fieldSize="sm" + rows={4} + autoFocus + placeholder={placeholder} + value={note} + disabled={busy} + onChange={(event) => setNote(event.target.value)} + data-testid={`${testId}-note`} + /> + </div> + </Dialog> + ) +} diff --git a/src/admin/pages/branches/PageCompare.tsx b/src/admin/pages/branches/PageCompare.tsx new file mode 100644 index 000000000..1a5f9956c --- /dev/null +++ b/src/admin/pages/branches/PageCompare.tsx @@ -0,0 +1,259 @@ +/** + * PageCompare — one page as main renders it and as the branch renders it, + * in scaled, sandboxed frames. The HTML comes from the review's render + * endpoint through the API client and is handed to the frame as `srcdoc` + * (a frame navigation would not carry the admin session the same way); + * once a frame has loaded, the nodes the plan lists as changed are found + * by their `uid` attribute and outlined in place, so the highlights come + * from the tree diff, not from guesses. Side by side, a swipe with one + * frame clipped over the other, or the plain change list. + */ +import { useEffect, useRef, useState, type CSSProperties } from 'react' +import type { MergeTreeDiff, ReviewRenderSide } from '@core/branches' +import { apiTextRequest, isAbortError } from '@core/http' +import { cmsBranchReviewRenderUrl } from '@core/persistence' +import { getErrorMessage } from '@core/utils/errorMessage' +import { SegmentedControl } from '@ui/components/SegmentedControl' +import { Switch } from '@ui/components/Switch' +import styles from './BranchReviewPage.module.css' + +const PAGE_WIDTH = 1280 +const MIN_HEIGHT = 720 +const MAX_HEIGHT = 2400 + +interface HighlightBox { + id: string + label: string + tone: 'added' | 'changed' | 'removed' + x: number + y: number + width: number + height: number +} + +interface FrameProps { + branchId: string + rowId: string + side: ReviewRenderSide + title: string + /** Node ids to outline in this frame, with their tone. */ + marks: Array<{ id: string; label: string; tone: HighlightBox['tone'] }> + showHighlights: boolean +} + +function ScaledFrame({ branchId, rowId, side, title, marks, showHighlights }: FrameProps) { + const hostRef = useRef<HTMLDivElement | null>(null) + const frameRef = useRef<HTMLIFrameElement | null>(null) + const [scale, setScale] = useState(0.3) + const [docHeight, setDocHeight] = useState(MIN_HEIGHT) + const [boxes, setBoxes] = useState<HighlightBox[]>([]) + const [loaded, setLoaded] = useState(false) + const [html, setHtml] = useState<string | null>(null) + const [error, setError] = useState<string | null>(null) + + useEffect(() => { + const controller = new AbortController() + apiTextRequest(cmsBranchReviewRenderUrl(branchId, rowId, side), { + signal: controller.signal, + fallbackMessage: 'Could not render the page', + }) + .then((text) => { + if (!controller.signal.aborted) setHtml(text) + }) + .catch((err: unknown) => { + if (isAbortError(err) || controller.signal.aborted) return + console.error('[branch-review] page render failed:', err) + setError(getErrorMessage(err, 'Could not render the page')) + }) + return () => controller.abort() + }, [branchId, rowId, side]) + + useEffect(() => { + const host = hostRef.current + if (!host) return undefined + const observer = new ResizeObserver((entries) => { + const width = entries[0]?.contentRect.width ?? 0 + if (width > 0) setScale(width / PAGE_WIDTH) + }) + observer.observe(host) + return () => observer.disconnect() + }, []) + + function measure(): void { + const frame = frameRef.current + const doc = frame?.contentDocument + if (!frame || !doc?.documentElement) return + const height = Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, doc.documentElement.scrollHeight)) + setDocHeight(height) + const next: HighlightBox[] = [] + for (const mark of marks) { + const element = doc.querySelector(`[uid="${CSS.escape(mark.id)}"]`) + if (!(element instanceof doc.defaultView!.HTMLElement)) continue + const rect = element.getBoundingClientRect() + if (rect.width === 0 && rect.height === 0) continue + next.push({ + id: mark.id, + label: mark.label, + tone: mark.tone, + x: rect.left + (doc.defaultView?.scrollX ?? 0), + y: rect.top + (doc.defaultView?.scrollY ?? 0), + width: rect.width, + height: rect.height, + }) + } + setBoxes(next) + setLoaded(true) + } + + const hostStyle = { '--frame-scale': scale, '--frame-h': `${docHeight * scale}px` } as CSSProperties + const stageStyle = { '--doc-h': `${docHeight}px` } as CSSProperties + if (error) { + return ( + <div ref={hostRef} className={styles.frameHost} style={hostStyle} data-loaded="error" role="alert"> + <p className={styles.frameError}>{error}</p> + </div> + ) + } + return ( + <div ref={hostRef} className={styles.frameHost} style={hostStyle} data-loaded={loaded ? 'true' : 'false'}> + <div className={styles.frameStage} style={stageStyle}> + {html !== null && ( + <iframe + ref={frameRef} + title={title} + srcDoc={html} + sandbox="allow-same-origin" + referrerPolicy="no-referrer" + className={styles.frame} + tabIndex={-1} + onLoad={measure} + /> + )} + {showHighlights && boxes.map((box) => ( + <span + key={box.id} + className={styles.highlight} + data-tone={box.tone} + style={{ '--hl-x': `${box.x}px`, '--hl-y': `${box.y}px`, '--hl-w': `${box.width}px`, '--hl-h': `${box.height}px` } as CSSProperties} + > + <span className={styles.highlightLabel}>{box.label}</span> + </span> + ))} + </div> + </div> + ) +} + +type Mode = 'side' | 'swipe' | 'list' + +interface PageCompareProps { + branchId: string + rowId: string + label: string + action: 'create' | 'update' | 'delete' + tree: MergeTreeDiff | null + /** Plain-text field changes shown in the "What changed" list. */ + fieldLines: string[] + mainLabel: string +} + +export function PageCompare({ branchId, rowId, label, action, tree, fieldLines, mainLabel }: PageCompareProps) { + const [mode, setMode] = useState<Mode>('side') + const [showHighlights, setShowHighlights] = useState(true) + const [split, setSplit] = useState(50) + const hasMain = action !== 'create' + const hasBranch = action !== 'delete' + const bothSides = hasMain && hasBranch + + const branchMarks = tree + ? [ + ...tree.changed.map((id) => ({ id, label: tree.labels[id] ?? 'changed', tone: 'changed' as const })), + ...tree.added.map((id) => ({ id, label: tree.labels[id] ?? 'added', tone: 'added' as const })), + ] + : [] + const mainMarks = tree ? tree.removed.map((id) => ({ id, label: tree.labels[id] ?? 'removed', tone: 'removed' as const })) : [] + const treeLines = tree + ? [ + ...tree.added.map((id) => `Added ${tree.labels[id] ?? id}`), + ...tree.changed.map((id) => `Changed ${tree.labels[id] ?? id}`), + ...tree.removed.map((id) => `Removed ${tree.labels[id] ?? id}`), + ] + : [] + const lines = [...fieldLines, ...treeLines] + + return ( + <div> + <div className={styles.compareBar}> + <SegmentedControl + value={mode} + size="xs" + aria-label="Compare mode" + options={[ + { value: 'side', label: 'Side by side' }, + { value: 'swipe', label: 'Swipe', tooltip: bothSides ? undefined : 'Needs both sides' }, + { value: 'list', label: 'What changed' }, + ]} + onChange={(next) => { + if (next === 'swipe' && !bothSides) return + setMode(next) + }} + /> + <span className={styles.spacer} /> + {(branchMarks.length > 0 || mainMarks.length > 0) && mode !== 'list' && ( + <label className={styles.compareToggle}> + <Switch checked={showHighlights} onCheckedChange={setShowHighlights} switchSize="sm" aria-label="Highlight changes" /> + <span>Highlight changes</span> + </label> + )} + </div> + + {mode === 'list' ? ( + lines.length > 0 ? ( + <ul className={styles.changeList}> + {lines.map((line) => ( + <li key={line}>{line}</li> + ))} + </ul> + ) : ( + <p className={styles.changeListEmpty}>Only the tree's order or metadata changed.</p> + ) + ) : mode === 'swipe' && bothSides ? ( + <div className={styles.swipe}> + <div className={styles.swipeStack} style={{ '--split': `${split}%` } as CSSProperties}> + <ScaledFrame branchId={branchId} rowId={rowId} side="branch" title={`${label} on the branch`} marks={branchMarks} showHighlights={showHighlights} /> + <div className={styles.swipeTop}> + <ScaledFrame branchId={branchId} rowId={rowId} side="main" title={`${label} on main`} marks={mainMarks} showHighlights={showHighlights} /> + </div> + <span className={styles.swipeLine} /> + <span className={styles.swipeTagLeft}>{mainLabel}</span> + <span className={styles.swipeTagRight}>Branch</span> + </div> + <input + type="range" + min={0} + max={100} + value={split} + aria-label="Reveal the branch version" + className={styles.swipeRange} + onChange={(event) => setSplit(Number(event.target.value))} + /> + </div> + ) : ( + <div className={styles.compareGrid} data-single={bothSides ? 'false' : 'true'}> + {hasMain && ( + <div className={styles.compareCol}> + <div className={styles.compareLabel}>{mainLabel}</div> + <ScaledFrame branchId={branchId} rowId={rowId} side="main" title={`${label} on main`} marks={mainMarks} showHighlights={showHighlights} /> + </div> + )} + {hasBranch && ( + <div className={styles.compareCol}> + <div className={styles.compareLabel}>{action === 'create' ? 'Branch, new page' : 'Branch'}</div> + <ScaledFrame branchId={branchId} rowId={rowId} side="branch" title={`${label} on the branch`} marks={branchMarks} showHighlights={showHighlights} /> + </div> + )} + </div> + )} + </div> + ) +} diff --git a/src/admin/pages/branches/ReviewChangeCard.tsx b/src/admin/pages/branches/ReviewChangeCard.tsx new file mode 100644 index 000000000..e896b16d1 --- /dev/null +++ b/src/admin/pages/branches/ReviewChangeCard.tsx @@ -0,0 +1,196 @@ +/** + * ReviewChangeCard — the right-hand side of one timeline node: what the + * change looks like. Pages get before/after frames, entries a field table, + * tables their schema, the shell its settings, files a line diff. A change + * with a conflict carries the decision strip on top. + */ +import type { MergeChange, MergeFieldChange, MergeResolution } from '@core/branches' +import { countDiffLines, diffLines } from '@core/utils/lineDiff' +import { SegmentedControl } from '@ui/components/SegmentedControl' +import { WarningDiamondSolidIcon } from 'pixel-art-icons/icons/warning-diamond-solid' +import { PageCompare } from './PageCompare' +import { ACTION_WORD, changeKindLabel, isPageChange } from './reviewFormat' +import styles from './BranchReviewPage.module.css' + +interface ReviewChangeCardProps { + branchId: string + change: MergeChange + resolution: MergeResolution | undefined + canResolve: boolean + onResolve: (resolution: MergeResolution) => void +} + +function describeConflicts(conflicts: readonly string[]): string { + if (conflicts.includes('(deleted)')) return 'Deleted on one side, changed on the other.' + const fields = conflicts.slice(0, 3).map((path) => path.replace(/^cells\./, '')).join(', ') + return conflicts.length > 3 + ? `Both sides changed ${fields} and ${conflicts.length - 3} more.` + : `Both sides changed ${fields}.` +} + +function ConflictStrip({ change, resolution, canResolve, onResolve }: Omit<ReviewChangeCardProps, 'branchId'>) { + if (change.conflicts.length === 0) return null + return ( + <div className={styles.conflictStrip} data-resolved={resolution ? 'true' : 'false'} data-testid={`review-conflict-${change.key}`}> + <WarningDiamondSolidIcon size={14} aria-hidden="true" /> + <span className={styles.conflictText}> + <strong> + {resolution + ? resolution === 'into' ? 'Resolved: keeping main.' : 'Resolved: taking the branch.' + : 'Conflict.'} + </strong>{' '} + {describeConflicts(change.conflicts)} + {!canResolve && ' A branch manager decides which side wins.'} + </span> + {canResolve && ( + <SegmentedControl + value={resolution} + size="xs" + aria-label={`Resolve ${change.label}`} + options={[ + { value: 'into', label: 'Keep main' }, + { value: 'from', label: 'Take branch' }, + ]} + onChange={onResolve} + /> + )} + </div> + ) +} + +function FieldTable({ fields, action }: { fields: MergeFieldChange[]; action: MergeChange['action'] }) { + if (fields.length === 0) return <p className={styles.cardEmpty}>No field-level difference to show.</p> + return ( + <table className={styles.fieldTable}> + <thead> + <tr> + <th>Field</th> + <th>Main</th> + <th>Branch</th> + </tr> + </thead> + <tbody> + {fields.map((field) => ( + <tr key={field.id} className={styles.fieldRow} data-changed="true" data-conflict={field.conflict ? 'true' : 'false'}> + <td className={styles.fieldName}>{field.label}</td> + <td className={styles.cellBefore} data-structured={field.structured ? 'true' : 'false'}> + {field.before ?? <span className={styles.cellEmpty}>{action === 'create' ? 'none' : 'empty'}</span>} + </td> + <td className={styles.cellAfter} data-structured={field.structured ? 'true' : 'false'}> + {field.after ?? <span className={styles.cellEmpty}>{action === 'delete' ? 'removed' : 'empty'}</span>} + </td> + </tr> + ))} + </tbody> + </table> + ) +} + +function fieldLines(fields: MergeFieldChange[]): string[] { + return fields.map((field) => { + if (field.before === null) return `${field.label}: set to “${field.after ?? ''}”` + if (field.after === null) return `${field.label}: cleared` + return `${field.label}: “${field.before}” → “${field.after}”` + }) +} + +export function ReviewChangeCard({ branchId, change, resolution, canResolve, onResolve }: ReviewChangeCardProps) { + const { detail } = change + const header = ( + <div className={styles.cardHead}> + <span className={styles.cardKind}>{changeKindLabel(change)}</span> + <strong className={detail.kind === 'file' ? styles.mono : undefined}>{change.label}</strong> + {detail.kind === 'file' && detail.pathBefore && ( + <span className={styles.cardPath}>was {detail.pathBefore}</span> + )} + <span className={styles.spacer} /> + {detail.kind === 'file' && !detail.binary && <FileCounts before={detail.before ?? ''} after={detail.after ?? ''} />} + <span>{ACTION_WORD[change.action]}</span> + </div> + ) + + let body + if (detail.kind === 'row' && isPageChange(change)) { + body = ( + <PageCompare + branchId={branchId} + rowId={change.logicalId} + label={change.label} + action={change.action} + tree={detail.tree} + fieldLines={fieldLines(detail.fields)} + mainLabel={change.conflicts.length > 0 ? 'Main, as it is now' : 'Main'} + /> + ) + } else if (detail.kind === 'row') { + body = ( + <> + <FieldTable fields={detail.fields} action={change.action} /> + {detail.tree && ( + <ul className={styles.changeList}> + {detail.tree.added.map((id) => <li key={`a-${id}`}>Added {detail.tree!.labels[id] ?? id}</li>)} + {detail.tree.changed.map((id) => <li key={`c-${id}`}>Changed {detail.tree!.labels[id] ?? id}</li>)} + {detail.tree.removed.map((id) => <li key={`r-${id}`}>Removed {detail.tree!.labels[id] ?? id}</li>)} + </ul> + )} + </> + ) + } else if (detail.kind === 'table') { + body = ( + <> + {detail.fields.length > 0 && <FieldTable fields={detail.fields} action={change.action} />} + <ul className={styles.schemaList}> + {detail.schema.map((field) => ( + <li key={field.id} className={styles.schemaRow}> + <span>{field.label}</span> + <span className={styles.schemaType}>{field.type}</span> + <span className={styles.schemaBadge} data-status={field.status}> + {field.status === 'same' ? 'unchanged' : field.status} + </span> + </li> + ))} + </ul> + </> + ) + } else if (detail.kind === 'site') { + body = <FieldTable fields={detail.fields} action={change.action} /> + } else if (detail.binary) { + body = <p className={styles.cardEmpty}>Binary asset ({detail.fileType}); no text to compare.</p> + } else { + body = <LineDiff before={detail.before ?? ''} after={detail.after ?? ''} /> + } + + return ( + <section className={styles.card} data-testid={`review-change-${change.key}`}> + {header} + <ConflictStrip change={change} resolution={resolution} canResolve={canResolve} onResolve={onResolve} /> + {body} + </section> + ) +} + +function FileCounts({ before, after }: { before: string; after: string }) { + const counts = countDiffLines(diffLines(before, after)) + return ( + <span className={styles.fileCounts}> + <span className={styles.add}>+{counts.additions}</span> + {counts.deletions > 0 && <span className={styles.del}>−{counts.deletions}</span>} + </span> + ) +} + +function LineDiff({ before, after }: { before: string; after: string }) { + const rows = diffLines(before, after) + return ( + <div className={styles.diff}> + {rows.map((row, index) => ( + <div key={index} className={styles.diffRow} data-type={row.type}> + <span className={styles.diffNo}>{row.before ?? ''}</span> + <span className={styles.diffNo}>{row.after ?? ''}</span> + <span className={styles.diffSign}>{row.type === 'add' ? '+' : row.type === 'del' ? '−' : ''}</span> + <span className={styles.diffCode}>{row.text || ' '}</span> + </div> + ))} + </div> + ) +} diff --git a/src/admin/pages/branches/ReviewThread.tsx b/src/admin/pages/branches/ReviewThread.tsx new file mode 100644 index 000000000..a561dc7c5 --- /dev/null +++ b/src/admin/pages/branches/ReviewThread.tsx @@ -0,0 +1,127 @@ +/** + * ReviewThread — the comments on one change (or on the request itself): + * a boxed list with an always-present composer. Posting goes through the + * review's `comment` action; the list re-renders from the server's copy. + */ +import { useState, type KeyboardEvent, type ReactNode } from 'react' +import type { BranchReviewComment, ReviewUserLabel } from '@core/branches' +import { getErrorMessage } from '@core/utils/errorMessage' +import { Button } from '@ui/components/Button' +import { Textarea } from '@ui/components/Input' +import { pushToast } from '@ui/components/Toast' +import { UserAvatar } from '@admin/shared/UserAvatar/UserAvatar' +import { relativeIso } from './reviewFormat' +import styles from './BranchReviewPage.module.css' + +interface ReviewThreadProps { + title: ReactNode + comments: BranchReviewComment[] + me: ReviewUserLabel + placeholder: string + onPost: (body: string) => Promise<unknown> + testId: string +} + +function submitOnCmdEnter(event: KeyboardEvent<HTMLTextAreaElement>, submit: () => void): void { + if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { + event.preventDefault() + submit() + } +} + +export function ReviewThread({ title, comments, me, placeholder, onPost, testId }: ReviewThreadProps) { + const [text, setText] = useState('') + const [focused, setFocused] = useState(false) + const [posting, setPosting] = useState(false) + const expanded = focused || text.trim().length > 0 + + async function submit(): Promise<void> { + const body = text.trim() + if (!body || posting) return + setPosting(true) + try { + await onPost(body) + setText('') + setFocused(false) + } catch (err) { + console.error('[branch-review] comment failed:', err) + pushToast({ kind: 'error', title: 'Could not post the comment', body: getErrorMessage(err, 'Unknown review error') }) + } finally { + setPosting(false) + } + } + + const count = comments.length + return ( + <div className={styles.thread} data-testid={testId}> + <div className={styles.threadHead}> + <span className={styles.threadTitle}>{title}</span> + <span className={styles.spacer} /> + <span className={styles.threadCount}>{count === 0 ? 'No comments' : `${count} ${count === 1 ? 'comment' : 'comments'}`}</span> + </div> + {comments.map((comment) => ( + <div key={comment.id} className={styles.threadItem} data-testid={`${testId}-comment`}> + <span className={styles.threadAvatar}> + {comment.author && <UserAvatar user={comment.author} size={20} />} + </span> + <div> + <div className={styles.threadItemHead}> + <strong>{comment.author?.displayName ?? 'Removed user'}</strong> + <span>{relativeIso(comment.createdAt)}</span> + </div> + <p className={styles.threadItemText}>{comment.body}</p> + </div> + </div> + ))} + <div className={styles.threadComposer}> + <div className={styles.threadComposerRow}> + <span className={styles.threadAvatar}> + <UserAvatar user={me} size={20} /> + </span> + <Textarea + fieldSize="sm" + rows={expanded ? 3 : 1} + placeholder={placeholder} + value={text} + disabled={posting} + onChange={(event) => setText(event.target.value)} + onFocus={() => setFocused(true)} + onBlur={() => setFocused(false)} + onKeyDown={(event) => submitOnCmdEnter(event, () => { void submit() })} + data-testid={`${testId}-input`} + /> + </div> + {expanded && ( + <div className={styles.threadComposerActions}> + <span className={styles.threadComposerHint}>Enter with Cmd or Ctrl posts</span> + <span className={styles.spacer} /> + <Button + variant="ghost" + size="xs" + type="button" + onMouseDown={(event) => event.preventDefault()} + onClick={() => { + setText('') + setFocused(false) + }} + > + Cancel + </Button> + <Button + variant="secondary" + size="xs" + type="button" + busy={posting} + disabled={!text.trim()} + onMouseDown={(event) => event.preventDefault()} + onClick={() => { void submit() }} + data-testid={`${testId}-submit`} + > + Comment + </Button> + </div> + )} + </div> + </div> + ) +} diff --git a/src/admin/pages/branches/reviewFormat.ts b/src/admin/pages/branches/reviewFormat.ts new file mode 100644 index 000000000..bfa88bae8 --- /dev/null +++ b/src/admin/pages/branches/reviewFormat.ts @@ -0,0 +1,87 @@ +/** + * Labels and grouping the merge review uses: how a change's kind and action + * read, which filter a change belongs to, and short relative times. + */ +import type { MergeChange, MergeRequestStatus } from '@core/branches' +import { formatRelativeTime } from '@core/utils/relativeTime' + +export const REVIEW_FILTERS = ['all', 'pages', 'content', 'files', 'conflicts', 'comments'] as const +export type ReviewFilter = (typeof REVIEW_FILTERS)[number] + +export const FILTER_LABELS: Record<ReviewFilter, string> = { + all: 'All', + pages: 'Pages', + content: 'Content', + files: 'Files', + conflicts: 'Conflicts', + comments: 'With comments', +} + +/** Rows of these tables render as pages (before/after frames). */ +export function isPageChange(change: MergeChange): boolean { + return change.kind === 'row' && change.tableId === 'pages' +} + +export function changeKindLabel(change: MergeChange): string { + if (change.kind === 'site') return 'Site settings' + if (change.kind === 'file') return 'File' + if (change.kind === 'table') return 'Table' + if (isPageChange(change)) return 'Page' + return change.tableName ? `Entry · ${change.tableName}` : 'Entry' +} + +export const ACTION_LETTER: Record<MergeChange['action'], string> = { create: 'A', update: 'M', delete: 'D' } +export const ACTION_WORD: Record<MergeChange['action'], string> = { create: 'new', update: 'changed', delete: 'removed' } + +export function matchesFilter(change: MergeChange, filter: ReviewFilter, commentCount: number): boolean { + switch (filter) { + case 'all': + return true + case 'pages': + return isPageChange(change) + case 'content': + return (change.kind === 'row' && !isPageChange(change)) || change.kind === 'table' + case 'files': + return change.kind === 'file' || change.kind === 'site' + case 'conflicts': + return change.conflicts.length > 0 + case 'comments': + return commentCount > 0 + } +} + +export function requestStatusLabel(status: MergeRequestStatus): string { + switch (status) { + case 'open': + return 'Awaiting review' + case 'declined': + return 'Changes requested' + case 'merged': + return 'Merged' + case 'withdrawn': + return 'Withdrawn' + } +} + +export function requestStatusTone(status: MergeRequestStatus): 'warning' | 'danger' | 'success' | 'neutral' { + switch (status) { + case 'open': + return 'warning' + case 'declined': + return 'danger' + case 'merged': + return 'success' + case 'withdrawn': + return 'neutral' + } +} + +/** "3m" / "2h" / "4d" from an ISO timestamp; empty when unparsable. */ +export function relativeIso(iso: string): string { + const ms = Date.parse(iso) + if (Number.isNaN(ms)) return '' + return formatRelativeTime(ms) +} + +/** Every comment on the request itself uses the empty key. */ +export const REQUEST_ENTITY_KEY = '' diff --git a/src/admin/pages/branches/useBranchReview.ts b/src/admin/pages/branches/useBranchReview.ts new file mode 100644 index 000000000..ba75be64c --- /dev/null +++ b/src/admin/pages/branches/useBranchReview.ts @@ -0,0 +1,107 @@ +/** + * The merge review's data: the merge plan (every change, with detail) and + * the review state (request, comments, current content hash), plus the + * actions that move them. Loaded together; each action updates the local + * copy from the server's response so the page never guesses. + */ +import { useEffect, useState } from 'react' +import type { BranchMergeRequest, BranchReviewComment, BranchReviewState, MergePlan } from '@core/branches' +import { isAbortError } from '@core/http' +import { + addCmsBranchReviewComment, + declineCmsBranchMergeRequest, + getCmsBranchMergePlan, + getCmsBranchReview, + requestCmsBranchMerge, + withdrawCmsBranchMergeRequest, +} from '@core/persistence' +import { getErrorMessage } from '@core/utils/errorMessage' + +export interface BranchReviewData { + plan: MergePlan | null + review: BranchReviewState | null + loadError: string | null + /** Re-fetch both the plan and the review state. */ + reload: () => Promise<void> + request: (note: string) => Promise<BranchMergeRequest> + withdraw: () => Promise<BranchMergeRequest> + decline: (note: string) => Promise<BranchMergeRequest> + comment: (entityKey: string, body: string) => Promise<BranchReviewComment> +} + +async function fetchReviewData( + branchId: string, + signal?: AbortSignal, +): Promise<{ plan: MergePlan; review: BranchReviewState }> { + const [plan, review] = await Promise.all([ + getCmsBranchMergePlan(branchId, 'merge'), + getCmsBranchReview(branchId, signal), + ]) + return { plan, review } +} + +export function useBranchReview(branchId: string): BranchReviewData { + const [plan, setPlan] = useState<MergePlan | null>(null) + const [review, setReview] = useState<BranchReviewState | null>(null) + const [loadError, setLoadError] = useState<string | null>(null) + + useEffect(() => { + const controller = new AbortController() + fetchReviewData(branchId, controller.signal) + .then((next) => { + if (controller.signal.aborted) return + setPlan(next.plan) + setReview(next.review) + setLoadError(null) + }) + .catch((err: unknown) => { + if (isAbortError(err) || controller.signal.aborted) return + console.error('[branch-review] load failed:', err) + setLoadError(getErrorMessage(err, 'Could not load the review')) + }) + return () => controller.abort() + }, [branchId]) + + async function reload(): Promise<void> { + try { + const next = await fetchReviewData(branchId) + setPlan(next.plan) + setReview(next.review) + setLoadError(null) + } catch (err) { + console.error('[branch-review] reload failed:', err) + setLoadError(getErrorMessage(err, 'Could not load the review')) + } + } + + function replaceRequest(request: BranchMergeRequest): void { + setReview((current) => (current ? { ...current, request } : current)) + } + + return { + plan, + review, + loadError, + reload, + request: async (note) => { + const request = await requestCmsBranchMerge(branchId, note) + replaceRequest(request) + return request + }, + withdraw: async () => { + const request = await withdrawCmsBranchMergeRequest(branchId) + replaceRequest(request) + return request + }, + decline: async (note) => { + const request = await declineCmsBranchMergeRequest(branchId, note) + replaceRequest(request) + return request + }, + comment: async (entityKey, body) => { + const comment = await addCmsBranchReviewComment(branchId, { entityKey, body }) + setReview((current) => (current ? { ...current, comments: [...current.comments, comment] } : current)) + return comment + }, + } +} diff --git a/src/admin/router.tsx b/src/admin/router.tsx index b187bcbc4..dd0a40ec0 100644 --- a/src/admin/router.tsx +++ b/src/admin/router.tsx @@ -59,6 +59,7 @@ export function AdminRoutes() { <Route path="/admin/data" element={withRouteBoundary(<AdminEntry section="data" />)} /> <Route path="/admin/media" element={withRouteBoundary(<AdminEntry section="media" />)} /> <Route path="/admin/plugins" element={withRouteBoundary(<AdminEntry section="plugins" />)} /> + <Route path="/admin/branches/:branchId/review" element={withRouteBoundary(<AdminEntry section="branchReview" />)} /> <Route path="/admin/users" element={withRouteBoundary(<AdminEntry section="users" />)} /> <Route path="/admin/ai" element={withRouteBoundary(<AdminEntry section="ai" />)} /> <Route path="/admin/ai/oauth/authorize" element={withRouteBoundary(<AdminEntry section="ai" />)} /> diff --git a/src/admin/shared/AdminSectionNavigation/AdminSectionNavigation.tsx b/src/admin/shared/AdminSectionNavigation/AdminSectionNavigation.tsx index ba25f67bd..d708806ed 100644 --- a/src/admin/shared/AdminSectionNavigation/AdminSectionNavigation.tsx +++ b/src/admin/shared/AdminSectionNavigation/AdminSectionNavigation.tsx @@ -140,7 +140,7 @@ export function AdminSectionNavigation({ to="/admin/site" icon={<LayoutSolidIcon size={NAV_ICON_SIZE} aria-hidden="true" />} label="Site" - active={section === 'site'} + active={section === 'site' || section === 'branchReview'} onNavigateStart={onWorkspaceNavigateStart} /> )} diff --git a/src/admin/shared/BranchSwitcher/BranchContextStrip.tsx b/src/admin/shared/BranchSwitcher/BranchContextStrip.tsx index e638aecbd..5a2d41bdb 100644 --- a/src/admin/shared/BranchSwitcher/BranchContextStrip.tsx +++ b/src/admin/shared/BranchSwitcher/BranchContextStrip.tsx @@ -8,6 +8,7 @@ */ import { Suspense, lazy, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' +import { useNavigate } from '@admin/lib/routing' import { ArrowDownIcon } from 'pixel-art-icons/icons/arrow-down' import { CircleDotSolidIcon } from 'pixel-art-icons/icons/circle-dot-solid' import { EditSolidIcon } from 'pixel-art-icons/icons/edit-solid' @@ -61,6 +62,7 @@ export function BranchContextStrip() { function BranchStripBody({ branch: current }: { branch: SiteBranch }) { const user = useCurrentAdminUser() const canManage = hasCapability(user, 'site.branches.manage') + const navigate = useNavigate() const openManage = useBranchStore((state) => state.openManage) const [moreOpen, setMoreOpen] = useState(false) const [deleting, setDeleting] = useState(false) @@ -153,18 +155,18 @@ function BranchStripBody({ branch: current }: { branch: SiteBranch }) { </Button> )} - {canManage && ( - <Button - variant="primary" - size="xs" - type="button" - data-testid="branch-strip-merge" - onClick={() => setMerge('merge')} - > - <GitMergeSolidIcon size={12} aria-hidden="true" /> - <span>Merge into main…</span> - </Button> - )} + <Button + variant="primary" + size="xs" + type="button" + data-testid="branch-strip-merge" + tooltip={canManage ? 'Review every change, then merge' : 'Review the changes and request a merge'} + tooltipSide="bottom" + onClick={() => navigate(`/admin/branches/${encodeURIComponent(current.id)}/review`)} + > + <GitMergeSolidIcon size={12} aria-hidden="true" /> + <span>{canManage ? 'Merge into main…' : 'Request merge…'}</span> + </Button> <Button ref={moreRef} diff --git a/src/admin/workspace.ts b/src/admin/workspace.ts index c7eac3a03..5c6aaf5c9 100644 --- a/src/admin/workspace.ts +++ b/src/admin/workspace.ts @@ -28,4 +28,6 @@ export type AdminWorkspace = | 'users' | 'ai' | 'pluginPage' + /** The merge review of one branch (`/admin/branches/:branchId/review`). */ + | 'branchReview' | 'account' diff --git a/src/core/branches/index.ts b/src/core/branches/index.ts index fc2b646ef..0ac7f6ac3 100644 --- a/src/core/branches/index.ts +++ b/src/core/branches/index.ts @@ -27,6 +27,24 @@ export { ApplyMergeBodySchema, ApplyMergeEnvelopeSchema, MergeChangeSchema, + MergeChangeDetailSchema, + MergeEntityKindSchema, + MergeFieldChangeSchema, + MergeTreeDiffSchema, + MergeSchemaFieldSchema, + BranchMergeRequestSchema, + BranchReviewCommentSchema, + BranchReviewStateSchema, + CreateMergeRequestBodySchema, + CreateReviewCommentBodySchema, + DeclineMergeRequestBodySchema, + MergeRequestEnvelopeSchema, + MergeRequestStatusSchema, + ReviewCommentEnvelopeSchema, + ReviewRenderSideSchema, + ReviewUserLabelSchema, + REVIEW_COMMENT_MAX_LENGTH, + REVIEW_NOTE_MAX_LENGTH, MergeDirectionSchema, MergePlanEnvelopeSchema, MergePlanSchema, @@ -36,6 +54,20 @@ export { type BranchPreview, type CreateBranchBody, type MergeChange, + type MergeChangeDetail, + type MergeEntityKind, + type MergeFieldChange, + type MergeTreeDiff, + type MergeSchemaField, + type BranchMergeRequest, + type BranchReviewComment, + type BranchReviewState, + type CreateMergeRequestBody, + type CreateReviewCommentBody, + type DeclineMergeRequestBody, + type MergeRequestStatus, + type ReviewRenderSide, + type ReviewUserLabel, type MergeDirection, type MergePlan, type MergeResolution, diff --git a/src/core/branches/schemas.ts b/src/core/branches/schemas.ts index 11577c5df..20ff1b9b0 100644 --- a/src/core/branches/schemas.ts +++ b/src/core/branches/schemas.ts @@ -69,15 +69,97 @@ export type MergeDirection = Static<typeof MergeDirectionSchema> export const MergeResolutionSchema = Type.Union([Type.Literal('into'), Type.Literal('from')]) export type MergeResolution = Static<typeof MergeResolutionSchema> +export const MergeEntityKindSchema = Type.Union([ + Type.Literal('row'), + Type.Literal('table'), + Type.Literal('site'), + Type.Literal('file'), +]) +export type MergeEntityKind = Static<typeof MergeEntityKindSchema> + +/** One field that differs between the two sides, as display text. */ +export const MergeFieldChangeSchema = Type.Object({ + id: Type.String(), + label: Type.String(), + /** Value on the receiving side (`into`), null when absent there. */ + before: Type.Union([Type.String(), Type.Null()]), + /** Value on the contributing side (`from`), null when absent there. */ + after: Type.Union([Type.String(), Type.Null()]), + /** True when either value is an object or array shown as a JSON preview. */ + structured: Type.Boolean(), + /** True when this field is one both sides changed differently. */ + conflict: Type.Boolean(), +}) +export type MergeFieldChange = Static<typeof MergeFieldChangeSchema> + +/** Node-level diff of a page tree, keyed by node id (what the review highlights). */ +export const MergeTreeDiffSchema = Type.Object({ + added: Type.Array(Type.String()), + changed: Type.Array(Type.String()), + removed: Type.Array(Type.String()), + /** Human label per node id that appears above. */ + labels: Type.Record(Type.String(), Type.String()), +}) +export type MergeTreeDiff = Static<typeof MergeTreeDiffSchema> + +export const MergeSchemaFieldSchema = Type.Object({ + id: Type.String(), + label: Type.String(), + type: Type.String(), + status: Type.Union([ + Type.Literal('new'), + Type.Literal('changed'), + Type.Literal('removed'), + Type.Literal('same'), + ]), +}) +export type MergeSchemaField = Static<typeof MergeSchemaFieldSchema> + +/** + * What a change looks like, per entity kind — enough for the review to draw + * a field table, a schema list, a file diff, or page highlights without a + * second request. + */ +export const MergeChangeDetailSchema = Type.Union([ + Type.Object({ + kind: Type.Literal('row'), + fields: Type.Array(MergeFieldChangeSchema), + /** Present for rows whose `body` cell is a node tree (pages, components, layouts). */ + tree: Type.Union([MergeTreeDiffSchema, Type.Null()]), + }), + Type.Object({ + kind: Type.Literal('table'), + fields: Type.Array(MergeFieldChangeSchema), + schema: Type.Array(MergeSchemaFieldSchema), + }), + Type.Object({ + kind: Type.Literal('site'), + fields: Type.Array(MergeFieldChangeSchema), + }), + Type.Object({ + kind: Type.Literal('file'), + path: Type.String(), + /** The path on the receiving side when the file was renamed. */ + pathBefore: Type.Union([Type.String(), Type.Null()]), + fileType: Type.String(), + before: Type.Union([Type.String(), Type.Null()]), + after: Type.Union([Type.String(), Type.Null()]), + /** Asset files carry no text; the review shows metadata only. */ + binary: Type.Boolean(), + }), +]) +export type MergeChangeDetail = Static<typeof MergeChangeDetailSchema> + export const MergeChangeSchema = Type.Object({ key: Type.String(), - kind: Type.Union([Type.Literal('row'), Type.Literal('table'), Type.Literal('site')]), + kind: MergeEntityKindSchema, logicalId: Type.String(), label: Type.String(), tableId: Type.Union([Type.String(), Type.Null()]), tableName: Type.Union([Type.String(), Type.Null()]), action: Type.Union([Type.Literal('create'), Type.Literal('update'), Type.Literal('delete')]), conflicts: Type.Array(Type.String()), + detail: MergeChangeDetailSchema, }) export type MergeChange = Static<typeof MergeChangeSchema> @@ -104,3 +186,86 @@ export const ApplyMergeEnvelopeSchema = Type.Object({ plan: MergePlanSchema, branchDeleted: Type.Boolean(), }) + +// --------------------------------------------------------------------------- +// Merge review — requests and comments on a branch +// --------------------------------------------------------------------------- + +export const ReviewUserLabelSchema = Type.Object({ + id: Type.String(), + displayName: Type.String(), + email: Type.String(), + avatarUrl: Type.Union([Type.String(), Type.Null()]), + gravatarHash: Type.String(), +}) +export type ReviewUserLabel = Static<typeof ReviewUserLabelSchema> + +export const MergeRequestStatusSchema = Type.Union([ + Type.Literal('open'), + Type.Literal('declined'), + Type.Literal('merged'), + Type.Literal('withdrawn'), +]) +export type MergeRequestStatus = Static<typeof MergeRequestStatusSchema> + +export const BranchMergeRequestSchema = Type.Object({ + id: Type.String(), + branchId: Type.String(), + requestedBy: Type.Union([ReviewUserLabelSchema, Type.Null()]), + note: Type.String(), + /** Hash of the branch's content when the request was made — stale detection. */ + contentHash: Type.String(), + status: MergeRequestStatusSchema, + resolvedBy: Type.Union([ReviewUserLabelSchema, Type.Null()]), + resolvedAt: Type.Union([Type.String(), Type.Null()]), + resolutionNote: Type.String(), + createdAt: Type.String(), + updatedAt: Type.String(), +}) +export type BranchMergeRequest = Static<typeof BranchMergeRequestSchema> + +/** Comments attach to the request itself (`entityKey: ''`) or to one change key. */ +export const BranchReviewCommentSchema = Type.Object({ + id: Type.String(), + branchId: Type.String(), + requestId: Type.Union([Type.String(), Type.Null()]), + entityKey: Type.String(), + author: Type.Union([ReviewUserLabelSchema, Type.Null()]), + body: Type.String(), + createdAt: Type.String(), +}) +export type BranchReviewComment = Static<typeof BranchReviewCommentSchema> + +export const BranchReviewStateSchema = Type.Object({ + branch: SiteBranchSchema, + request: Type.Union([BranchMergeRequestSchema, Type.Null()]), + comments: Type.Array(BranchReviewCommentSchema), + /** Hash of the branch's content right now (compare with `request.contentHash`). */ + contentHash: Type.String(), +}) +export type BranchReviewState = Static<typeof BranchReviewStateSchema> + +export const REVIEW_NOTE_MAX_LENGTH = 2000 +export const REVIEW_COMMENT_MAX_LENGTH = 4000 + +export const CreateMergeRequestBodySchema = Type.Object({ + note: Type.String({ maxLength: REVIEW_NOTE_MAX_LENGTH }), +}, { additionalProperties: false }) +export type CreateMergeRequestBody = Static<typeof CreateMergeRequestBodySchema> + +export const DeclineMergeRequestBodySchema = Type.Object({ + note: Type.String({ minLength: 1, maxLength: REVIEW_NOTE_MAX_LENGTH }), +}, { additionalProperties: false }) +export type DeclineMergeRequestBody = Static<typeof DeclineMergeRequestBodySchema> + +export const CreateReviewCommentBodySchema = Type.Object({ + entityKey: Type.String({ maxLength: 400 }), + body: Type.String({ minLength: 1, maxLength: REVIEW_COMMENT_MAX_LENGTH }), +}, { additionalProperties: false }) +export type CreateReviewCommentBody = Static<typeof CreateReviewCommentBodySchema> + +export const ReviewCommentEnvelopeSchema = Type.Object({ comment: BranchReviewCommentSchema }) +export const MergeRequestEnvelopeSchema = Type.Object({ request: BranchMergeRequestSchema }) + +export const ReviewRenderSideSchema = Type.Union([Type.Literal('main'), Type.Literal('branch')]) +export type ReviewRenderSide = Static<typeof ReviewRenderSideSchema> diff --git a/src/core/http/apiClient.ts b/src/core/http/apiClient.ts index 913b97e60..e736eda6d 100644 --- a/src/core/http/apiClient.ts +++ b/src/core/http/apiClient.ts @@ -231,6 +231,19 @@ export async function apiBlobRequest( return res.blob() } +/** + * Fetch a text response (server-rendered HTML for a sandboxed frame, plain + * text exports) through the same transport as {@link apiRequest}. Text has + * no TypeBox shape; the caller decides what the string is. + */ +export async function apiTextRequest( + path: string, + options: Omit<ApiRequestOptions, 'schema'> = {}, +): Promise<string> { + const res = await requestResponse(path, options) + return res.text() +} + async function requestResponse( path: string, options: Omit<ApiRequestOptions, 'schema'> | ApiRequestOptions, diff --git a/src/core/http/index.ts b/src/core/http/index.ts index 060d1aa6b..9ac602125 100644 --- a/src/core/http/index.ts +++ b/src/core/http/index.ts @@ -6,6 +6,7 @@ export { apiRequest, apiBlobRequest, + apiTextRequest, readEnvelope, assertOk, responseErrorMessage, diff --git a/src/core/persistence/cmsBranches.ts b/src/core/persistence/cmsBranches.ts index 203867b2c..c197054af 100644 --- a/src/core/persistence/cmsBranches.ts +++ b/src/core/persistence/cmsBranches.ts @@ -15,13 +15,21 @@ import { BranchListEnvelopeSchema, BranchPreviewLinkEnvelopeSchema, BranchPreviewStateEnvelopeSchema, + BranchReviewStateSchema, MergePlanEnvelopeSchema, + MergeRequestEnvelopeSchema, + ReviewCommentEnvelopeSchema, type ApplyMergeBody, + type BranchMergeRequest, type BranchPreview, + type BranchReviewComment, + type BranchReviewState, type CreateBranchBody, + type CreateReviewCommentBody, type MergeDirection, type MergePlan, type RenameBranchBody, + type ReviewRenderSide, type SiteBranch, } from '@core/branches' @@ -107,3 +115,63 @@ export async function applyCmsBranchMerge( fallbackMessage: direction === 'merge' ? 'Failed to merge the branch' : 'Failed to update the branch', }) } + +// --------------------------------------------------------------------------- +// Merge review +// --------------------------------------------------------------------------- + +function reviewPath(id: string): string { + return `${BRANCHES_PATH}/${encodeURIComponent(id)}/review` +} + +export async function getCmsBranchReview(id: string, signal?: AbortSignal): Promise<BranchReviewState> { + return apiRequest(reviewPath(id), { + schema: BranchReviewStateSchema, + signal, + fallbackMessage: 'Failed to load the review', + }) +} + +export async function requestCmsBranchMerge(id: string, note: string): Promise<BranchMergeRequest> { + const payload = await apiRequest(`${reviewPath(id)}/request`, { + method: 'POST', + body: { note }, + schema: MergeRequestEnvelopeSchema, + fallbackMessage: 'Failed to request the merge', + }) + return payload.request +} + +export async function withdrawCmsBranchMergeRequest(id: string): Promise<BranchMergeRequest> { + const payload = await apiRequest(`${reviewPath(id)}/withdraw`, { + method: 'POST', + schema: MergeRequestEnvelopeSchema, + fallbackMessage: 'Failed to withdraw the request', + }) + return payload.request +} + +export async function declineCmsBranchMergeRequest(id: string, note: string): Promise<BranchMergeRequest> { + const payload = await apiRequest(`${reviewPath(id)}/decline`, { + method: 'POST', + body: { note }, + schema: MergeRequestEnvelopeSchema, + fallbackMessage: 'Failed to decline the request', + }) + return payload.request +} + +export async function addCmsBranchReviewComment(id: string, body: CreateReviewCommentBody): Promise<BranchReviewComment> { + const payload = await apiRequest(`${reviewPath(id)}/comments`, { + method: 'POST', + body, + schema: ReviewCommentEnvelopeSchema, + fallbackMessage: 'Failed to post the comment', + }) + return payload.comment +} + +/** URL of one page's HTML as `side` renders it — for a sandboxed iframe, not for fetch. */ +export function cmsBranchReviewRenderUrl(id: string, rowId: string, side: ReviewRenderSide): string { + return `${reviewPath(id)}/render?row=${encodeURIComponent(rowId)}&side=${side}` +} diff --git a/src/core/persistence/index.ts b/src/core/persistence/index.ts index 791cb4e98..7e3756b0c 100644 --- a/src/core/persistence/index.ts +++ b/src/core/persistence/index.ts @@ -107,4 +107,10 @@ export { revokeCmsBranchPreview, getCmsBranchMergePlan, applyCmsBranchMerge, + getCmsBranchReview, + requestCmsBranchMerge, + withdrawCmsBranchMergeRequest, + declineCmsBranchMergeRequest, + addCmsBranchReviewComment, + cmsBranchReviewRenderUrl, } from './cmsBranches' diff --git a/src/core/utils/lineDiff.ts b/src/core/utils/lineDiff.ts new file mode 100644 index 000000000..28f41a354 --- /dev/null +++ b/src/core/utils/lineDiff.ts @@ -0,0 +1,60 @@ +/** + * Line diff of two texts (longest common subsequence). Small inputs only — + * site files and plugin sources — so the O(n·m) table is fine and the + * result stays exact. Rows come out in reading order with both line numbers. + */ +export interface DiffLine { + type: 'same' | 'add' | 'del' + /** Line number on the before side, null for an added line. */ + before: number | null + /** Line number on the after side, null for a removed line. */ + after: number | null + text: string +} + +function splitLines(text: string): string[] { + if (text === '') return [] + return text.replace(/\n$/, '').split('\n') +} + +export function diffLines(before: string, after: string): DiffLine[] { + const a = splitLines(before) + const b = splitLines(after) + const n = a.length + const m = b.length + const table: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0)) + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + table[i]![j] = a[i] === b[j] ? table[i + 1]![j + 1]! + 1 : Math.max(table[i + 1]![j]!, table[i]![j + 1]!) + } + } + const rows: DiffLine[] = [] + let i = 0 + let j = 0 + while (i < n && j < m) { + if (a[i] === b[j]) { + rows.push({ type: 'same', before: i + 1, after: j + 1, text: a[i]! }) + i++ + j++ + } else if (table[i + 1]![j]! >= table[i]![j + 1]!) { + rows.push({ type: 'del', before: i + 1, after: null, text: a[i]! }) + i++ + } else { + rows.push({ type: 'add', before: null, after: j + 1, text: b[j]! }) + j++ + } + } + while (i < n) rows.push({ type: 'del', before: i + 1, after: null, text: a[i++]! }) + while (j < m) rows.push({ type: 'add', before: null, after: j + 1, text: b[j++]! }) + return rows +} + +export function countDiffLines(rows: readonly DiffLine[]): { additions: number; deletions: number } { + let additions = 0 + let deletions = 0 + for (const row of rows) { + if (row.type === 'add') additions++ + else if (row.type === 'del') deletions++ + } + return { additions, deletions } +} diff --git a/tests/e2e/branch-review.e2e.ts b/tests/e2e/branch-review.e2e.ts new file mode 100644 index 000000000..a56866428 --- /dev/null +++ b/tests/e2e/branch-review.e2e.ts @@ -0,0 +1,262 @@ +import { mkdir } from 'node:fs/promises' +import { expect, test, type Browser, type Page } from '@playwright/test' +import { ANONYMOUS_STATE, OWNER, OWNER_STATE_FILE, completeStepUp, loginAs } from './helpers' + +/** + * Merge review across two accounts (REVIEW-001). + * + * 1. The owner creates a branch and a site editor who cannot merge. + * 2. The editor edits the home page on the branch, reads the review with + * before/after renders and highlights, comments, and requests a merge. + * 3. The owner edits the same page on main (a conflict), reviews, comments, + * declines with a note. + * 4. The editor sees the decline and requests again. + * 5. The owner resolves the conflict, merges with a password step-up, and + * main's draft carries the branch's edit. + * + * Evidence lands under `.tmp/evidence/branch-review-*.png`. + */ + +const EVIDENCE_DIR = '.tmp/evidence' +const VIEWPORT = { width: 1440, height: 900 } +const BRANCH_ID = 'launch-review' +const EDITOR = { email: 'review-editor.e2e@example.com', password: 'review-editor-pass-12345', name: 'Eli Editor' } +const BRANCH_TITLE = 'Home, launch edition' +const MAIN_TITLE = 'Home, main edition' + +test.use({ viewport: VIEWPORT }) +test.describe.configure({ mode: 'serial' }) + +test.beforeAll(async () => { + await mkdir(EVIDENCE_DIR, { recursive: true }) +}) + +async function shot(page: Page, name: string): Promise<void> { + await page.waitForTimeout(500) + await page.screenshot({ path: `${EVIDENCE_DIR}/branch-review-${name}.png` }) +} + +/** Same-origin fetch from the page's session; branch-scoped when `branch` is set. */ +async function api<T>(page: Page, path: string, init: { method?: string; body?: unknown; branch?: string } = {}): Promise<{ status: number; body: T }> { + return page.evaluate(async ({ path, init }) => { + const headers: Record<string, string> = { 'content-type': 'application/json' } + if (init.branch) headers['x-instatic-branch'] = init.branch + const res = await fetch(path, { + method: init.method ?? 'GET', + credentials: 'include', + headers, + body: init.body === undefined ? undefined : JSON.stringify(init.body), + }) + const text = await res.text() + let body: unknown = null + try { body = JSON.parse(text) } catch { body = text } + return { status: res.status, body: body as never } + }, { path, init }) +} + +interface HomeRow { + id: string + slug: string + cells: { title: string; body: { nodes: Record<string, { moduleId: string; props?: Record<string, unknown>; children?: string[] }>; rootNodeId: string } } +} + +async function homeRow(page: Page, branch?: string): Promise<HomeRow> { + const { status, body } = await api<{ rows: HomeRow[] }>(page, '/admin/api/cms/pages', { branch }) + expect(status).toBe(200) + const home = body.rows.find((row) => row.slug === '' || row.slug === 'home') ?? body.rows[0] + expect(home).toBeDefined() + return home! +} + +async function saveHome(page: Page, home: HomeRow, cells: HomeRow['cells'], branch?: string): Promise<void> { + const { status } = await api(page, `/admin/api/cms/data/rows/${encodeURIComponent(home.id)}`, { + method: 'PATCH', + body: { cells: { ...home.cells, ...cells } }, + branch, + }) + expect(status).toBe(200) +} + +async function editorPage(browser: Browser): Promise<Page> { + const context = await browser.newContext({ storageState: ANONYMOUS_STATE, viewport: VIEWPORT }) + const page = await context.newPage() + await loginAs(page, EDITOR.email, EDITOR.password) + return page +} + +test('owner prepares a branch and an editor without merge rights', async ({ page }) => { + await page.goto('/admin/dashboard') + await expect(page.getByRole('heading', { level: 1 })).toBeVisible() + // Creating roles and users is step-up gated; re-verify the owner first. + const stepUp = await api(page, '/admin/api/cms/auth/step-up', { method: 'POST', body: { password: OWNER.password } }) + expect(stepUp.status).toBe(200) + // Step-up rotates the session token; later owner contexts must reuse it. + await page.context().storageState({ path: OWNER_STATE_FILE }) + + // The editor role edits pages (through the row API this spec uses) but + // cannot manage branches, so it cannot merge. Reused across runs. + const capabilities = [ + 'dashboard.read', 'site.read', 'site.content.edit', 'site.structure.edit', 'site.style.edit', 'pages.edit', 'media.read', + 'content.create', 'content.edit.any', 'data.system.tables.read', 'data.system.tables.manage', + ] + const roleCreate = await api<{ role: { id: string } }>(page, '/admin/api/cms/roles', { + method: 'POST', + body: { name: 'Site editor (review)', slug: 'site-editor-review', description: 'Edits pages; asks for merges.', capabilities }, + }) + let roleId: string + if (roleCreate.status === 201) { + roleId = roleCreate.body.role.id + } else { + const roles = await api<{ roles: Array<{ id: string; slug: string }> }>(page, '/admin/api/cms/roles') + const existing = roles.body.roles.find((entry) => entry.slug === 'site-editor-review') + expect(existing, `role create answered ${roleCreate.status}: ${JSON.stringify(roleCreate.body)}`).toBeDefined() + roleId = existing!.id + const patched = await api(page, `/admin/api/cms/roles/${roleId}`, { method: 'PATCH', body: { capabilities } }) + expect(patched.status, `role patch answered ${patched.status}: ${JSON.stringify(patched.body)}`).toBe(200) + } + const user = await api(page, '/admin/api/cms/users', { + method: 'POST', + body: { email: EDITOR.email, displayName: EDITOR.name, password: EDITOR.password, roleId }, + }) + if (user.status !== 201) { + const users = await api<{ users: Array<{ id: string; email: string }> }>(page, '/admin/api/cms/users') + const existing = users.body.users.find((entry) => entry.email === EDITOR.email) + expect(existing, `user create answered ${user.status}: ${JSON.stringify(user.body)}`).toBeDefined() + const patched = await api(page, `/admin/api/cms/users/${existing!.id}`, { method: 'PATCH', body: { roleId } }) + expect(patched.status, `user patch answered ${patched.status}: ${JSON.stringify(patched.body)}`).toBe(200) + } + + // A real home page, so the review has something to render: a hero with a + // heading, a paragraph and a button. + const home = await homeRow(page) + const root = home.cells.body.rootNodeId + const nodes = { + ...home.cells.body.nodes, + [root]: { ...home.cells.body.nodes[root]!, children: ['review-hero'] }, + 'review-hero': { id: 'review-hero', moduleId: 'base.container', props: {}, children: ['review-heading', 'review-copy', 'review-cta'] }, + 'review-heading': { id: 'review-heading', moduleId: 'base.text', props: { text: 'Ship your site faster', tag: 'h1' }, children: [] }, + 'review-copy': { id: 'review-copy', moduleId: 'base.text', props: { text: 'A self-hosted CMS with a visual editor and a plugin system that runs in a sandbox.', tag: 'p' }, children: [] }, + 'review-cta': { id: 'review-cta', moduleId: 'base.button', props: { label: 'Get started', href: '/pricing' }, children: [] }, + } + await saveHome(page, home, { ...home.cells, title: 'Home', body: { ...home.cells.body, nodes } }) + + // A clean branch for this run. + await api(page, `/admin/api/cms/branches/${BRANCH_ID}`, { method: 'DELETE' }) + const created = await api(page, '/admin/api/cms/branches', { method: 'POST', body: { name: 'Launch review', id: BRANCH_ID } }) + expect(created.status).toBe(201) +}) + +test('the editor edits the branch, reads the review, comments and requests a merge', async ({ browser }) => { + const page = await editorPage(browser) + const home = await homeRow(page, BRANCH_ID) + const heading = home.cells.body.nodes['review-heading']! + const hero = home.cells.body.nodes['review-hero']! + const nodes = { + ...home.cells.body.nodes, + 'review-heading': { ...heading, props: { ...heading.props, text: 'Launch week starts Monday' } }, + 'review-hero': { ...hero, children: [...(hero.children ?? []), 'review-note'] }, + 'review-note': { id: 'review-note', moduleId: 'base.text', props: { text: 'Five features in five days, starting with branches.', tag: 'p' }, children: [] }, + } + await saveHome(page, home, { ...home.cells, title: BRANCH_TITLE, body: { ...home.cells.body, nodes } }, BRANCH_ID) + + // The review page is where the branch strip's "Request merge…" lands. + await page.goto(`/admin/branches/${BRANCH_ID}/review?branch=${BRANCH_ID}`) + await expect(page.getByTestId('branch-review-title')).toHaveText(/Merge Launch review into main/) + const strip = page.getByTestId('branch-strip') + await expect(strip).toContainText('Launch review') + // Without merge rights the strip's action reads as a request, not a merge. + await expect(page.getByTestId('branch-strip-merge')).toHaveText(/Request merge/) + const homeChange = page.getByTestId(`review-change-row:${home.id}`) + await expect(homeChange).toBeVisible() + // Both renders load (the frame host flips to loaded once measured). + await expect(homeChange.locator('[data-loaded="true"]').first()).toBeVisible({ timeout: 30_000 }) + await expect(homeChange.locator('[data-loaded="true"]')).toHaveCount(2, { timeout: 30_000 }) + // The changed heading and the added paragraph are outlined in the branch + // render, found by their node ids. + await expect(homeChange.locator('[data-tone="changed"]')).toHaveCount(1) + await expect(homeChange.locator('[data-tone="added"]')).toHaveCount(1) + // Labels name the node the plan diffed (a text node here). + await expect(homeChange.locator('[data-tone="changed"]')).toContainText('text') + await shot(page, '1-editor-review') + + await page.getByTestId(`review-thread-row:${home.id}-input`).fill('New headline for launch week; the rest of the page is untouched.') + await page.getByTestId(`review-thread-row:${home.id}-submit`).click() + await expect(page.getByTestId(`review-thread-row:${home.id}-comment`)).toHaveCount(1) + + await page.getByTestId('review-request-open').first().click() + await page.getByTestId('review-request-note').fill('Launch week home page. Please review before Monday.') + await page.getByTestId('review-request-confirm').click() + await expect(page.getByTestId('review-status').first()).toHaveText(/Awaiting review/) + await expect(page.getByTestId('review-withdraw')).toBeVisible() + await shot(page, '3-editor-requested') + await page.context().close() +}) + +test('the owner sees a conflict, comments, and declines with a note', async ({ page }) => { + // Main moves on the same page: a conflict on the title. + await page.goto('/admin/dashboard') + const home = await homeRow(page) + await saveHome(page, home, { ...home.cells, title: MAIN_TITLE }) + + await page.goto(`/admin/branches/${BRANCH_ID}/review?branch=${BRANCH_ID}`) + await expect(page.getByTestId('branch-review-title')).toHaveText(/Merge Launch review into main/) + await expect(page.getByTestId('review-status').first()).toHaveText(/Awaiting review · 1 conflict/) + const conflict = page.getByTestId(`review-conflict-row:${home.id}`) + await expect(conflict).toContainText('Both sides changed title') + await expect(page.getByTestId('review-merge')).toBeDisabled() + await expect(page.getByTestId(`review-thread-row:${home.id}-comment`)).toHaveCount(1) + await shot(page, '4-owner-conflict') + + await page.getByTestId('review-thread-request-input').fill('Main got a new title in the meantime; I will take yours after you fix the excerpt.') + await page.getByTestId('review-thread-request-submit').click() + await expect(page.getByTestId('review-thread-request-comment')).toHaveCount(1) + + await page.getByTestId('review-decline-open').click() + await page.getByTestId('review-decline-note').fill('Shorten the headline to fit the hero on mobile, then request again.') + await page.getByTestId('review-decline-confirm').click() + await expect(page.getByTestId('review-status').first()).toHaveText(/Changes requested/) + await expect(page.getByTestId('review-decision')).toContainText('Shorten the headline') + await shot(page, '5-owner-declined') +}) + +test('the editor sees the decline and requests again', async ({ browser }) => { + const page = await editorPage(browser) + await page.goto(`/admin/branches/${BRANCH_ID}/review?branch=${BRANCH_ID}`) + await expect(page.getByTestId('branch-review-title')).toHaveText(/Changes requested on Launch review/) + await expect(page.getByTestId('review-decision')).toContainText('Shorten the headline') + await shot(page, '6-editor-declined') + + await page.getByTestId('review-request-open').first().click() + await page.getByTestId('review-request-note').fill('Headline shortened. Ready for another look.') + await page.getByTestId('review-request-confirm').click() + await expect(page.getByTestId('review-status').first()).toHaveText(/Awaiting review/) + await shot(page, '7-editor-requested-again') + await page.context().close() +}) + +test('the owner resolves the conflict and merges with a step-up', async ({ page }) => { + await page.goto(`/admin/branches/${BRANCH_ID}/review?branch=${BRANCH_ID}`) + const home = await homeRow(page) + await expect(page.getByTestId('review-merge')).toBeDisabled() + await page.getByRole('radio', { name: 'Take branch' }).or(page.getByRole('button', { name: 'Take branch' })).first().click() + await expect(page.getByTestId(`review-conflict-row:${home.id}`)).toContainText('Resolved: taking the branch') + await expect(page.getByTestId('review-merge')).toBeEnabled() + await shot(page, '8-owner-resolved') + + await page.getByTestId('review-merge').click() + await completeStepUp(page, OWNER.password) + await expect(page.getByText(/Merged Launch review into main/)).toBeVisible({ timeout: 20_000 }) + await expect(page).toHaveURL(/\/admin\/site/) + await shot(page, '9-owner-merged') + + // Main's draft carries the branch's title; the branch is gone. + const merged = await homeRow(page) + expect(merged.cells.title).toBe(BRANCH_TITLE) + const branches = await api<{ branches: Array<{ id: string }> }>(page, '/admin/api/cms/branches') + expect(branches.body.branches.some((branch) => branch.id === BRANCH_ID)).toBe(false) + + await page.goto('/admin/users') + await page.getByRole('radio', { name: 'Audit' }).or(page.getByRole('tab', { name: 'Audit' })).or(page.getByRole('button', { name: 'Audit' })).first().click() + await expect(page.getByText(/was merged|review/i).first()).toBeVisible() + await shot(page, '10-audit') +}) From 5851562fc8e7c683c60338158001ca494bda7fc6 Mon Sep 17 00:00:00 2001 From: DavidBabinec <hello@davidbabinec.com> Date: Thu, 3 Sep 2026 18:31:43 +0200 Subject: [PATCH 03/16] feat(branches): review polish, update-only dialog, docs Highlight labels read as Changed/Added/Removed with the node name when it has one, short pages get short frames, the old merge dialog is now UpdateBranchDialog (merging lives on the review page), BRANCH-005 merges from the page, branches and audit docs describe the review. --- docs/features/audit-log.md | 2 +- docs/features/branches.md | 52 +++++++++++-- .../branches/BranchReviewPage.module.css | 2 +- src/admin/pages/branches/PageCompare.tsx | 17 ++++- .../BranchSwitcher/BranchContextStrip.tsx | 14 ++-- ...dule.css => UpdateBranchDialog.module.css} | 8 -- ...ranchDialog.tsx => UpdateBranchDialog.tsx} | 76 ++++++------------- tests/e2e/branch-review.e2e.ts | 11 ++- tests/e2e/branches.e2e.ts | 16 ++-- 9 files changed, 105 insertions(+), 93 deletions(-) rename src/admin/shared/BranchSwitcher/{MergeBranchDialog.module.css => UpdateBranchDialog.module.css} (91%) rename src/admin/shared/BranchSwitcher/{MergeBranchDialog.tsx => UpdateBranchDialog.tsx} (71%) diff --git a/docs/features/audit-log.md b/docs/features/audit-log.md index 2f34072eb..05d4caf27 100644 --- a/docs/features/audit-log.md +++ b/docs/features/audit-log.md @@ -44,7 +44,7 @@ Every event has a typed `action` string. The closed union is the source of truth | Publishing | `publish` | | Plugins | `plugin.install`, `plugin.update`, `plugin.enable`, `plugin.disable`, `plugin.delete`, `plugin.pack.install`, `plugin.settings.update` | | AI | `ai.credential.created`, `ai.credential.updated`, `ai.credential.deleted`, `ai.credential.tested`, `ai.default.updated`, `ai.default.cleared`, `ai.chat.started`, `ai.chat.completed`, `ai.chat.failed`, `ai.mcp_connector.created`, `ai.mcp_connector.revoked` | -| Branches | `branch.create`, `branch.rename`, `branch.delete`, `branch.merge`, `branch.update`, `branch.preview.share`, `branch.preview.revoke` | +| Branches | `branch.create`, `branch.rename`, `branch.delete`, `branch.merge`, `branch.update`, `branch.preview.share`, `branch.preview.revoke`, `branch.review.request`, `branch.review.withdraw`, `branch.review.decline`, `branch.review.comment` | | Versions | `version.restore` — a published version copied back into a row's draft (metadata: `versionId`, `versionNumber`, `branchId`) | If you add a new action that fits an existing group, append to the union. New groups (e.g. media-related audit) extend the same union. diff --git a/docs/features/branches.md b/docs/features/branches.md index e7650b791..13698f81b 100644 --- a/docs/features/branches.md +++ b/docs/features/branches.md @@ -15,6 +15,7 @@ main with a three-way review. Publishing only ever happens on main. - **Publish and schedule are disabled on a branch** with the reason inline (`useBranchPublishGate`); the server answers `409` if a request slips through. - **Preview links** (`/_instatic/preview/<token>`) set an HttpOnly cookie; while it names a live link, every public GET renders the branch's draft with a banner (`server/publish/branchPreview.ts`). - **Merge** (branch → main) and **Update** (main → branch) are the same three-way merge over `site_branch_bases` (`server/branches/merge.ts`): field-level where both sides moved different fields, a reviewer decision where they moved the same one. An update never writes main. +- **Merge review** (`/admin/branches/:id/review`) is where merging happens: one timeline node per planned change — pages as before/after renders with the changed nodes outlined, entries as field tables, tables as schemas, files as line diffs — each with its own comment thread; anyone with `site.read` can ask for a merge, comment, and read the plan, a branch manager declines or merges from the page's footer. - **Version history** lists a row's published versions and restores one into the draft on the active branch (`data_row_versions`, `GET/POST …/data/rows/:id/versions`). - Capability: `site.branches.manage` (Owner, Admin). Audit: `branch.*`, `version.restore`. @@ -31,24 +32,30 @@ src/core/branches/ server/branches/ ├── scope.ts BranchScope, MAIN_SCOPE, resolveBranchScope(req, db), BRANCH_HEADER -├── contentHash.ts rowContent / tableContent / siteContent projections + hashes + schemas +├── contentHash.ts rowContent / tableContent / siteContent / fileContent projections + hashes + schemas +├── entities.ts collectBranchEntities — shell, files, tables, rows as one keyed map +├── changeDetail.ts describeChange — per-change detail for the review (fields, page tree diff, file text) ├── fork.ts forkBranch — copy shell, tables, rows; record bases (one transaction) ├── deleteBranch.ts deleteBranch — rows, tables, shell, collab docs, registry row ├── merge.ts planBranchMerge / applyBranchMerge (merge + update directions) +├── review.ts merge requests, comments, branch content hash (stale detection) └── previewLinks.ts tokens, cookie, resolvePreviewCookie, entry/exit paths server/repositories/ ├── branches.ts site_branches registry ├── branchBases.ts site_branch_bases — base hash + content per entity -└── branchPreviews.ts site_branch_previews — hashed tokens, one active link per branch +├── branchPreviews.ts site_branch_previews — hashed tokens, one active link per branch +└── branchReviews.ts site_branch_merge_requests + site_branch_review_comments server/handlers/cms/branches.ts /admin/api/cms/branches[/…] endpoints server/publish/branchPreview.ts render a branch draft for a public URL server/publish/branchPreviewAssets.ts in-memory runtime bundles for previews +server/publish/branchReviewRender.ts one page as main or the branch renders it, node ids stamped server/publish/publicRoutes.ts dispatcher tail: preview link, public route, 404 src/admin/state/branchStore.ts active branch, registry, switcher UI state, publish gate -src/admin/shared/BranchSwitcher/ chip + palette, context strip, manage / delete / merge dialogs +src/admin/shared/BranchSwitcher/ chip + palette, context strip, manage / delete / update dialogs +src/admin/pages/branches/ the merge review page (timeline, page compare, threads) src/admin/shared/VersionHistoryDialog/ published versions + restore src/admin/spotlight/commands/branches.ts Switch / Create / Manage / Switch to main src/admin/spotlight/providers/branchesProvider.ts "Switch to <branch>" rows @@ -98,9 +105,9 @@ Doc ids carry the branch: `page:<branch>:<rowId>`, `component:<branch>:<rowId>`, Everything lives in the shared toolbar (`src/admin/pages/site/toolbar/Toolbar.tsx`), so it is present on every admin route: - **Chip** (`BranchChip`) next to the site brand — always icon-only, tinted with the branch accent off main (the context strip right above it carries the name, so the chip does not repeat it). Opens a palette: search first (Enter switches to the first match, or starts creating when nothing matches), then *Current* and *Recent* (main first, then by `updatedAt`), then *Create branch…* (an in-place form: name → slug preview, start from main or the current branch) and *Manage branches…*. -- **Context strip** (`BranchContextStrip`) above the toolbar while on a branch, painted `--bg-surface-2` with the identity tint (`pillAccent(branch.id)`) on the icon and name only. Actions: *Share preview* / *New preview link*, *Merge into main…*, and a menu with *Update from main…*, *Rename…*, *Revoke preview link*, *Switch to main*, *Delete branch*. +- **Context strip** (`BranchContextStrip`) above the toolbar while on a branch, painted `--bg-surface-2` with the identity tint (`pillAccent(branch.id)`) on the icon and name only. Actions: *Share preview* / *New preview link*, *Merge into main…* (*Request merge…* without `site.branches.manage`; both open the review page), and a menu with *Update from main…*, *Rename…*, *Revoke preview link*, *Switch to main*, *Delete branch*. - **Manage dialog** (`ManageBranchesDialog`) — search by name or id, open, rename inline, delete, create. -- **Merge dialog** (`MergeBranchDialog`) — the plan grouped by Site / Tables / entries per table, `New` / `Changed` / `Removed` badges, a two-way choice per conflict, and (merge only) *Delete branch after merging*, default on. +- **Update dialog** (`UpdateBranchDialog`) — the update plan grouped by Site / Tables / entries per table, `New` / `Changed` / `Removed` badges, a two-way choice per conflict. Merging has no dialog: it happens on the review page (below). - **Delete** always confirms (`DeleteBranchDialog`) and steps up. - **Publish controls** on a branch: the site Publish button, the Content and Data publish groups, the data grid's row menu and bulk bar, and the Content settings status select all disable with `BRANCH_PUBLISH_REASON` inline. The Spotlight `editor.publish` command hides on a branch. - **Spotlight**: group `branches` — *Switch branch…*, *Create branch…*, *Switch to main*, *Manage branches…*; typing a branch name lists *Switch to <name>*. @@ -134,11 +141,39 @@ Both directions run `planBranchMerge(db, branchId, direction)` over three snapsh | Both moved, same field | Conflict at that path — the reviewer picks `into` or `from` for the whole entity | | Deleted on one side, changed on the other | Conflict (`(deleted)`) | -Row content is `{ tableId, cells, slug }` — **never `status`**: a merge changes drafts, not what is live. The site content is `{ name, shell }` without id and timestamps (the merged shell is re-validated with `validateSite` before it is saved); table content is the schema fields. +Row content is `{ tableId, cells, slug }` — **never `status`**: a merge changes drafts, not what is live. The site content is `{ name, shell }` without id, timestamps, **or files**; every site file is an entity of its own (`file:<file id>`, content = the file minus id and timestamps), so two people editing different files never conflict and a file conflict names the file. The merged shell is re-validated with `validateSite` before it is saved; file writes read the shell, replace or drop the one file, and save it back. Table content is the schema fields. + +Every planned change carries `detail` (`server/branches/changeDetail.ts`, schema `MergeChangeDetailSchema`): changed fields as display text with `before` (the receiving side) and `after`, a node-level tree diff (`added` / `changed` / `removed` ids with labels) for rows whose `body` is a node tree, the schema field statuses for a table, and both texts for a file. It is computed from the same projections the merge compares, so the review never disagrees with the plan. `applyBranchMerge` flushes the relay, re-plans against live data (an unresolved conflict aborts before any write), then in one transaction writes each result to `into`. A **merge** also mirrors the result onto the branch so both sides agree afterwards, and the base becomes the result. An **update** never writes main: the branch takes the result and the base becomes main's content as of the update, so the branch's own changes stay pending. Entities identical on both sides whose base is stale move their base forward too, so a later edit on one side is not reported as a conflict. Row writes use the repositories with `collabInternal`; after commit the row/shell write notifications fire (open editors reset and reload) and, when main received rows, the `content.entry.*` plugin hooks fire for them. Table creates run before rows and restore a soft-deleted table under the same id; table deletes run last and abort the merge (`MergeApplyError`) when the table still has rows. -Endpoints: `GET|POST /admin/api/cms/branches/:id/merge` and `…/update` (`site.branches.manage`; `POST` steps up). `POST` body: `{ resolutions?: Record<key, 'into' | 'from'>, deleteBranch?: boolean }` → `{ plan, branchDeleted }`; unresolved conflicts answer `409 { code: 'merge_conflicts', keys }`. +Endpoints: `GET|POST /admin/api/cms/branches/:id/merge` and `…/update`. `GET` (the plan) needs `site.read` — the review page shows it to whoever can read the site; `POST` needs `site.branches.manage` and steps up. `POST` body: `{ resolutions?: Record<key, 'into' | 'from'>, deleteBranch?: boolean }` → `{ plan, branchDeleted }`; unresolved conflicts answer `409 { code: 'merge_conflicts', keys }`. A successful merge closes the branch's open merge request as `merged`. + +--- + +## Merge review + +`/admin/branches/:id/review` (`src/admin/pages/branches/BranchReviewPage.tsx`, workspace `branchReview`, gated by `site.read`) is the review. Opening it switches the tab to the branch. It loads the merge plan and the review state together and renders one timeline: + +- **The request node** — the open or last merge request (who, note, status pill: *Awaiting review* / *Changes requested* / *Merged* / *Withdrawn*), with the general conversation beside it and a facts grid (changes by kind, conflicts left, freshness, what merging does). Without a request it offers *Request merge…*. +- **One node per change**, marked `A` / `M` / `D` on the line, with a thread box on the left (comments keyed by the change's `key`, an always-present composer) and the change on the right: a page (`row` in the `pages` table) as **before/after frames**, an entry as a field table, a table as its schema, the shell as a settings table, a file as a line diff (`@core/utils/lineDiff`). A change with conflicts carries a strip with *Keep main* / *Take branch* (managers only). +- **The decision node** — the decline note, the merge outcome, or the wait. +- **The footer** — managers: *Delete branch after merging*, *Decline…* (open request only; a note is required) and *Merge N changes*, disabled with the count while conflicts are undecided; the merge runs the existing step-up-gated `POST …/merge`. Requesters: *Withdraw request*; everyone else: *Request merge…*. + +Page frames: `GET /admin/api/cms/branches/:id/review/render?row=<page row id>&side=main|branch` (`site.read`) returns the page's HTML composed like the branch preview (template chain, draft loops, inlined CSS, `dynamicNodes: 'inline'`) with `annotateNodeIds` on, so every node's root element carries `uid="<node id>"`; no runtime scripts are bundled. The page fetches it through `apiTextRequest` and hands it to an `<iframe sandbox="allow-same-origin">` as `srcdoc` (scripts stay off). After load, the frame is measured and the nodes the plan's tree diff lists are found by `uid` and outlined in place — highlights come from the diff, never from guesses. Modes: side by side, swipe (one frame clipped over the other), and the plain change list. + +Requests and comments (`server/branches/review.ts`, `server/repositories/branchReviews.ts`, migration `027_site_branch_reviews`): `site_branch_merge_requests` (one open per branch; `content_hash` of every branch entity at request time, so the page can say when the branch moved on) and `site_branch_review_comments` (keyed by branch and `entity_key`, `''` for the request itself; they outlive a declined request). Both cascade with the branch. + +| Endpoint | Gate | Effect | +|----------|------|--------| +| `GET /admin/api/cms/branches/:id/review` | `site.read` | `{ branch, request, comments, contentHash }` | +| `POST …/review/request` | `site.read` | Opens a request `{ note }`; `409 merge_request_open` while one is open | +| `POST …/review/withdraw` | requester or `site.branches.manage` | Closes it as withdrawn | +| `POST …/review/decline` | `site.branches.manage` | Closes it as declined; `{ note }` required | +| `POST …/review/comments` | `site.read` | `{ entityKey, body }` → `{ comment }` | +| `GET …/review/render?row=&side=` | `site.read` | The page HTML for one side, `no-store`, `noindex` | + +Audit: `branch.review.request`, `branch.review.withdraw`, `branch.review.decline`, `branch.review.comment`, next to `branch.merge`. --- @@ -176,6 +211,7 @@ Add `branch_id text not null default 'main'` and the generated `logical_id` to t - Publishing, scheduling, or baking artefacts for a scope other than main. - Storing a preview token in plain text, or granting preview access from anything but the cookie's token lookup. - Merging `status` or timestamps — only content moves between branches. +- Rendering a review frame with scripts enabled, or loading it by navigation instead of `srcdoc` (the frame must never carry the admin session as a page). --- @@ -185,4 +221,4 @@ Add `branch_id text not null default 'main'` and the generated `logical_id` to t - [`content-storage.md`](content-storage.md) — the branched tables - [`publisher.md`](publisher.md) — the public render path a preview mirrors - [`../reference/capabilities.md`](../reference/capabilities.md) — `site.branches.manage` -- [`audit-log.md`](audit-log.md) — `branch.*`, `version.restore` +- [`audit-log.md`](audit-log.md) — `branch.*`, `branch.review.*`, `version.restore` diff --git a/src/admin/pages/branches/BranchReviewPage.module.css b/src/admin/pages/branches/BranchReviewPage.module.css index 9ea228ba8..e7901a1df 100644 --- a/src/admin/pages/branches/BranchReviewPage.module.css +++ b/src/admin/pages/branches/BranchReviewPage.module.css @@ -239,7 +239,7 @@ .highlight { position: absolute; left: var(--hl-x); top: var(--hl-y); width: var(--hl-w); height: var(--hl-h); border: 3px solid var(--warning); border-radius: 6px; background: var(--warning-10); pointer-events: none; box-sizing: border-box; } .highlight[data-tone="added"] { border-color: var(--success); background: var(--success-10); } .highlight[data-tone="removed"] { border-color: var(--danger-light); background: var(--danger-10); } -.highlightLabel { position: absolute; left: -3px; top: -30px; padding: 3px 10px; border-radius: 4px; background: var(--warning); color: var(--bg-body); font-size: calc(var(--text-s) * 2.4); font-weight: 700; white-space: nowrap; } +.highlightLabel { position: absolute; right: 6px; top: 6px; padding: 2px 10px; border-radius: 999px; background: var(--warning); color: var(--bg-body); font-size: calc(var(--text-s) * 2); font-weight: 700; white-space: nowrap; opacity: 0.92; } .highlight[data-tone="added"] .highlightLabel { background: var(--success); } .highlight[data-tone="removed"] .highlightLabel { background: var(--danger-light); } diff --git a/src/admin/pages/branches/PageCompare.tsx b/src/admin/pages/branches/PageCompare.tsx index 1a5f9956c..a9244b57e 100644 --- a/src/admin/pages/branches/PageCompare.tsx +++ b/src/admin/pages/branches/PageCompare.tsx @@ -18,7 +18,7 @@ import { Switch } from '@ui/components/Switch' import styles from './BranchReviewPage.module.css' const PAGE_WIDTH = 1280 -const MIN_HEIGHT = 720 +const MIN_HEIGHT = 360 const MAX_HEIGHT = 2400 interface HighlightBox { @@ -146,6 +146,15 @@ function ScaledFrame({ branchId, rowId, side, title, marks, showHighlights }: Fr type Mode = 'side' | 'swipe' | 'list' +/** + * "Changed · Hero title" when the node carries a name of its own; a bare + * "Changed" when the plan could only name its module (`text`, `container`). + */ +function markLabel(verb: string, nodeLabel: string | undefined): string { + if (!nodeLabel || /^[a-z][a-z0-9-]*$/.test(nodeLabel)) return verb + return `${verb} · ${nodeLabel}` +} + interface PageCompareProps { branchId: string rowId: string @@ -167,11 +176,11 @@ export function PageCompare({ branchId, rowId, label, action, tree, fieldLines, const branchMarks = tree ? [ - ...tree.changed.map((id) => ({ id, label: tree.labels[id] ?? 'changed', tone: 'changed' as const })), - ...tree.added.map((id) => ({ id, label: tree.labels[id] ?? 'added', tone: 'added' as const })), + ...tree.changed.map((id) => ({ id, label: markLabel('Changed', tree.labels[id]), tone: 'changed' as const })), + ...tree.added.map((id) => ({ id, label: markLabel('Added', tree.labels[id]), tone: 'added' as const })), ] : [] - const mainMarks = tree ? tree.removed.map((id) => ({ id, label: tree.labels[id] ?? 'removed', tone: 'removed' as const })) : [] + const mainMarks = tree ? tree.removed.map((id) => ({ id, label: markLabel('Removed', tree.labels[id]), tone: 'removed' as const })) : [] const treeLines = tree ? [ ...tree.added.map((id) => `Added ${tree.labels[id] ?? id}`), diff --git a/src/admin/shared/BranchSwitcher/BranchContextStrip.tsx b/src/admin/shared/BranchSwitcher/BranchContextStrip.tsx index 5a2d41bdb..bacddab1b 100644 --- a/src/admin/shared/BranchSwitcher/BranchContextStrip.tsx +++ b/src/admin/shared/BranchSwitcher/BranchContextStrip.tsx @@ -19,7 +19,7 @@ import { LinkIcon } from 'pixel-art-icons/icons/link' import { MoreHorizontalSolidIcon } from 'pixel-art-icons/icons/more-horizontal-solid' import { ShareSolidIcon } from 'pixel-art-icons/icons/share-solid' import { TrashSolidIcon } from 'pixel-art-icons/icons/trash-solid' -import { MAIN_BRANCH_ID, type BranchPreview, type MergeDirection, type SiteBranch } from '@core/branches' +import { MAIN_BRANCH_ID, type BranchPreview, type SiteBranch } from '@core/branches' import { getCmsBranchPreview, issueCmsBranchPreview, revokeCmsBranchPreview } from '@core/persistence' import { isAbortError } from '@core/http' import { getErrorMessage } from '@core/utils/errorMessage' @@ -36,8 +36,8 @@ import styles from './BranchSwitcher.module.css' const DeleteBranchDialog = lazy(() => import('./DeleteBranchDialog').then((m) => ({ default: m.DeleteBranchDialog })), ) -const MergeBranchDialog = lazy(() => - import('./MergeBranchDialog').then((m) => ({ default: m.MergeBranchDialog })), +const UpdateBranchDialog = lazy(() => + import('./UpdateBranchDialog').then((m) => ({ default: m.UpdateBranchDialog })), ) async function copyToClipboard(text: string): Promise<boolean> { @@ -68,7 +68,7 @@ function BranchStripBody({ branch: current }: { branch: SiteBranch }) { const [deleting, setDeleting] = useState(false) const [preview, setPreview] = useState<BranchPreview | null>(null) const [sharing, setSharing] = useState(false) - const [merge, setMerge] = useState<MergeDirection | null>(null) + const [updateOpen, setUpdateOpen] = useState(false) const moreRef = useRef<HTMLButtonElement>(null) useEffect(() => { @@ -199,7 +199,7 @@ function BranchStripBody({ branch: current }: { branch: SiteBranch }) { data-testid="branch-strip-update" onClick={() => { setMoreOpen(false) - setMerge('update') + setUpdateOpen(true) }} > <ArrowDownIcon size={12} aria-hidden="true" /> @@ -264,9 +264,9 @@ function BranchStripBody({ branch: current }: { branch: SiteBranch }) { <DeleteBranchDialog branch={current} onClose={() => setDeleting(false)} /> </Suspense> )} - {merge && ( + {updateOpen && ( <Suspense fallback={null}> - <MergeBranchDialog key={merge} branch={current} direction={merge} onClose={() => setMerge(null)} /> + <UpdateBranchDialog branch={current} onClose={() => setUpdateOpen(false)} /> </Suspense> )} </div> diff --git a/src/admin/shared/BranchSwitcher/MergeBranchDialog.module.css b/src/admin/shared/BranchSwitcher/UpdateBranchDialog.module.css similarity index 91% rename from src/admin/shared/BranchSwitcher/MergeBranchDialog.module.css rename to src/admin/shared/BranchSwitcher/UpdateBranchDialog.module.css index e852e63a4..6d9689176 100644 --- a/src/admin/shared/BranchSwitcher/MergeBranchDialog.module.css +++ b/src/admin/shared/BranchSwitcher/UpdateBranchDialog.module.css @@ -88,14 +88,6 @@ font-size: var(--text-2xs); } -.deleteToggle { - display: inline-flex; - align-items: center; - gap: var(--space-xs); - color: var(--text); - font-size: var(--text-xs); -} - .footerSpacer { flex: 1; } diff --git a/src/admin/shared/BranchSwitcher/MergeBranchDialog.tsx b/src/admin/shared/BranchSwitcher/UpdateBranchDialog.tsx similarity index 71% rename from src/admin/shared/BranchSwitcher/MergeBranchDialog.tsx rename to src/admin/shared/BranchSwitcher/UpdateBranchDialog.tsx index a396c61f6..dc577136a 100644 --- a/src/admin/shared/BranchSwitcher/MergeBranchDialog.tsx +++ b/src/admin/shared/BranchSwitcher/UpdateBranchDialog.tsx @@ -1,6 +1,7 @@ /** - * MergeBranchDialog — review what a merge (branch → main) or an update - * (main → branch) will change, decide every conflict, then apply. + * UpdateBranchDialog — review what updating a branch from main will change, + * decide every conflict, then apply. Merging into main has its own page + * (`/admin/branches/:id/review`); this dialog only ever updates the branch. * * The plan comes from the server; the dialog never guesses. A change with * conflicts renders a two-way choice — keep this side or take the other — @@ -11,10 +12,8 @@ */ import { useEffect, useState } from 'react' import { ArrowDownIcon } from 'pixel-art-icons/icons/arrow-down' -import { GitMergeSolidIcon } from 'pixel-art-icons/icons/git-merge-solid' import { type MergeChange, - type MergeDirection, type MergePlan, type MergeResolution, type SiteBranch, @@ -28,15 +27,13 @@ import { Button } from '@ui/components/Button' import { Dialog } from '@ui/components/Dialog' import { SegmentedControl } from '@ui/components/SegmentedControl' import { Skeleton } from '@ui/components/Skeleton' -import { Switch } from '@ui/components/Switch' import { TagPill } from '@ui/components/TagPill' import { pushToast } from '@ui/components/Toast' import { cn } from '@ui/cn' -import styles from './MergeBranchDialog.module.css' +import styles from './UpdateBranchDialog.module.css' -interface MergeBranchDialogProps { +interface UpdateBranchDialogProps { branch: SiteBranch - direction: MergeDirection onClose: () => void } @@ -67,24 +64,21 @@ function describeConflicts(conflicts: string[]): string { return conflicts.length > 3 ? `Both sides changed ${fields} and ${conflicts.length - 3} more` : `Both sides changed ${fields}` } -export function MergeBranchDialog({ branch, direction, onClose }: MergeBranchDialogProps) { +export function UpdateBranchDialog({ branch, onClose }: UpdateBranchDialogProps) { const { runStepUp } = useStepUp() const [plan, setPlan] = useState<MergePlan | null>(null) const [loadError, setLoadError] = useState<string | null>(null) const [resolutions, setResolutions] = useState<Record<string, MergeResolution>>({}) - const [deleteAfter, setDeleteAfter] = useState(direction === 'merge') const [busy, setBusy] = useState(false) - const isMerge = direction === 'merge' - const title = isMerge ? `Merge ${branch.name} into main` : `Update ${branch.name} from main` - const intoLabel = isMerge ? 'Keep main' : 'Keep branch' - const fromLabel = isMerge ? 'Take branch' : 'Take main' + const title = `Update ${branch.name} from main` + const intoLabel = 'Keep branch' + const fromLabel = 'Take main' - // Mounted fresh per open (keyed by direction in the strip), so the plan - // state starts empty and needs no reset here. + // Mounted fresh per open, so the plan state starts empty and needs no reset. useEffect(() => { const controller = new AbortController() - getCmsBranchMergePlan(branch.id, direction) + getCmsBranchMergePlan(branch.id, 'update') .then((next) => { if (!controller.signal.aborted) setPlan(next) }) @@ -94,7 +88,7 @@ export function MergeBranchDialog({ branch, direction, onClose }: MergeBranchDia setLoadError(getErrorMessage(err, 'Could not compare the branches')) }) return () => controller.abort() - }, [branch.id, direction]) + }, [branch.id]) const unresolved = plan ? plan.changes.filter((change) => change.conflicts.length > 0 && !resolutions[change.key]).length @@ -105,28 +99,24 @@ export function MergeBranchDialog({ branch, direction, onClose }: MergeBranchDia if (!plan || busy || unresolved > 0) return setBusy(true) try { - const result = await runStepUp(() => - mergeBranch(branch.id, direction, { resolutions, deleteBranch: isMerge && deleteAfter }), - ) + const result = await runStepUp(() => mergeBranch(branch.id, 'update', { resolutions })) onClose() const count = result.plan.changes.length pushToast({ kind: 'success', - title: isMerge ? `Merged ${branch.name} into main` : `Updated ${branch.name} from main`, - body: isMerge - ? `${count} change${count === 1 ? '' : 's'} landed in main's draft. Publish when you're ready.${result.branchDeleted ? ' The branch was deleted.' : ''}` - : `${count} change${count === 1 ? '' : 's'} from main now on the branch.`, + title: `Updated ${branch.name} from main`, + body: `${count} change${count === 1 ? '' : 's'} from main now on the branch.`, }) } catch (err) { if (err instanceof Error && err.message === StepUpCancelledMessage) return console.error('[branches] merge failed:', err) pushToast({ kind: 'error', - title: isMerge ? 'Merge failed' : 'Update failed', - body: getErrorMessage(err, 'Unknown merge error'), + title: 'Update failed', + body: getErrorMessage(err, 'Unknown update error'), }) // A conflict that appeared after the plan was loaded: reload it. - getCmsBranchMergePlan(branch.id, direction).then(setPlan).catch(() => undefined) + getCmsBranchMergePlan(branch.id, 'update').then(setPlan).catch(() => undefined) } finally { setBusy(false) } @@ -137,22 +127,10 @@ export function MergeBranchDialog({ branch, direction, onClose }: MergeBranchDia open onClose={onClose} title={title} - eyebrow={isMerge ? 'Merge' : 'Update'} + eyebrow="Update" size="lg" footer={( <> - {isMerge && ( - <label className={styles.deleteToggle}> - <Switch - checked={deleteAfter} - onCheckedChange={setDeleteAfter} - switchSize="sm" - aria-label="Delete branch after merging" - data-testid="branch-merge-delete-toggle" - /> - <span>Delete branch after merging</span> - </label> - )} <span className={styles.footerSpacer} aria-hidden="true" /> <Button variant="ghost" size="sm" type="button" onClick={onClose} disabled={busy}> Cancel @@ -167,12 +145,8 @@ export function MergeBranchDialog({ branch, direction, onClose }: MergeBranchDia data-testid="branch-merge-apply" onClick={() => { void apply() }} > - {isMerge ? <GitMergeSolidIcon size={12} aria-hidden="true" /> : <ArrowDownIcon size={12} aria-hidden="true" />} - <span> - {isMerge - ? `Merge ${total} change${total === 1 ? '' : 's'}` - : `Update with ${total} change${total === 1 ? '' : 's'}`} - </span> + <ArrowDownIcon size={12} aria-hidden="true" /> + <span>{`Update with ${total} change${total === 1 ? '' : 's'}`}</span> </Button> </> )} @@ -187,16 +161,12 @@ export function MergeBranchDialog({ branch, direction, onClose }: MergeBranchDia </div> ) : total === 0 ? ( <p className={styles.empty} data-testid="branch-merge-empty"> - {isMerge - ? `${branch.name} has no changes that main does not already have.` - : `${branch.name} already has everything on main.`} + {`${branch.name} already has everything on main.`} </p> ) : ( <> <p className={styles.summary} data-testid="branch-merge-summary"> - {isMerge - ? `${total} change${total === 1 ? '' : 's'} will land in main's draft.` - : `${total} change${total === 1 ? '' : 's'} from main will land on the branch.`} + {`${total} change${total === 1 ? '' : 's'} from main will land on the branch.`} {plan.conflictCount > 0 && ( <> {' '} diff --git a/tests/e2e/branch-review.e2e.ts b/tests/e2e/branch-review.e2e.ts index a56866428..ae58ed54c 100644 --- a/tests/e2e/branch-review.e2e.ts +++ b/tests/e2e/branch-review.e2e.ts @@ -48,9 +48,14 @@ async function api<T>(page: Page, path: string, init: { method?: string; body?: body: init.body === undefined ? undefined : JSON.stringify(init.body), }) const text = await res.text() - let body: unknown = null - try { body = JSON.parse(text) } catch { body = text } - return { status: res.status, body: body as never } + const parse = (): unknown => { + try { + return JSON.parse(text) + } catch { + return text + } + } + return { status: res.status, body: parse() as never } }, { path, init }) } diff --git a/tests/e2e/branches.e2e.ts b/tests/e2e/branches.e2e.ts index 9356275d0..549a4b14a 100644 --- a/tests/e2e/branches.e2e.ts +++ b/tests/e2e/branches.e2e.ts @@ -177,7 +177,7 @@ test('share a preview link and open it as a visitor (BRANCH-004)', async ({ page await expect(page.getByTestId('branch-strip')).toHaveCount(0) }) -test('merge a branch into main from the review dialog (BRANCH-005)', async ({ page }) => { +test('merge a branch into main from the review page (BRANCH-005)', async ({ page }) => { await login(page) await openSiteEditor(page) await page.getByTestId('branch-chip').click() @@ -192,18 +192,18 @@ test('merge a branch into main from the review dialog (BRANCH-005)', async ({ pa await expect(page.getByText('A change was reverted')).toHaveCount(0) await page.getByTestId('branch-strip-merge').click() - const dialog = page.getByRole('dialog', { name: 'Merge Merge Me into main' }) - await expect(dialog).toBeVisible() - await expect(page.getByTestId('branch-merge-summary')).toContainText('1 change') + // The review page: one node per change, the decision in the footer. + await expect(page.getByTestId('branch-review-title')).toHaveText('Merge Merge Me into main') + await expect(page.getByTestId('branch-review')).toContainText('1 change') // A page that exists only on the branch cannot conflict with main. - await expect(dialog.getByText(/Both sides changed|Deleted on one side/)).toHaveCount(0) - await expect(dialog.getByText('Branch Page')).toBeVisible() + await expect(page.getByText(/Both sides changed|Deleted on one side/)).toHaveCount(0) + await expect(page.getByTestId('branch-review')).toContainText('Branch Page') await page.waitForTimeout(400) await shot(page, '8-merge-review', 'full') - await page.getByTestId('branch-merge-apply').click() + await page.getByTestId('review-merge').click() await completeStepUp(page) - await expect(dialog).toBeHidden() + await expect(page).toHaveURL(/\/admin\/site/) // The branch was deleted after merging, so the tab is back on main … await expect(page.getByTestId('branch-strip')).toHaveCount(0) // … where the page now exists. From 3b7baafdc185fea3bf330a880817da8bfd2c54b5 Mon Sep 17 00:00:00 2001 From: DavidBabinec <hello@davidbabinec.com> Date: Thu, 3 Sep 2026 19:11:40 +0200 Subject: [PATCH 04/16] fix(branches): harden the merge review after its first review pass - Plan reads redact rows the reader cannot see; file path collisions become conflicts; render responses are text/plain with a sandbox CSP. - One open request per branch (partial unique index), ISO timestamps, closed states behave the same for everyone. - Page rows written outside the editor parse through parsePageNode, so the collab seeder never meets a node without its maps (the 'change reverted' storm), and the review compares nodes as the editor loads them; node labels come from the editor name. - Review frames re-measure on mark changes without touching refs in render; the layout is imported through its barrel (one chunk). --- docs/e2e/feature-matrix.md | 3 +- docs/e2e/feature-validation.tsv | 3 +- docs/features/branches.md | 2 +- server/branches/changeDetail.ts | 25 ++++- server/branches/merge.ts | 57 +++++++++-- server/branches/review.ts | 40 +++++--- server/db/migrations-pg.ts | 4 + server/db/migrations-sqlite.ts | 4 + server/handlers/cms/branches.ts | 48 ++++++++- server/repositories/branchReviews.ts | 33 ++++--- src/__tests__/server/branchReview.test.ts | 98 ++++++++++++++++++- src/admin/pages/branches/BranchReviewPage.tsx | 28 ++++-- src/admin/pages/branches/PageCompare.tsx | 22 ++++- src/core/data/pageFromRow.ts | 24 ++++- tests/e2e/branch-review.e2e.ts | 23 +++-- 15 files changed, 343 insertions(+), 71 deletions(-) diff --git a/docs/e2e/feature-matrix.md b/docs/e2e/feature-matrix.md index 236d54227..8673dcfaf 100644 --- a/docs/e2e/feature-matrix.md +++ b/docs/e2e/feature-matrix.md @@ -142,7 +142,8 @@ Page management note: `page-management.e2e.ts` creates disposable pages from the | BRANCH-002 | P1 | ✅ | Branches | Switch branches by search and from the command palette | Owner logged in, a branch exists | Chip search + Enter; ⌘K "Switch to main" | Enter switches to the first match; the palette command returns to main | stale palette results, switch without remount | | BRANCH-003 | P1 | ✅ | Branches | Rename and delete a branch from the manage dialog | Fresh login (step-up) | Chip → Manage branches… | Search narrows the list and clearing it restores every branch; inline rename updates the row; delete confirms, steps up, and removes the branch | delete without step-up, rename lost | | BRANCH-004 | P1 | ✅ | Branches | Share a preview link and open it as a visitor | Fresh login (step-up for cleanup) | Strip → Share preview; visitor context opens the URL | The visitor sees the branch draft with the "Previewing branch" banner and can exit; revoking kills the link | banner missing, link still works after revoke, main content shown | -| BRANCH-005 | P1 | ✅ | Branches | Merge a branch into main from the review dialog | Fresh login (step-up) | Strip → Merge into main… | The plan lists the branch-only page as New, merging steps up, the branch is deleted, and the page exists on main | empty plan while the relay still holds the edit, merge without step-up | +| BRANCH-005 | P1 | ✅ | Branches | Merge a branch into main from the review page | Fresh login (step-up) | Strip → Merge into main… | The review page lists the branch-only page as new, merging steps up, the branch is deleted, and the page exists on main | empty plan while the relay still holds the edit, merge without step-up | +| REVIEW-001 | P1 | ✅ | Branches | Merge review across two accounts | Owner + a site editor without `site.branches.manage` | Editor edits on the branch, opens the review, comments, requests; owner sees the conflict, declines with a note; editor re-requests; owner resolves and merges with step-up | Before/after page renders with the changed and added nodes outlined, threads per change, decline note, merge lands the branch title on main, audit shows every step | request without merge rights merging, conflict merged without a decision, comments lost across a decline | | VERSION-001 | P1 | ✅ | Versions | Restore a published version of the active page | Fresh login (step-up for publish) | Publish menu → Version history… | The page's first version is listed as Latest; Restore asks for confirmation, then the draft is replaced and a toast confirms | empty list after publish, restore publishing instead of drafting | BRANCH-001 … BRANCH-005 note: `tests/e2e/branches.e2e.ts` drives the real toolbar chip, palette, in-place creator, context strip, manage dialog, preview-link visitor flow (a second browser context without an admin session), and the merge review dialog; every step captures evidence under `.tmp/evidence/branches-*.png`. Tests that step up run on a fresh login because step-up rotates the shared owner session. diff --git a/docs/e2e/feature-validation.tsv b/docs/e2e/feature-validation.tsv index 5d1674692..29c1cab4b 100644 --- a/docs/e2e/feature-validation.tsv +++ b/docs/e2e/feature-validation.tsv @@ -154,5 +154,6 @@ BRANCH-001 Create and switch site branches from the toolbar As an editor, I want BRANCH-002 Search-to-switch and palette branch commands As an editor, I want to reach any branch by typing its name so switching is instant. Typing in the chip palette filters branches and Enter switches to the first match; the ⌘K palette offers Switch to main and Switch to <branch>. No match offers Create <slug>… to managers only. Palette rows come from the branch registry; commands are gated on site.read. src/admin/spotlight/commands/branches.ts; src/admin/spotlight/providers/branchesProvider.ts The chip refreshes the registry when opened. Happy: type spring, Enter, then ⌘K Switch to main. Passing 2026-09-02 0 None 2026-09-02 BRANCH-003 Manage branches dialog As a manager, I want to rename or delete branches in one place. The dialog lists branches with inline rename; delete confirms, steps up, and removes the branch and its content. Main cannot be renamed or deleted; deleting the active branch returns the tab to main. Requires site.branches.manage; delete requires step-up. src/admin/shared/BranchSwitcher/ManageBranchesDialog.tsx; server/branches/deleteBranch.ts Runs on a fresh login because step-up rotates the session. Happy: rename to Spring 2027, delete with step-up. Passing 2026-09-02 0 None Evidence: .tmp/evidence/branches-5-manage.png 2026-09-02 BRANCH-004 Branch preview links As an editor, I want to share a link that shows a branch draft to someone without an admin account. Share preview issues a tokenised link; opening it sets a cookie and renders the branch draft with a banner and an exit link; revoking kills the link. Sharing again rotates the token; a dead token clears the cookie; a route missing on the branch falls through to the 404 page. Tokens are stored hashed; the cookie is HttpOnly SameSite=Lax; responses are no-store and noindex. server/branches/previewLinks.ts; server/publish/branchPreview.ts; server/publish/publicRoutes.ts A second browser context plays the visitor. Happy: share, open as visitor, exit, revoke, open again. Passing 2026-09-02 0 None Evidence: .tmp/evidence/branches-6-preview-shared.png, branches-7-visitor-preview.png 2026-09-02 -BRANCH-005 Merge a branch into main As a manager, I want to review what a branch changes and land it on main. The review dialog plans a three-way merge (New/Changed/Removed, conflicts with a two-way choice); applying steps up, writes main's draft, and optionally deletes the branch. Unresolved conflicts keep the button disabled and answer 409 server-side; a change that landed after the plan is re-planned on apply. Row status never merges; bases move to the merged result; requires site.branches.manage + step-up. server/branches/merge.ts; src/core/branches/threeWayMerge.ts; src/admin/shared/BranchSwitcher/MergeBranchDialog.tsx The relay is flushed before planning so live edits count. Happy: create Branch Page on the branch, merge with delete, find the page on main. Passing 2026-09-02 0 None Evidence: .tmp/evidence/branches-8-merge-review.png 2026-09-02 +BRANCH-005 Merge a branch into main As a manager, I want to review what a branch changes and land it on main. The review page plans a three-way merge (one timeline node per change, conflicts with a two-way choice); merging steps up, writes main's draft, and optionally deletes the branch. Unresolved conflicts keep the button disabled and answer 409 server-side; a change that landed after the plan is re-planned on apply. Row status never merges; bases move to the merged result; requires site.branches.manage + step-up. server/branches/merge.ts; src/core/branches/threeWayMerge.ts; src/admin/pages/branches/BranchReviewPage.tsx The relay is flushed before planning so live edits count. Happy: create Branch Page on the branch, merge with delete, find the page on main. Passing 2026-09-02 0 None Evidence: .tmp/evidence/branches-8-merge-review.png 2026-09-02 VERSION-001 Page version history and restore As an editor, I want to see the published versions of a page and bring one back into the draft. The publish menu opens Version history; versions list newest first with the live one marked; Restore confirms inline and replaces the draft on the active branch. Unpublished pages show an empty state; restoring never publishes. Requires row edit access; restore is audited as version.restore. src/admin/shared/VersionHistoryDialog/; server/handlers/cms/data/rows.ts; tests/e2e/version-history.e2e.ts Publish steps up, so the spec runs on a fresh login. Happy: publish, open history, restore version 1. Passing 2026-09-02 0 None 2026-09-02 +REVIEW-001 Merge review across accounts As a site editor, I want to ask for a merge and discuss each change, and as a branch manager I want to read before/after renders, resolve conflicts, decline with a note, or merge. /admin/branches/:id/review shows one timeline node per change: pages as before/after frames with the changed nodes outlined from the tree diff, entries as field tables, files as line diffs, each with a comment thread; requests open/close with the branch; merging steps up. A declined request can be re-requested; a stale request is flagged; unresolved conflicts keep merge disabled and answer 409; rows of content tables the reader cannot open are withheld. Request and comment need site.read; decline and merge need site.branches.manage; one open request per branch (unique index); audit events for every step. server/branches/review.ts; server/branches/changeDetail.ts; server/publish/branchReviewRender.ts; src/admin/pages/branches/ The relay is flushed before the plan and the request hash so live edits count. Happy: editor edits, requests; owner conflicts, comments, declines; editor re-requests; owner resolves, merges with delete. Passing 2026-09-03 0 None Evidence: .tmp/evidence/branch-review-*.png 2026-09-03 diff --git a/docs/features/branches.md b/docs/features/branches.md index 13698f81b..85826ed28 100644 --- a/docs/features/branches.md +++ b/docs/features/branches.md @@ -143,7 +143,7 @@ Both directions run `planBranchMerge(db, branchId, direction)` over three snapsh Row content is `{ tableId, cells, slug }` — **never `status`**: a merge changes drafts, not what is live. The site content is `{ name, shell }` without id, timestamps, **or files**; every site file is an entity of its own (`file:<file id>`, content = the file minus id and timestamps), so two people editing different files never conflict and a file conflict names the file. The merged shell is re-validated with `validateSite` before it is saved; file writes read the shell, replace or drop the one file, and save it back. Table content is the schema fields. -Every planned change carries `detail` (`server/branches/changeDetail.ts`, schema `MergeChangeDetailSchema`): changed fields as display text with `before` (the receiving side) and `after`, a node-level tree diff (`added` / `changed` / `removed` ids with labels) for rows whose `body` is a node tree, the schema field statuses for a table, and both texts for a file. It is computed from the same projections the merge compares, so the review never disagrees with the plan. +Every planned change carries `detail` (`server/branches/changeDetail.ts`, schema `MergeChangeDetailSchema`): changed fields as display text with `before` (the receiving side) and `after`, a node-level tree diff (`added` / `changed` / `removed` ids with labels) for rows whose `body` is a node tree — two nodes compare as the editor would load them (`parsePageNode`, children excluded), so a row written through the data API with empty or missing style maps does not read as changed; the label is the node's editor name when it has one, else its module — the schema field statuses for a table, and both texts for a file. It is computed from the same projections the merge compares, so the review never disagrees with the plan. `applyBranchMerge` flushes the relay, re-plans against live data (an unresolved conflict aborts before any write), then in one transaction writes each result to `into`. A **merge** also mirrors the result onto the branch so both sides agree afterwards, and the base becomes the result. An **update** never writes main: the branch takes the result and the base becomes main's content as of the update, so the branch's own changes stay pending. Entities identical on both sides whose base is stale move their base forward too, so a later edit on one side is not reported as a conflict. Row writes use the repositories with `collabInternal`; after commit the row/shell write notifications fire (open editors reset and reload) and, when main received rows, the `content.entry.*` plugin hooks fire for them. Table creates run before rows and restore a soft-deleted table under the same id; table deletes run last and abort the merge (`MergeApplyError`) when the table still has rows. diff --git a/server/branches/changeDetail.ts b/server/branches/changeDetail.ts index 38b7ea628..b81c203d4 100644 --- a/server/branches/changeDetail.ts +++ b/server/branches/changeDetail.ts @@ -9,6 +9,7 @@ * (`from`) — main and the branch for a merge, the other way round for an * update. */ +import { parsePageNode } from '@core/page-tree' import { canonicalJson } from '@core/utils/canonicalJson' import type { MergeChangeDetail, @@ -57,27 +58,45 @@ function fieldChanges( if (canonicalJson(a ?? null) === canonicalJson(b ?? null)) continue const shownBefore = displayValue(a) const shownAfter = displayValue(b) + const path = `${options.prefix}${key}` out.push({ id: key, label: options.labels?.[key] ?? key, before: shownBefore.text, after: shownAfter.text, structured: shownBefore.structured || shownAfter.structured, - conflict: options.conflicts.has(`${options.prefix}${key}`), + // A conflict deeper inside a structured value still belongs to this field. + conflict: [...options.conflicts].some((conflict) => conflict === path || conflict.startsWith(`${path}.`)), }) } return out } +/** + * What "the same node" means for the diff: the node as the editor would load + * it, minus its children (a child list change is the child's own add/remove). + * Rows written outside the editor (the data API, an import) may store `{}` + * maps or omit them; parsing both sides first keeps those from counting as + * changes. + */ function nodeSignature(node: unknown): string { if (!isRecord(node)) return canonicalJson(node ?? null) - const { children: _children, ...rest } = node + const { children: _children, ...rest } = normalizeNode(node) return canonicalJson(rest) } +function normalizeNode(node: Record<string, unknown>): Record<string, unknown> { + try { + return parsePageNode(node, 'node') + } catch { + // A node the editor could not load is compared as stored. + return node + } +} + function nodeLabel(node: unknown): string { if (!isRecord(node)) return 'node' - if (typeof node.name === 'string' && node.name.trim()) return node.name.trim() + if (typeof node.label === 'string' && node.label.trim()) return node.label.trim() if (typeof node.moduleId === 'string') return node.moduleId.replace(/^base\./, '') return 'node' } diff --git a/server/branches/merge.ts b/server/branches/merge.ts index 845ea93f5..9c2b5ddb5 100644 --- a/server/branches/merge.ts +++ b/server/branches/merge.ts @@ -27,7 +27,8 @@ import { type MergePlan, type MergeResolution, } from '@core/branches' -import { validateSite } from '@core/persistence/validate' +import { SiteValidationError, validateSite } from '@core/persistence/validate' +import { normalizePath } from '@core/files/pathValidation' import type { SiteFile } from '@core/files/schemas' import type { DbClient } from '../db/client' import { MAIN_SCOPE, isMainScope, type BranchScope } from './scope' @@ -103,6 +104,26 @@ export class MergeApplyError extends Error { } const DELETED_MARKER = '(deleted)' +/** A file whose path another file (a different id) already uses on the receiving side. */ +const PATH_MARKER = '(path)' + +/** + * The shell keeps one file per normalized path (first wins), so a merged + * file that lands on a path a different file already holds would vanish + * silently. Report it on the plan instead; applying it is refused. + */ +function pathCollision(entry: Work, into: Map<string, BranchEntity>): boolean { + if (entry.change.kind !== 'file' || entry.result === null) return false + const incoming = entry.result as { path?: unknown } + if (typeof incoming.path !== 'string') return false + const path = normalizePath(incoming.path) + for (const entity of into.values()) { + if (entity.kind !== 'file' || entity.logicalId === entry.change.logicalId) continue + const other = entity.content as { path?: unknown } + if (typeof other.path === 'string' && normalizePath(other.path) === path) return true + } + return false +} function scopesFor(branchId: string, direction: MergeDirection): { from: BranchScope; into: BranchScope } { const branch: BranchScope = { branchId } @@ -207,6 +228,11 @@ export async function planBranchMerge( work.push({ change: describe(theirs, 'update', merged.conflicts, ours, theirs), ours, theirs, result: merged.value }) } + for (const entry of work) { + if (pathCollision(entry, intoEntities) && !entry.change.conflicts.includes(PATH_MARKER)) { + entry.change.conflicts.push(PATH_MARKER) + } + } work.sort((a, b) => changeOrder(a.change) - changeOrder(b.change) || a.change.label.localeCompare(b.change.label)) const changes = work.map((entry) => entry.change) return { @@ -224,6 +250,16 @@ export async function planBranchMerge( } } +/** A merged shell that fails validation is a refused change, not a crash. */ +function validateMergedShell(key: string, candidate: unknown): ReturnType<typeof validateSite> { + try { + return validateSite(candidate) + } catch (err) { + if (err instanceof SiteValidationError) throw new MergeApplyError(key, `The merged site is invalid: ${err.message}`) + throw err + } +} + function resolvedResult(entry: Work, resolutions: Readonly<Record<string, MergeResolution>>): unknown | null { if (entry.change.conflicts.length === 0) return entry.result const resolution = resolutions[entry.change.key] @@ -266,7 +302,7 @@ async function writeEntity( const content = parseContent(SiteContentSchema, result, 'site') // The merged shell is rebuilt from stored JSON — validate it as a whole // before it becomes the draft, exactly like the relay's projection. - const shell = validateSite({ + const shell = validateMergedShell(key, { ...current, ...content.shell, id: current.id, @@ -290,14 +326,17 @@ async function writeEntity( } else { const content = parseContent(FileContentSchema, result, 'file') const existing = current.files.find((file) => file.id === logicalId) - files = [ - ...others, - existing - ? { ...existing, ...content, updatedAt: now } - : { id: logicalId, ...content, createdAt: now, updatedAt: now }, - ] + // The merged content is the whole file: a key the other side dropped + // (blob, ejected, …) must not survive from the previous version. + files = [...others, { id: logicalId, createdAt: existing?.createdAt ?? now, ...content, updatedAt: now }] + } + const shell = validateMergedShell(key, { ...current, files, updatedAt: now }) + if (shell.files.length < files.length) { + throw new MergeApplyError( + key, + `Another file on ${scope.branchId} already uses the path "${entry.change.label}"; rename one of them first`, + ) } - const shell = validateSite({ ...current, files, updatedAt: now }) await saveDraftSite(tx, scope, shell, actorUserId, { collabInternal: true }) notices.shell = true return diff --git a/server/branches/review.ts b/server/branches/review.ts index 4dc0876c3..8219c5edf 100644 --- a/server/branches/review.ts +++ b/server/branches/review.ts @@ -11,14 +11,15 @@ import type { BranchMergeRequest, BranchReviewComment, BranchReviewState, SiteBr import { createHash } from 'node:crypto' import type { DbClient } from '../db/client' import { contentHash } from './contentHash' +import { runPublishFlush } from '../publish/publishFlush' import { collectBranchEntities } from './entities' import { + closeOpenMergeRequests, getLatestMergeRequest, getOpenMergeRequest, insertMergeRequest, insertReviewComment, listReviewComments, - resolveMergeRequest, } from '../repositories/branchReviews' export class MergeRequestAlreadyOpenError extends Error { @@ -49,6 +50,8 @@ export async function branchContentHash(db: DbClient, branchId: string): Promise } export async function readBranchReviewState(db: DbClient, branch: SiteBranch): Promise<BranchReviewState> { + // Same reason as the plan: the hash must see what the editors see. + await runPublishFlush() const [request, comments, hash] = await Promise.all([ getLatestMergeRequest(db, branch.id), listReviewComments(db, branch.id), @@ -57,17 +60,32 @@ export async function readBranchReviewState(db: DbClient, branch: SiteBranch): P return { branch, request, comments, contentHash: hash } } +/** True when the error is the partial unique index on open requests firing. */ +function isOpenRequestUniqueViolation(err: unknown): boolean { + const message = err instanceof Error ? err.message : '' + return /site_branch_merge_requests_open_idx|unique constraint failed: site_branch_merge_requests/i.test(message) +} + export async function openMergeRequest( db: DbClient, input: { branchId: string; requestedByUserId: string; note: string }, ): Promise<BranchMergeRequest> { if (await getOpenMergeRequest(db, input.branchId)) throw new MergeRequestAlreadyOpenError() - return insertMergeRequest(db, { - branchId: input.branchId, - requestedByUserId: input.requestedByUserId, - note: input.note.trim(), - contentHash: await branchContentHash(db, input.branchId), - }) + // Live editors hold edits in the relay's debounce window; the hash the + // request records must cover them. + await runPublishFlush() + try { + return await insertMergeRequest(db, { + branchId: input.branchId, + requestedByUserId: input.requestedByUserId, + note: input.note.trim(), + contentHash: await branchContentHash(db, input.branchId), + }) + } catch (err) { + // Two requests raced past the check above; the index keeps one. + if (isOpenRequestUniqueViolation(err)) throw new MergeRequestAlreadyOpenError() + throw err + } } export async function closeMergeRequest( @@ -75,9 +93,7 @@ export async function closeMergeRequest( branchId: string, input: { status: 'declined' | 'merged' | 'withdrawn'; resolvedByUserId: string | null; note: string }, ): Promise<BranchMergeRequest> { - const open = await getOpenMergeRequest(db, branchId) - if (!open) throw new NoOpenMergeRequestError() - const closed = await resolveMergeRequest(db, open.id, { + const closed = await closeOpenMergeRequests(db, branchId, { status: input.status, resolvedByUserId: input.resolvedByUserId, resolutionNote: input.note.trim(), @@ -92,9 +108,7 @@ export async function markMergeRequestMerged( branchId: string, resolvedByUserId: string | null, ): Promise<BranchMergeRequest | null> { - const open = await getOpenMergeRequest(db, branchId) - if (!open) return null - return resolveMergeRequest(db, open.id, { status: 'merged', resolvedByUserId, resolutionNote: '' }) + return closeOpenMergeRequests(db, branchId, { status: 'merged', resolvedByUserId, resolutionNote: '' }) } export async function addReviewComment( diff --git a/server/db/migrations-pg.ts b/server/db/migrations-pg.ts index 7b679459e..dc86ab225 100644 --- a/server/db/migrations-pg.ts +++ b/server/db/migrations-pg.ts @@ -1311,6 +1311,10 @@ export const pgMigrations: Migration[] = [ create index if not exists site_branch_merge_requests_branch_idx on site_branch_merge_requests (branch_id, status, created_at desc); + create unique index if not exists site_branch_merge_requests_open_idx + on site_branch_merge_requests (branch_id) + where status = 'open'; + create table if not exists site_branch_review_comments ( id text primary key, branch_id text not null references site_branches(id) on delete cascade, diff --git a/server/db/migrations-sqlite.ts b/server/db/migrations-sqlite.ts index 6263d6348..00ec07382 100644 --- a/server/db/migrations-sqlite.ts +++ b/server/db/migrations-sqlite.ts @@ -1395,6 +1395,10 @@ export const sqliteMigrations: Migration[] = [ create index if not exists site_branch_merge_requests_branch_idx on site_branch_merge_requests (branch_id, status, created_at desc); + create unique index if not exists site_branch_merge_requests_open_idx + on site_branch_merge_requests (branch_id) + where status = 'open'; + create table if not exists site_branch_review_comments ( id text primary key, branch_id text not null references site_branches(id) on delete cascade, diff --git a/server/handlers/cms/branches.ts b/server/handlers/cms/branches.ts index 47fc10763..5635b6535 100644 --- a/server/handlers/cms/branches.ts +++ b/server/handlers/cms/branches.ts @@ -50,6 +50,11 @@ import { import { renderBranchReviewPage } from '../../publish/branchReviewRender' import { getOpenMergeRequest } from '../../repositories/branchReviews' import { userHasCapability } from '../../auth/authz' +import { canReadTable } from './data/access' +import { listDataTables } from '../../repositories/data' +import { MAIN_SCOPE } from '../../branches/scope' +import type { AuthUser } from '../../repositories/users' +import type { MergePlan } from '@core/branches' import type { DbClient } from '../../db/client' import type { BranchScope } from '../../branches/scope' import { forkBranch } from '../../branches/fork' @@ -144,7 +149,36 @@ async function handleMergePlan( // the review shows exactly what people see on the canvas. await runPublishFlush() const { plan } = await planBranchMerge(db, branchId, direction) - return jsonResponse({ plan }) + return jsonResponse({ plan: await redactPlanForReader(db, plan, user) }) +} + +/** + * Pages, components and layouts ARE the site: whoever may read the site + * (`site.read`, the canvas viewer) sees them. Every other table follows the + * data workspace's gate (`canReadTable`: `posts` needs + * `data.system.tables.read`, custom tables `data.custom.tables.read`). Rows + * the reader may not open stay in the plan as a stub — the key and kind so + * counts add up and a manager's resolution still addresses them — with the + * label and detail withheld. + */ +const SITE_TABLES = new Set(['pages', 'components', 'layouts']) + +async function redactPlanForReader(db: DbClient, plan: MergePlan, user: AuthUser): Promise<MergePlan> { + const gated = plan.changes.filter((change) => change.kind === 'row' && change.tableId !== null && !SITE_TABLES.has(change.tableId)) + if (gated.length === 0) return plan + const tables = new Map<string, { system: boolean }>() + for (const scope of [MAIN_SCOPE, { branchId: plan.branchId }]) { + for (const table of await listDataTables(db, scope)) tables.set(table.id, table) + } + return { + ...plan, + changes: plan.changes.map((change) => { + if (change.kind !== 'row' || change.tableId === null || SITE_TABLES.has(change.tableId)) return change + const table = tables.get(change.tableId) + if (table && canReadTable(user, table)) return change + return { ...change, label: 'A row you cannot read', detail: { kind: 'row', fields: [], tree: null } } + }), + } } async function handleMergeApply( @@ -298,6 +332,7 @@ async function handleReviewRequest(req: Request, db: DbClient, branchId: string) async function handleReviewWithdraw(req: Request, db: DbClient, branchId: string): Promise<Response> { const user = await requireCapability(req, db, 'site.read') if (user instanceof Response) return user + if (isMainBranch(branchId)) return badRequest('Main is the live site; it is what branches merge into') const branch = await getBranch(db, branchId) if (!branch) return branchNotFound(branchId) const open = await getOpenMergeRequest(db, branchId) @@ -327,6 +362,7 @@ async function handleReviewWithdraw(req: Request, db: DbClient, branchId: string async function handleReviewDecline(req: Request, db: DbClient, branchId: string): Promise<Response> { const user = await requireCapability(req, db, 'site.branches.manage') if (user instanceof Response) return user + if (isMainBranch(branchId)) return badRequest('Main is the live site; it is what branches merge into') const branch = await getBranch(db, branchId) if (!branch) return branchNotFound(branchId) const body = await readValidatedBody(req, DeclineMergeRequestBodySchema) @@ -384,15 +420,19 @@ async function handleReviewRender(req: Request, db: DbClient, branchId: string, if (!rowId || (side !== 'main' && side !== 'branch')) { return badRequest('Pass ?row=<page row id>&side=main|branch') } + // Pages are the site: `site.read` (the canvas viewer) is the whole gate. const html = await renderBranchReviewPage(db, branchId, side, rowId) if (html === null) return jsonResponse({ error: `No page "${rowId}" on ${side}` }, { status: 404 }) + // Served as text and sandboxed: the review reads it and hands it to a + // scriptless srcdoc frame; navigated to directly it is never a page that + // runs with the admin session. return new Response(html, { headers: { - 'content-type': 'text/html; charset=utf-8', + 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store', 'x-robots-tag': 'noindex', - // The review embeds this in a sandboxed iframe of its own origin only. - 'content-security-policy': "frame-ancestors 'self'", + 'content-security-policy': 'sandbox', + 'x-content-type-options': 'nosniff', }, }) } diff --git a/server/repositories/branchReviews.ts b/server/repositories/branchReviews.ts index 8d3bab74b..17cb02639 100644 --- a/server/repositories/branchReviews.ts +++ b/server/repositories/branchReviews.ts @@ -141,34 +141,45 @@ export async function insertMergeRequest( input: { branchId: string; requestedByUserId: string; note: string; contentHash: string }, ): Promise<BranchMergeRequest> { const id = sortableId() + const now = new Date().toISOString() await db` - insert into site_branch_merge_requests (id, branch_id, requested_by_user_id, note, content_hash, status) - values (${id}, ${input.branchId}, ${input.requestedByUserId}, ${input.note}, ${input.contentHash}, 'open') + insert into site_branch_merge_requests (id, branch_id, requested_by_user_id, note, content_hash, status, created_at, updated_at) + values (${id}, ${input.branchId}, ${input.requestedByUserId}, ${input.note}, ${input.contentHash}, 'open', ${now}, ${now}) ` const request = await getMergeRequestById(db, id) if (!request) throw new Error('[branches] merge request vanished after insert') return request } -/** Close the request; returns null when it is not open any more. */ -export async function resolveMergeRequest( +/** + * Close every open request on the branch (there should be one; a race that + * produced two must not leave a stray one open). Returns the newest closed + * request, or null when none was open. + */ +export async function closeOpenMergeRequests( db: DbClient, - id: string, + branchId: string, input: { status: Exclude<MergeRequestStatus, 'open'>; resolvedByUserId: string | null; resolutionNote: string }, ): Promise<BranchMergeRequest | null> { + // Bound as ISO text from here: SQLite's `current_timestamp` is a space- + // separated local-time string that `Date.parse` reads as local time. + const now = new Date().toISOString() const { rows } = await db<{ id: string }>` update site_branch_merge_requests set status = ${input.status}, resolved_by_user_id = ${input.resolvedByUserId}, - resolved_at = current_timestamp, + resolved_at = ${now}, resolution_note = ${input.resolutionNote}, - updated_at = current_timestamp - where id = ${id} + updated_at = ${now} + where branch_id = ${branchId} and status = 'open' returning id ` if (rows.length === 0) return null - return getMergeRequestById(db, id) + const closed = await Promise.all(rows.map((row) => getMergeRequestById(db, row.id))) + return closed + .filter((request): request is BranchMergeRequest => request !== null) + .sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))[0] ?? null } function mapComment(row: CommentRow): BranchReviewComment { @@ -211,8 +222,8 @@ export async function insertReviewComment( ): Promise<BranchReviewComment> { const id = sortableId() await db` - insert into site_branch_review_comments (id, branch_id, request_id, entity_key, author_user_id, body) - values (${id}, ${input.branchId}, ${input.requestId}, ${input.entityKey}, ${input.authorUserId}, ${input.body}) + insert into site_branch_review_comments (id, branch_id, request_id, entity_key, author_user_id, body, created_at) + values (${id}, ${input.branchId}, ${input.requestId}, ${input.entityKey}, ${input.authorUserId}, ${input.body}, ${new Date().toISOString()}) ` const [comment] = await selectComments(db, `where c.id = ${placeholder(db.dialect, 1)} limit 1`, [id]) if (!comment) throw new Error('[branches] review comment vanished after insert') diff --git a/src/__tests__/server/branchReview.test.ts b/src/__tests__/server/branchReview.test.ts index 2aa8d41c9..5b4773df5 100644 --- a/src/__tests__/server/branchReview.test.ts +++ b/src/__tests__/server/branchReview.test.ts @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it } from 'bun:test' import { MAIN_SCOPE } from '../../../server/branches/scope' import { applyBranchMerge, planBranchMerge } from '../../../server/branches/merge' -import { getDataRow, listDataRows, saveDataRowDraft } from '../../../server/repositories/data' +import { getDataRow, listDataRows, saveDataRowDraft, upsertDataRowDraft } from '../../../server/repositories/data' import { getDraftSite, saveDraftSite } from '../../../server/repositories/site' import { createCapabilityTestHarness, @@ -108,18 +108,42 @@ describe('merge review', () => { it('describes a page change as fields plus a node-level tree diff', async () => { harness = await createCapabilityTestHarness() const owner = await harness.setupOwner() + // A node written outside the editor (no style maps at all) lives on main + // before the fork; the branch stores it with the maps the editor adds. + const shapeOnlyNodeId = 'review-shape-node' + const [seed] = await listDataRows(harness.db, MAIN_SCOPE, 'pages') + const seedBody = seed!.cells.body as { nodes: Record<string, Record<string, unknown>>; rootNodeId: string } + await saveDataRowDraft(harness.db, MAIN_SCOPE, seed!.id, { + cells: { + ...seed!.cells, + body: { + ...seedBody, + nodes: { + ...seedBody.nodes, + [shapeOnlyNodeId]: { id: shapeOnlyNodeId, moduleId: 'base.text', props: { text: 'Stays' }, children: [] }, + }, + }, + }, + slug: seed!.slug, + }) const branchId = await forkViaApi(harness, owner, 'Home copy') const branch = { branchId } const [home] = await listDataRows(harness.db, branch, 'pages') const body = home!.cells.body as { nodes: Record<string, Record<string, unknown>>; rootNodeId: string } - const nodeIds = Object.keys(body.nodes) + const nodeIds = Object.keys(body.nodes).filter((id) => id !== shapeOnlyNodeId) expect(nodeIds.length).toBeGreaterThan(0) const changedNodeId = nodeIds[nodeIds.length - 1]! const addedNodeId = 'review-added-node' const nextNodes = { ...body.nodes, - [changedNodeId]: { ...body.nodes[changedNodeId]!, props: { ...(body.nodes[changedNodeId]!.props as object), reviewed: true } }, + // Same node as the editor would load it: empty maps are not a change. + [shapeOnlyNodeId]: { ...body.nodes[shapeOnlyNodeId]!, inlineStyles: {}, breakpointOverrides: {}, classIds: [] }, + [changedNodeId]: { + ...body.nodes[changedNodeId]!, + label: 'Reviewed block', + props: { ...(body.nodes[changedNodeId]!.props as object), reviewed: true }, + }, [addedNodeId]: { id: addedNodeId, moduleId: 'base.text', props: { text: 'Added on the branch' }, children: [] }, } await saveDataRowDraft(harness.db, branch, home!.id, { @@ -137,9 +161,11 @@ describe('merge review', () => { // The tree is not shown as a JSON blob; it is a node diff. expect(change.detail.fields.some((field) => field.id === 'body')).toBe(false) expect(change.detail.tree).not.toBeNull() - expect(change.detail.tree!.changed).toContain(changedNodeId) + expect(change.detail.tree!.changed).toEqual([changedNodeId]) expect(change.detail.tree!.added).toEqual([addedNodeId]) expect(change.detail.tree!.removed).toEqual([]) + // Labels: the editor's node name when there is one, else the module. + expect(change.detail.tree!.labels[changedNodeId]).toBe('Reviewed block') expect(change.detail.tree!.labels[addedNodeId]).toBe('text') } }) @@ -221,6 +247,66 @@ describe('merge review', () => { const after = await readJson<BranchReviewState>(await harness.cms(review, { cookie: stepped })) expect(after.request?.status).toBe('merged') expect(after.request?.resolvedBy?.email).toBeDefined() + // Timestamps are UTC ISO strings on both dialects (not SQLite's local-time text). + const resolvedAt = Date.parse(after.request!.resolvedAt!) + expect(Math.abs(Date.now() - resolvedAt)).toBeLessThan(60_000) + expect(after.request!.resolvedAt).toMatch(/Z$/) + }) + + it('withholds rows of content tables the reader may not open, while pages stay readable', async () => { + harness = await createCapabilityTestHarness() + const owner = await harness.setupOwner() + // site.read only: can review, but has no data-table read capability at all. + const reader = await harness.createRoleUser({ name: 'Site reader', slug: 'site-reader', capabilities: ['site.read'] }) + const branchId = await forkViaApi(harness, owner, 'Redacted') + const branch = { branchId } + const [home] = await listDataRows(harness.db, branch, 'pages') + await saveDataRowDraft(harness.db, branch, home!.id, { cells: { ...home!.cells, title: 'Secret title' }, slug: home!.slug }) + await upsertDataRowDraft(harness.db, branch, { + id: 'secret-post', + tableId: 'posts', + cells: { title: 'Secret post', slug: 'secret-post' }, + slug: 'secret-post', + }) + + const { plan } = await readJson<{ plan: MergePlan }>(await harness.cms(`${BRANCHES}/${branchId}/merge`, { cookie: reader.cookie })) + expect(plan.changes).toHaveLength(2) + // Pages are the site: readable with site.read, rendered too. + const page = plan.changes.find((change) => change.logicalId === home!.id)! + expect(page.label).toBe('Secret title') + const render = await harness.cms(`${BRANCHES}/${branchId}/review/render?row=${encodeURIComponent(home!.id)}&side=branch`, { cookie: reader.cookie }) + expect(render.status).toBe(200) + // Posts follow the data workspace's gate: withheld from a site.read-only reader. + const post = plan.changes.find((change) => change.logicalId === 'secret-post')! + expect(post.label).toBe('A row you cannot read') + expect(post.detail).toEqual({ kind: 'row', fields: [], tree: null }) + expect(JSON.stringify(plan)).not.toContain('Secret post') + + // The owner sees everything. + const full = await readJson<{ plan: MergePlan }>(await harness.cms(`${BRANCHES}/${branchId}/merge`, { cookie: owner })) + expect(full.plan.changes.map((change) => change.label)).toEqual(expect.arrayContaining(['Secret title', 'Secret post'])) + }) + + it('refuses to merge a file whose path another file already uses, instead of dropping it', async () => { + harness = await createCapabilityTestHarness() + const owner = await harness.setupOwner() + const branchId = await forkViaApi(harness, owner, 'Collide') + const branch = { branchId } + // Main and the branch each add a file at the same path under different ids. + const mainShell = (await getDraftSite(harness.db, MAIN_SCOPE))! + await saveDraftSite(harness.db, MAIN_SCOPE, { ...mainShell, files: [...mainShell.files, themeFile('main', { id: 'file-main' })] }) + const branchShell = (await getDraftSite(harness.db, branch))! + await saveDraftSite(harness.db, branch, { ...branchShell, files: [...branchShell.files, themeFile('branch', { id: 'file-branch' })] }) + + const { plan } = await planBranchMerge(harness.db, branchId, 'merge') + const incoming = plan.changes.find((change) => change.key === 'file:file-branch')! + expect(incoming.action).toBe('create') + expect(incoming.conflicts).toEqual(['(path)']) + // Keeping main's file is a valid decision; taking the branch's is refused, never silently lost. + await expect( + applyBranchMerge(harness.db, { branchId, direction: 'merge', resolutions: { 'file:file-branch': 'from' }, actorUserId: null }), + ).rejects.toThrow(/already uses the path/) + expect((await getDraftSite(harness.db, MAIN_SCOPE))!.files.filter((file) => file.path === 'src/styles/theme.css')).toHaveLength(1) }) it('lets a reader load the plan and renders a page for either side with node ids', async () => { @@ -248,7 +334,9 @@ describe('merge review', () => { const render = `${BRANCHES}/${branchId}/review/render` const branchSide = await harness.cms(`${render}?row=${encodeURIComponent(home!.id)}&side=branch`, { cookie: editor.cookie }) expect(branchSide.status).toBe(200) - expect(branchSide.headers.get('content-type')).toContain('text/html') + // Served as text so a direct navigation never runs it as a page. + expect(branchSide.headers.get('content-type')).toContain('text/plain') + expect(branchSide.headers.get('content-security-policy')).toBe('sandbox') expect(branchSide.headers.get('cache-control')).toBe('no-store') const branchHtml = await branchSide.text() expect(branchHtml).toContain('<title>Rendered on the branch') diff --git a/src/admin/pages/branches/BranchReviewPage.tsx b/src/admin/pages/branches/BranchReviewPage.tsx index 239f4b75a..97ecb4b81 100644 --- a/src/admin/pages/branches/BranchReviewPage.tsx +++ b/src/admin/pages/branches/BranchReviewPage.tsx @@ -13,7 +13,7 @@ import { useEffect, useState } from 'react' import { MAIN_BRANCH_ID, type MergeChange, type MergeResolution, type ReviewUserLabel } from '@core/branches' import { getErrorMessage } from '@core/utils/errorMessage' -import { AdminWorkspaceCanvasLayout } from '@admin/layouts/AdminWorkspaceCanvasLayout/AdminWorkspaceCanvasLayout' +import { AdminWorkspaceCanvasLayout } from '@admin/layouts/AdminWorkspaceCanvasLayout' import { useNavigate, useParams } from '@admin/lib/routing' import { hasCapability } from '@admin/access' import { useAuthenticatedAdminUser } from '@admin/sessionContext' @@ -287,6 +287,20 @@ function Review({ branchId, branchName }: ReviewProps) { ) : ( <p className={styles.cardEmpty}>No note.</p> )} + {!open && plan.changes.length > 0 && ( + <div className={styles.requestEmpty}> + <span> + {request.status === 'merged' + ? 'That request was merged. The changes below are new work since.' + : 'That request is closed. Request a merge again when the branch is ready.'} + </span> + <div> + <Button variant="secondary" size="sm" type="button" onClick={() => setDialog('request')} data-testid="review-request-open"> + Request merge… + </Button> + </div> + </div> + )} </> ) : ( <> @@ -382,7 +396,7 @@ function Review({ branchId, branchName }: ReviewProps) { branchId={branchId} change={change} resolution={resolutions[change.key]} - canResolve={canManage && (request === null || open)} + canResolve={canManage} onResolve={(resolution) => setResolutions((current) => ({ ...current, [change.key]: resolution }))} /> )} @@ -522,14 +536,12 @@ function Review({ branchId, branchName }: ReviewProps) { {request?.status === 'declined' ? 'Fix what the note asks for, then request a merge again.' : request?.status === 'merged' - ? 'This branch was merged.' + ? 'The last request was merged. New work on this branch can be requested again.' : 'Request a merge when the branch is ready for review.'} </span> - {request?.status !== 'merged' && ( - <Button variant="primary" size="sm" type="button" disabled={busy} onClick={() => setDialog('request')} data-testid="review-request-open"> - {request?.status === 'declined' ? 'Request merge again…' : 'Request merge…'} - </Button> - )} + <Button variant="primary" size="sm" type="button" disabled={busy || plan.changes.length === 0} tooltip={plan.changes.length === 0 ? 'Nothing to merge yet' : undefined} onClick={() => setDialog('request')} data-testid="review-request-open"> + {request?.status === 'declined' ? 'Request merge again…' : 'Request merge…'} + </Button> </> )} </footer> diff --git a/src/admin/pages/branches/PageCompare.tsx b/src/admin/pages/branches/PageCompare.tsx index a9244b57e..c549648b8 100644 --- a/src/admin/pages/branches/PageCompare.tsx +++ b/src/admin/pages/branches/PageCompare.tsx @@ -8,7 +8,7 @@ * from the tree diff, not from guesses. Side by side, a swipe with one * frame clipped over the other, or the plain change list. */ -import { useEffect, useRef, useState, type CSSProperties } from 'react' +import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react' import type { MergeTreeDiff, ReviewRenderSide } from '@core/branches' import { apiTextRequest, isAbortError } from '@core/http' import { cmsBranchReviewRenderUrl } from '@core/persistence' @@ -79,14 +79,21 @@ function ScaledFrame({ branchId, rowId, side, title, marks, showHighlights }: Fr return () => observer.disconnect() }, []) - function measure(): void { + // The marks come from the parent's plan; `measure` reads them through a + // ref so it can stay a stable callback (it is an effect dependency below — + // React Compiler exception 1). + const marksRef = useRef(marks) + useEffect(() => { + marksRef.current = marks + }) + const measure = useCallback((): void => { const frame = frameRef.current const doc = frame?.contentDocument if (!frame || !doc?.documentElement) return const height = Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, doc.documentElement.scrollHeight)) setDocHeight(height) const next: HighlightBox[] = [] - for (const mark of marks) { + for (const mark of marksRef.current) { const element = doc.querySelector(`[uid="${CSS.escape(mark.id)}"]`) if (!(element instanceof doc.defaultView!.HTMLElement)) continue const rect = element.getBoundingClientRect() @@ -103,7 +110,14 @@ function ScaledFrame({ branchId, rowId, side, title, marks, showHighlights }: Fr } setBoxes(next) setLoaded(true) - } + }, []) + + // Marks come from the plan; a reload can change them while the HTML is + // the same, so the boxes follow the marks, not only the frame's load. + const marksKey = marks.map((mark) => `${mark.tone}:${mark.id}`).join('|') + useEffect(() => { + if (loaded) measure() + }, [marksKey, loaded, measure]) const hostStyle = { '--frame-scale': scale, '--frame-h': `${docHeight * scale}px` } as CSSProperties const stageStyle = { '--doc-h': `${docHeight}px` } as CSSProperties diff --git a/src/core/data/pageFromRow.ts b/src/core/data/pageFromRow.ts index aff3b658a..479fdbe59 100644 --- a/src/core/data/pageFromRow.ts +++ b/src/core/data/pageFromRow.ts @@ -19,7 +19,7 @@ */ import type { Page, PageNode, PageTemplateConfig } from '@core/page-tree' -import { parsePageTemplate } from '@core/page-tree' +import { parsePageNode, parsePageTemplate } from '@core/page-tree' import type { DataRow, DataRowCells } from '@core/data/schemas' // --------------------------------------------------------------------------- @@ -38,13 +38,20 @@ export function pageFromRow(row: DataRow): Page { const cells = row.cells // body field: NodeTree<PageNode> { nodes: {...}, rootNodeId: '...' } - let nodes: Record<string, PageNode> = {} + const nodes: Record<string, PageNode> = {} let rootNodeId = '' const body = cells.body if (body && typeof body === 'object' && !Array.isArray(body)) { const b = body as Record<string, unknown> if (b.nodes && typeof b.nodes === 'object' && !Array.isArray(b.nodes)) { - nodes = b.nodes as Record<string, PageNode> + // Rows can be written outside the editor (the data API, an import), so + // every node goes through the tolerant parser: missing maps become + // empty, a node without an id or module is dropped, and the collab + // seeder never meets a shape it cannot build. + for (const [id, raw] of Object.entries(b.nodes as Record<string, unknown>)) { + const node = parseNode(id, raw) + if (node) nodes[id] = node + } } if (typeof b.rootNodeId === 'string') { rootNodeId = b.rootNodeId @@ -69,6 +76,17 @@ export function pageFromRow(row: DataRow): Page { } } +function parseNode(id: string, raw: unknown): PageNode | null { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null + try { + return parsePageNode({ id, ...(raw as Record<string, unknown>) }, `nodes.${id}`) + } catch { + // A node the parser cannot make sense of is dropped, not crashed on: + // validatePages reports the structural problem where it can be fixed. + return null + } +} + function readTemplateFromCells(cells: DataRowCells): PageTemplateConfig | null { if (cells.templateEnabled !== true) return null return parsePageTemplate({ diff --git a/tests/e2e/branch-review.e2e.ts b/tests/e2e/branch-review.e2e.ts index ae58ed54c..1201d341f 100644 --- a/tests/e2e/branch-review.e2e.ts +++ b/tests/e2e/branch-review.e2e.ts @@ -59,6 +59,11 @@ async function api<T>(page: Page, path: string, init: { method?: string; body?: }, { path, init }) } +/** A page node shaped the way the editor writes one (the maps are never absent). */ +function node(id: string, moduleId: string, props: Record<string, unknown>, children: string[] = [], label?: string) { + return { id, moduleId, props, children, breakpointOverrides: {}, classIds: [], ...(label ? { label } : {}) } +} + interface HomeRow { id: string slug: string @@ -68,7 +73,8 @@ interface HomeRow { async function homeRow(page: Page, branch?: string): Promise<HomeRow> { const { status, body } = await api<{ rows: HomeRow[] }>(page, '/admin/api/cms/pages', { branch }) expect(status).toBe(200) - const home = body.rows.find((row) => row.slug === '' || row.slug === 'home') ?? body.rows[0] + // The seeded home page; never fall back to whichever row sorts first. + const home = body.rows.find((row) => row.slug === 'index') expect(home).toBeDefined() return home! } @@ -138,10 +144,10 @@ test('owner prepares a branch and an editor without merge rights', async ({ page const nodes = { ...home.cells.body.nodes, [root]: { ...home.cells.body.nodes[root]!, children: ['review-hero'] }, - 'review-hero': { id: 'review-hero', moduleId: 'base.container', props: {}, children: ['review-heading', 'review-copy', 'review-cta'] }, - 'review-heading': { id: 'review-heading', moduleId: 'base.text', props: { text: 'Ship your site faster', tag: 'h1' }, children: [] }, - 'review-copy': { id: 'review-copy', moduleId: 'base.text', props: { text: 'A self-hosted CMS with a visual editor and a plugin system that runs in a sandbox.', tag: 'p' }, children: [] }, - 'review-cta': { id: 'review-cta', moduleId: 'base.button', props: { label: 'Get started', href: '/pricing' }, children: [] }, + 'review-hero': node('review-hero', 'base.container', {}, ['review-heading', 'review-copy', 'review-cta']), + 'review-heading': node('review-heading', 'base.text', { text: 'Ship your site faster', tag: 'h1' }, [], 'Headline'), + 'review-copy': node('review-copy', 'base.text', { text: 'A self-hosted CMS with a visual editor and a plugin system that runs in a sandbox.', tag: 'p' }), + 'review-cta': node('review-cta', 'base.button', { label: 'Get started', href: '/pricing' }), } await saveHome(page, home, { ...home.cells, title: 'Home', body: { ...home.cells.body, nodes } }) @@ -160,7 +166,7 @@ test('the editor edits the branch, reads the review, comments and requests a mer ...home.cells.body.nodes, 'review-heading': { ...heading, props: { ...heading.props, text: 'Launch week starts Monday' } }, 'review-hero': { ...hero, children: [...(hero.children ?? []), 'review-note'] }, - 'review-note': { id: 'review-note', moduleId: 'base.text', props: { text: 'Five features in five days, starting with branches.', tag: 'p' }, children: [] }, + 'review-note': node('review-note', 'base.text', { text: 'Five features in five days, starting with branches.', tag: 'p' }), } await saveHome(page, home, { ...home.cells, title: BRANCH_TITLE, body: { ...home.cells.body, nodes } }, BRANCH_ID) @@ -180,8 +186,9 @@ test('the editor edits the branch, reads the review, comments and requests a mer // render, found by their node ids. await expect(homeChange.locator('[data-tone="changed"]')).toHaveCount(1) await expect(homeChange.locator('[data-tone="added"]')).toHaveCount(1) - // Labels name the node the plan diffed (a text node here). - await expect(homeChange.locator('[data-tone="changed"]')).toContainText('text') + // The badge carries the node's editor name; a bare module id stays hidden. + await expect(homeChange.locator('[data-tone="changed"]')).toContainText('Headline') + await expect(homeChange.locator('[data-tone="added"]')).toHaveText('Added') await shot(page, '1-editor-review') await page.getByTestId(`review-thread-row:${home.id}-input`).fill('New headline for launch week; the rest of the page is untouched.') From 414f4c9781d00e0ed79459472ff134aeb409e1df Mon Sep 17 00:00:00 2001 From: DavidBabinec <hello@davidbabinec.com> Date: Fri, 4 Sep 2026 11:13:19 +0200 Subject: [PATCH 05/16] refactor(branches): review page in the house tile style - Rows of borderless surface tiles on the workspace canvas, card radius, 1px-gap tile groups instead of dividers; state and kind badges are TagPills; the facts sit in one row on wide screens. - TagPill gains a state tone (success, warning, danger) that keeps the gradient tint but colours it from the semantic tokens. - The E2E home seed is exact, so a reused database cannot skew the diff. --- docs/features/branches.md | 12 +- docs/reference/design-tokens.md | 2 +- docs/reference/ui-primitives.md | 2 +- src/__tests__/ui/tagPill.test.tsx | 9 + .../branches/BranchReviewPage.module.css | 379 +++++++++++------- src/admin/pages/branches/BranchReviewPage.tsx | 183 ++++----- src/admin/pages/branches/PageCompare.tsx | 2 +- src/admin/pages/branches/ReviewChangeCard.tsx | 19 +- src/admin/pages/branches/ReviewThread.tsx | 32 +- src/admin/pages/branches/reviewFormat.ts | 8 +- src/ui/components/TagPill/TagPill.module.css | 16 + src/ui/components/TagPill/TagPill.tsx | 7 +- src/ui/components/TagPill/index.ts | 2 +- tests/e2e/branch-review.e2e.ts | 2 +- 14 files changed, 390 insertions(+), 285 deletions(-) diff --git a/docs/features/branches.md b/docs/features/branches.md index 85826ed28..3f75d8549 100644 --- a/docs/features/branches.md +++ b/docs/features/branches.md @@ -15,7 +15,7 @@ main with a three-way review. Publishing only ever happens on main. - **Publish and schedule are disabled on a branch** with the reason inline (`useBranchPublishGate`); the server answers `409` if a request slips through. - **Preview links** (`/_instatic/preview/<token>`) set an HttpOnly cookie; while it names a live link, every public GET renders the branch's draft with a banner (`server/publish/branchPreview.ts`). - **Merge** (branch → main) and **Update** (main → branch) are the same three-way merge over `site_branch_bases` (`server/branches/merge.ts`): field-level where both sides moved different fields, a reviewer decision where they moved the same one. An update never writes main. -- **Merge review** (`/admin/branches/:id/review`) is where merging happens: one timeline node per planned change — pages as before/after renders with the changed nodes outlined, entries as field tables, tables as schemas, files as line diffs — each with its own comment thread; anyone with `site.read` can ask for a merge, comment, and read the plan, a branch manager declines or merges from the page's footer. +- **Merge review** (`/admin/branches/:id/review`) is where merging happens: one row of tiles per planned change, its comment thread beside it — pages as before/after renders with the changed nodes outlined, entries as field tables, tables as schemas, files as line diffs — each with its own comment thread; anyone with `site.read` can ask for a merge, comment, and read the plan, a branch manager declines or merges from the page's footer. - **Version history** lists a row's published versions and restores one into the draft on the active branch (`data_row_versions`, `GET/POST …/data/rows/:id/versions`). - Capability: `site.branches.manage` (Owner, Admin). Audit: `branch.*`, `version.restore`. @@ -55,7 +55,7 @@ server/publish/publicRoutes.ts dispatcher tail: preview link, public route, 4 src/admin/state/branchStore.ts active branch, registry, switcher UI state, publish gate src/admin/shared/BranchSwitcher/ chip + palette, context strip, manage / delete / update dialogs -src/admin/pages/branches/ the merge review page (timeline, page compare, threads) +src/admin/pages/branches/ the merge review page (rows of tiles, page compare, threads) src/admin/shared/VersionHistoryDialog/ published versions + restore src/admin/spotlight/commands/branches.ts Switch / Create / Manage / Switch to main src/admin/spotlight/providers/branchesProvider.ts "Switch to <branch>" rows @@ -153,11 +153,11 @@ Endpoints: `GET|POST /admin/api/cms/branches/:id/merge` and `…/update`. `GET` ## Merge review -`/admin/branches/:id/review` (`src/admin/pages/branches/BranchReviewPage.tsx`, workspace `branchReview`, gated by `site.read`) is the review. Opening it switches the tab to the branch. It loads the merge plan and the review state together and renders one timeline: +`/admin/branches/:id/review` (`src/admin/pages/branches/BranchReviewPage.tsx`, workspace `branchReview`, gated by `site.read`) is the review. Opening it switches the tab to the branch. It loads the merge plan and the review state together and renders rows of tiles on the workspace canvas (house surfaces, no borders; a thread tile beside what it discusses): -- **The request node** — the open or last merge request (who, note, status pill: *Awaiting review* / *Changes requested* / *Merged* / *Withdrawn*), with the general conversation beside it and a facts grid (changes by kind, conflicts left, freshness, what merging does). Without a request it offers *Request merge…*. -- **One node per change**, marked `A` / `M` / `D` on the line, with a thread box on the left (comments keyed by the change's `key`, an always-present composer) and the change on the right: a page (`row` in the `pages` table) as **before/after frames**, an entry as a field table, a table as its schema, the shell as a settings table, a file as a line diff (`@core/utils/lineDiff`). A change with conflicts carries a strip with *Keep main* / *Take branch* (managers only). -- **The decision node** — the decline note, the merge outcome, or the wait. +- **The request row** — the open or last merge request (who, note, state badge: *Awaiting review* / *Changes requested* / *Merged* / *Withdrawn*, a `TagPill` with a state `tone`), with the general conversation beside it and a row of fact tiles (changes by kind, conflicts left, freshness, what merging does). Without a request it offers *Request merge…*. +- **One row per change**, its kind (*Page*, *Entry*, *Table*, *File*) and action (*new* / *changed* / *removed*) as badges in the card head, with the thread tile on the left (comments keyed by the change's `key`, an always-present composer) and the change on the right: a page (`row` in the `pages` table) as **before/after frames**, an entry as a field table, a table as its schema, the shell as a settings table, a file as a line diff (`@core/utils/lineDiff`). A change with conflicts carries a strip with *Keep main* / *Take branch* (managers only). +- **The decision row** — the decline note, the merge outcome, or the wait. - **The footer** — managers: *Delete branch after merging*, *Decline…* (open request only; a note is required) and *Merge N changes*, disabled with the count while conflicts are undecided; the merge runs the existing step-up-gated `POST …/merge`. Requesters: *Withdraw request*; everyone else: *Request merge…*. Page frames: `GET /admin/api/cms/branches/:id/review/render?row=<page row id>&side=main|branch` (`site.read`) returns the page's HTML composed like the branch preview (template chain, draft loops, inlined CSS, `dynamicNodes: 'inline'`) with `annotateNodeIds` on, so every node's root element carries `uid="<node id>"`; no runtime scripts are bundled. The page fetches it through `apiTextRequest` and hands it to an `<iframe sandbox="allow-same-origin">` as `srcdoc` (scripts stay off). After load, the frame is measured and the nodes the plan's tree diff lists are found by `uid` and outlined in place — highlights come from the diff, never from guesses. Modes: side by side, swipe (one frame clipped over the other), and the plain change list. diff --git a/docs/reference/design-tokens.md b/docs/reference/design-tokens.md index a87948ed4..f30955ee2 100644 --- a/docs/reference/design-tokens.md +++ b/docs/reference/design-tokens.md @@ -184,7 +184,7 @@ Each accent also has a standard 10% tint for soft backgrounds: `--accent-1-10`, `TagPill` maps the first meaningful alphanumeric character of its label to a stable numbered accent. This keeps selector punctuation from driving the color while giving class names, HTML tags, and badges enough visual variety without -creating a second tag-specific tint scale. +creating a second tag-specific tint scale. A `tone` (`success`, `warning`, `danger`) replaces the categorical accent with the matching state token — the same gradient tint, coloured by state — for badges that report a status rather than an identity (the merge review's request state, a change's action). --- diff --git a/docs/reference/ui-primitives.md b/docs/reference/ui-primitives.md index d9baf0906..17bb50e99 100644 --- a/docs/reference/ui-primitives.md +++ b/docs/reference/ui-primitives.md @@ -75,7 +75,7 @@ Every interactive control in `src/admin/` goes through one of these. Bare `<butt | Primitive | When to use | Key props | |----------------------------|--------------------------------------------------------------|----------------------------------------------------------| | `DataTable` | Token-backed table shell; caller owns rows, sorting, and selection | `density`, `wrapperClassName`; compose `DataTableHead`, `DataTableBody`, `DataTableRow`, `DataTableHeader`, `DataTableCell` | -| `TagPill` | Compact tinted labels, selector chips, removable tag pills | `label`, `active`, `muted`, `size: 'xs' \| 'sm'`, `monospace`, `leading` (ReactNode prefix slot), `colorKey`, `onClick`, `onRemove`, `onContextMenu`, `mainAriaLabel`, `removeAriaLabel`, `removeTooltip` | +| `TagPill` | Compact tinted labels, selector chips, removable tag pills | `label`, `active`, `muted`, `size: 'xs' \| 'sm'`, `monospace`, `leading` (ReactNode prefix slot), `colorKey`, `tone: 'success' \| 'warning' \| 'danger'` (a semantic state instead of the label's accent), `onClick`, `onRemove`, `onContextMenu`, `mainAriaLabel`, `removeAriaLabel`, `removeTooltip` | | `Heading` | Semantic h1-h6 using editor typography tokens | `level`, `children` | | `Text` | Body, muted, strong, or monospace text in host/plugin UI | `variant`, `size`, `children` | | `Code` | Preformatted snippets or logs | `children` | diff --git a/src/__tests__/ui/tagPill.test.tsx b/src/__tests__/ui/tagPill.test.tsx index eff8dbc42..6fe7c4556 100644 --- a/src/__tests__/ui/tagPill.test.tsx +++ b/src/__tests__/ui/tagPill.test.tsx @@ -7,6 +7,15 @@ import { pillAccent } from '@ui/pillAccent' afterEach(cleanup) describe('TagPill', () => { + it('colours a state tone from the semantic token instead of the label accent', () => { + render(<TagPill label="Awaiting review" tone="warning" />) + + const pill = screen.getByText('Awaiting review').closest('[data-tone]') as HTMLElement + + expect(pill.getAttribute('data-tone')).toBe('warning') + expect(pill.style.getPropertyValue('--pill-accent')).toBe('var(--warning)') + }) + it('renders a read-only tinted label from the label text', () => { render(<TagPill label=".alpha" />) diff --git a/src/admin/pages/branches/BranchReviewPage.module.css b/src/admin/pages/branches/BranchReviewPage.module.css index e7901a1df..90dd7d3e5 100644 --- a/src/admin/pages/branches/BranchReviewPage.module.css +++ b/src/admin/pages/branches/BranchReviewPage.module.css @@ -1,4 +1,10 @@ -/* BranchReviewPage — the merge review as a timeline of changes. */ +/* BranchReviewPage — the merge review as rows of tiles on the workspace canvas. + * + * House surface model: the canvas is `--bg-surface`; every card is a + * `--bg-surface-2` tile with the card radius and no border; tiles that + * belong together sit in a group whose 1px gap shows the canvas through + * (the dashboard's widget grid). Inside a tile, sub-blocks lift to + * `--bg-surface-3` or a state tint with the panel radius — never a line. */ .canvas { display: flex; @@ -8,26 +14,27 @@ height: 100%; min-height: 0; overflow: hidden; - background: var(--bg-surface-2); - border-top-left-radius: 16px; - border-top-right-radius: 16px; + background: var(--bg-surface); + border-top-left-radius: var(--card-radius); + border-top-right-radius: var(--card-radius); } .page { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; } .scroll { flex: 1; min-height: 0; overflow: auto; } +/* ---------- header ---------- */ + .header { - display: flex; - flex-direction: column; - gap: var(--space-xs); - padding: var(--space-l) var(--space-xl) var(--space-m); - border-bottom: 1px solid var(--border-subtle); + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: var(--space-2xl) var(--space-4xl); + padding: var(--space-4xl) var(--space-4xl) var(--space-2xl); } +.headerMain { display: grid; gap: var(--space-s); min-width: 0; } + .eyebrow { - display: flex; - align-items: center; - gap: var(--space-s); font-size: var(--text-2xs); font-weight: 700; letter-spacing: 0.06em; @@ -35,251 +42,315 @@ color: var(--text-subtle); } -.title { margin: 0; font-size: var(--text-xl); font-weight: 600; color: var(--text); } +.title { + margin: 0; + font-size: var(--text-4xl); + font-weight: 600; + line-height: 1.15; + letter-spacing: 0; + color: var(--text-bright); + text-wrap: balance; +} .meta { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-xs) var(--space-m); - font-size: var(--text-xs); + font-size: var(--text-s); color: var(--text-muted); } .meta strong { color: var(--text); font-weight: 600; } -.filters { margin-top: var(--space-xs); } +.filters { min-width: 0; } .filterCount { margin-left: var(--space-2xs); font-family: var(--font-mono); font-size: var(--text-2xs); opacity: 0.8; } +@media (max-width: 1100px) { + .header { grid-template-columns: minmax(0, 1fr); align-items: start; } +} + +/* ---------- footer ---------- */ + .footer { display: flex; align-items: center; - gap: var(--space-s); - padding: var(--space-s) var(--space-xl); - border-top: 1px solid var(--border-subtle); + gap: var(--space-m); + margin: 0 var(--space-4xl) var(--space-2xl); + padding: var(--space-m) var(--space-2xl); + border-radius: var(--card-radius); background: var(--bg-surface-2); } -.footerStatus { flex: 1; font-size: var(--text-xs); color: var(--text-muted); } -.footerToggle { display: inline-flex; align-items: center; gap: var(--space-xs); font-size: var(--text-xs); color: var(--text-muted); } +.footerStatus { flex: 1; font-size: var(--text-s); color: var(--text-muted); } +.footerToggle { display: inline-flex; align-items: center; gap: var(--space-s); font-size: var(--text-s); color: var(--text-muted); white-space: nowrap; } + +/* ---------- empty / loading ---------- */ .state { display: flex; flex-direction: column; align-items: center; justify-content: center; - gap: var(--space-s); + gap: var(--space-m); height: 100%; - padding: var(--space-xl); + padding: var(--space-6xl); color: var(--text-subtle); font-size: var(--text-s); text-align: center; } -.loading { display: flex; flex-direction: column; gap: var(--space-s); padding: var(--space-xl); } +.loading { display: flex; flex-direction: column; gap: var(--space-s); padding: var(--space-4xl); } -/* ---------- timeline ---------- */ +/* ---------- rows: a thread tile beside the change it discusses ---------- */ -.timeline { display: flex; flex-direction: column; gap: var(--space-xl); padding: var(--space-l) var(--space-xl) var(--space-2xl); } +.timeline { + display: flex; + flex-direction: column; + gap: var(--space-xl); + padding: var(--space-s) var(--space-4xl) var(--space-4xl); +} .node { - position: relative; + --thread-w: 300px; display: grid; - grid-template-columns: 24px 300px minmax(0, 1fr); - gap: var(--space-s) var(--space-m); + grid-template-columns: var(--thread-w) minmax(0, 1fr); + gap: var(--space-xl); align-items: start; } -.node::before { - content: ""; - position: absolute; - left: 11px; - top: 28px; - bottom: calc(-1 * var(--space-xl)); - width: 1px; - background: var(--border-subtle); +@media (min-width: 1600px) { + .node { --thread-w: 340px; } } -.node[data-last="true"]::before { display: none; } +@media (max-width: 1000px) { + .node { grid-template-columns: minmax(0, 1fr); } + .left { position: static; } +} -.marker { - position: sticky; - top: var(--space-m); - z-index: 1; - width: 24px; - height: 24px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 999px; - background: var(--bg-surface-2); +.left { position: sticky; top: var(--space-xl); min-width: 0; } +.right { min-width: 0; display: grid; gap: var(--space-xl); } + +.spacer { flex: 1; } +.mono { font-family: var(--font-mono); } +.add { color: var(--success-text); } +.del { color: var(--danger-text); margin-left: var(--space-2xs); } +.fileCounts { font-family: var(--font-mono); font-size: var(--text-xs); font-variant-numeric: tabular-nums; } +.hint { margin: 0; font-size: var(--text-s); color: var(--text-subtle); line-height: 1.5; } +.statusDot { width: 6px; height: 6px; border-radius: 999px; background: currentColor; } + +/* ---------- tiles ---------- */ + +.tileGroup { + display: grid; + gap: var(--space-px); + min-width: 0; + border-radius: var(--card-radius); + overflow: hidden; } -.left { position: sticky; top: var(--space-m); min-width: 0; padding-top: 2px; } -.right { min-width: 0; } +.tile { min-width: 0; background: var(--bg-surface-2); } -.actionBadge { - width: 20px; - height: 20px; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: var(--radius-sm); - font-family: var(--font-mono); +.facts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-px); } +.facts[data-cols="2"] { grid-template-columns: repeat(2, minmax(0, 1fr)); } + +@media (max-width: 1300px) { + .facts { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +.fact { + display: grid; + align-content: start; + gap: var(--space-2xs); + padding: var(--space-l) var(--space-2xl); + background: var(--bg-surface-2); + font-size: var(--text-s); +} + +.factLabel { font-size: var(--text-2xs); font-weight: 700; - background: var(--bg-surface-4); + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-subtle); +} + +.factValue { color: var(--text); line-height: 1.5; } + +/* ---------- request ---------- */ + +.requestTile { display: grid; gap: var(--space-m); padding: var(--space-xl) var(--space-2xl) var(--space-2xl); } + +.requestHead { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-s); + font-size: var(--text-s); color: var(--text-muted); - flex-shrink: 0; } -.actionBadge[data-action="create"] { background: var(--success-20); color: var(--success-text); } -.actionBadge[data-action="update"] { background: var(--warning-20); color: var(--warning-text); } -.actionBadge[data-action="delete"] { background: var(--danger-20); color: var(--danger-text); } +.requestHead strong { color: var(--text-bright); font-weight: 600; } +.requestNote { margin: 0; font-size: var(--text-m); line-height: 1.6; color: var(--text); white-space: pre-wrap; } -.dot { width: 7px; height: 7px; border-radius: 999px; background: var(--border-strong); outline: 4px solid var(--bg-surface-2); } -.spacer { flex: 1; } -.mono { font-family: var(--font-mono); } -.add { color: var(--success-text); } -.del { color: var(--danger-text); margin-left: var(--space-2xs); } -.fileCounts { font-family: var(--font-mono); font-size: var(--text-2xs); font-variant-numeric: tabular-nums; } -.hint { margin: 0; font-size: var(--text-xs); color: var(--text-subtle); line-height: 1.5; } +.requestEmpty { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-m); + font-size: var(--text-s); + line-height: 1.5; + color: var(--text-muted); +} + +.requestEmpty > span { flex: 1 1 320px; } /* ---------- thread ---------- */ -.thread { display: flex; flex-direction: column; background: var(--bg-surface); border: 1px solid var(--border-subtle); border-radius: var(--radius); overflow: hidden; } +.thread { + display: flex; + flex-direction: column; + min-width: 0; + background: var(--bg-surface-2); + border-radius: var(--card-radius); +} .threadHead { display: flex; align-items: center; - gap: var(--space-xs); - min-height: 34px; - padding: var(--space-xs) var(--space-s); - background: var(--bg-surface-3); - font-size: var(--text-xs); + gap: var(--space-s); + padding: var(--space-xl) var(--space-2xl) var(--space-s); + font-size: var(--text-s); color: var(--text); } -.threadTitle { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-mono); } -.threadCount { color: var(--text-subtle); white-space: nowrap; } - -.threadItem { display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: var(--space-xs); padding: var(--space-s); border-top: 1px solid var(--border-subtle); font-size: var(--text-xs); line-height: 1.5; } -.threadAvatar { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; } -.threadItemHead { display: flex; align-items: baseline; gap: var(--space-xs); color: var(--text-subtle); } +.threadTitle { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; color: var(--text-bright); } +.threadCount { color: var(--text-subtle); white-space: nowrap; font-size: var(--text-xs); } +.threadItems { display: grid; gap: var(--space-m); padding: var(--space-xs) var(--space-2xl) var(--space-s); } +.threadItem { display: grid; grid-template-columns: 22px minmax(0, 1fr); gap: var(--space-s); font-size: var(--text-s); line-height: 1.55; } +.threadAvatar { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; } +.threadItemHead { display: flex; align-items: baseline; gap: var(--space-xs); font-size: var(--text-xs); color: var(--text-subtle); } .threadItemHead strong { color: var(--text); font-weight: 600; } -.threadItemText { margin: 3px 0 0; color: var(--text); white-space: pre-wrap; overflow-wrap: anywhere; } +.threadItemText { margin: 2px 0 0; color: var(--text); white-space: pre-wrap; overflow-wrap: anywhere; } -.threadComposer { display: flex; flex-direction: column; gap: var(--space-xs); padding: var(--space-s); border-top: 1px solid var(--border-subtle); background: var(--bg-surface-2); } -.threadComposerRow { display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: var(--space-xs); align-items: start; } -.threadComposerActions { display: flex; align-items: center; gap: var(--space-xs); padding-left: calc(20px + var(--space-xs)); } +.threadComposer { display: flex; flex-direction: column; gap: var(--space-s); padding: var(--space-s) var(--space-2xl) var(--space-2xl); } +.threadComposerRow { display: grid; grid-template-columns: 22px minmax(0, 1fr); gap: var(--space-s); align-items: start; } +.threadComposerActions { display: flex; align-items: center; gap: var(--space-xs); padding-left: calc(22px + var(--space-s)); } .threadComposerHint { font-size: var(--text-2xs); color: var(--text-subtle); } -.decision { padding: var(--space-s); border-top: 1px solid var(--border-subtle); font-size: var(--text-xs); line-height: 1.5; color: var(--text); } -.decision[data-tone="danger"] { background: var(--danger-5); } +.decision { + margin: 0 var(--space-m) var(--space-m); + padding: var(--space-m) var(--space-l); + border-radius: var(--panel-radius); + background: var(--bg-surface-3); + font-size: var(--text-s); + line-height: 1.55; + color: var(--text); +} + +.decision[data-tone="danger"] { background: var(--danger-10); } .decision[data-tone="success"] { background: var(--success-10); } .decision p { margin: 0; white-space: pre-wrap; } .decisionWho { display: flex; align-items: center; gap: var(--space-xs); margin-bottom: var(--space-xs); color: var(--text-muted); } -.decisionWho strong { color: var(--text); font-weight: 600; } +.decisionWho strong { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); font-weight: 600; } +.decisionWhen { flex-shrink: 0; white-space: nowrap; font-size: var(--text-xs); color: var(--text-subtle); } -/* ---------- cards ---------- */ +/* ---------- change card ---------- */ -.card { background: var(--bg-surface); border: 1px solid var(--border-subtle); border-radius: var(--radius); overflow: hidden; min-width: 0; } +.card { + display: flex; + flex-direction: column; + min-width: 0; + background: var(--bg-surface-2); + border-radius: var(--card-radius); + overflow: hidden; +} .cardHead { display: flex; align-items: center; - gap: var(--space-xs); - min-height: 34px; - padding: var(--space-xs) var(--space-s); - background: var(--bg-surface-3); - font-size: var(--text-xs); + gap: var(--space-s); + padding: var(--space-xl) var(--space-2xl) var(--space-m); + font-size: var(--text-s); color: var(--text-muted); } -.cardHead strong { color: var(--text); font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.cardKind { font-size: var(--text-2xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-subtle); white-space: nowrap; } -.cardPath { font-family: var(--font-mono); color: var(--text-subtle); } -.cardEmpty { margin: 0; padding: var(--space-s); font-size: var(--text-xs); color: var(--text-subtle); } - -.requestNote { margin: 0; padding: var(--space-m); font-size: var(--text-s); line-height: 1.55; color: var(--text); white-space: pre-wrap; } -.requestEmpty { padding: var(--space-m); display: flex; flex-direction: column; gap: var(--space-s); font-size: var(--text-xs); color: var(--text-muted); } - -.facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); border-top: 1px solid var(--border-subtle); } -.fact { display: flex; flex-direction: column; gap: var(--space-2xs); padding: var(--space-s) var(--space-m); font-size: var(--text-xs); border-top: 1px solid var(--border-subtle); } -.fact:nth-child(-n + 2) { border-top: 0; } -.fact:nth-child(odd) { border-right: 1px solid var(--border-subtle); } -.factLabel { font-size: var(--text-2xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-subtle); } -.factValue { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2xs) var(--space-xs); color: var(--text); line-height: 1.5; } - -.statusPill { display: inline-flex; align-items: center; gap: 5px; padding: 1px var(--space-xs); border-radius: 999px; border: 1px solid var(--border); font-size: var(--text-2xs); font-weight: 600; white-space: nowrap; color: var(--text-muted); } -.statusPill::before { content: ""; width: 6px; height: 6px; border-radius: 999px; background: currentColor; } -.statusPill[data-tone="warning"] { color: var(--warning-text); background: var(--warning-10); border-color: var(--warning-30); } -.statusPill[data-tone="danger"] { color: var(--danger-text); background: var(--danger-10); border-color: var(--danger-30); } -.statusPill[data-tone="success"] { color: var(--success-text); background: var(--success-10); border-color: var(--success-30); } - -.conflictStrip { display: flex; align-items: center; gap: var(--space-s); padding: var(--space-xs) var(--space-s); border-bottom: 1px solid var(--warning-30); background: var(--warning-10); color: var(--warning-text); font-size: var(--text-xs); } -.conflictStrip[data-resolved="true"] { border-color: var(--border-subtle); background: var(--bg-surface-2); color: var(--text-muted); } +.cardHead strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--text-m); font-weight: 600; color: var(--text-bright); } +.cardPath { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-subtle); } +.cardBody { display: grid; gap: var(--space-m); padding: 0 var(--space-2xl) var(--space-2xl); } +.cardEmpty { margin: 0; font-size: var(--text-s); color: var(--text-subtle); } + +.conflictStrip { + display: flex; + align-items: center; + gap: var(--space-m); + margin: 0 var(--space-2xl) var(--space-m); + padding: var(--space-m) var(--space-l); + border-radius: var(--panel-radius); + background: var(--warning-10); + color: var(--warning-text); + font-size: var(--text-s); +} + +.conflictStrip[data-resolved="true"] { background: var(--bg-surface-3); color: var(--text-muted); } .conflictText { flex: 1; min-width: 0; line-height: 1.45; } .conflictText strong { font-weight: 600; } /* ---------- page compare ---------- */ -.compareBar { display: flex; align-items: center; gap: var(--space-s); padding: var(--space-xs) var(--space-s); border-bottom: 1px solid var(--border-subtle); font-size: var(--text-xs); color: var(--text-muted); } -.compareToggle { display: inline-flex; align-items: center; gap: var(--space-xs); } -.compareGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-s); padding: var(--space-s); } -.compareGrid[data-single="true"] { grid-template-columns: minmax(0, 1fr); max-width: 720px; } -.compareCol { display: flex; flex-direction: column; gap: var(--space-2xs); min-width: 0; } +.compare { display: grid; gap: var(--space-m); } +.compareBar { display: flex; align-items: center; gap: var(--space-m); font-size: var(--text-xs); color: var(--text-muted); } +.compareToggle { display: inline-flex; align-items: center; gap: var(--space-s); } +.compareGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-l); } +.compareGrid[data-single="true"] { grid-template-columns: minmax(0, 1fr); max-width: 760px; } +.compareCol { display: grid; gap: var(--space-xs); min-width: 0; } .compareLabel { font-size: var(--text-2xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-subtle); } -.frameHost { position: relative; width: 100%; height: var(--frame-h); overflow: hidden; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle); background: var(--bg-surface-5); } -.frameHost[data-loaded="false"] { background: var(--bg-surface-4); } -.frameError { margin: 0; padding: var(--space-m); font-size: var(--text-xs); color: var(--danger-text); } +.frameHost { position: relative; width: 100%; height: var(--frame-h); overflow: hidden; border-radius: var(--panel-radius); background: var(--bg-surface-5); } +.frameHost[data-loaded="false"] { background: var(--bg-surface-3); } +.frameError { margin: 0; padding: var(--space-l); font-size: var(--text-s); color: var(--danger-text); } .frameStage { position: absolute; top: 0; left: 0; width: 1280px; height: var(--doc-h); transform: scale(var(--frame-scale)); transform-origin: top left; } .frame { width: 1280px; height: var(--doc-h); border: 0; display: block; background: var(--bg-surface); pointer-events: none; } -.highlight { position: absolute; left: var(--hl-x); top: var(--hl-y); width: var(--hl-w); height: var(--hl-h); border: 3px solid var(--warning); border-radius: 6px; background: var(--warning-10); pointer-events: none; box-sizing: border-box; } +.highlight { position: absolute; left: var(--hl-x); top: var(--hl-y); width: var(--hl-w); height: var(--hl-h); border: 3px solid var(--warning); border-radius: 8px; background: var(--warning-10); pointer-events: none; box-sizing: border-box; } .highlight[data-tone="added"] { border-color: var(--success); background: var(--success-10); } .highlight[data-tone="removed"] { border-color: var(--danger-light); background: var(--danger-10); } .highlightLabel { position: absolute; right: 6px; top: 6px; padding: 2px 10px; border-radius: 999px; background: var(--warning); color: var(--bg-body); font-size: calc(var(--text-s) * 2); font-weight: 700; white-space: nowrap; opacity: 0.92; } .highlight[data-tone="added"] .highlightLabel { background: var(--success); } .highlight[data-tone="removed"] .highlightLabel { background: var(--danger-light); } -.swipe { padding: var(--space-s); } +.swipe { display: grid; gap: var(--space-xs); } .swipeStack { position: relative; } .swipeTop { position: absolute; inset: 0; clip-path: inset(0 calc(100% - var(--split)) 0 0); } .swipeLine { position: absolute; top: 0; bottom: 0; left: var(--split); width: 2px; background: var(--warning); transform: translateX(-1px); pointer-events: none; } -.swipeTagLeft, .swipeTagRight { position: absolute; top: 6px; padding: 1px 6px; border-radius: 3px; font-size: var(--text-3xs); font-weight: 700; color: var(--bg-body); background: var(--text); pointer-events: none; } -.swipeTagLeft { left: 6px; } -.swipeTagRight { right: 6px; } -.swipeRange { width: 100%; margin-top: var(--space-xs); accent-color: var(--warning); } +.swipeTagLeft, .swipeTagRight { position: absolute; top: 8px; padding: 2px 8px; border-radius: 999px; font-size: var(--text-3xs); font-weight: 700; color: var(--bg-body); background: var(--text); pointer-events: none; } +.swipeTagLeft { left: 8px; } +.swipeTagRight { right: 8px; } +.swipeRange { width: 100%; accent-color: var(--warning); } -.changeList { margin: 0; padding: var(--space-s) var(--space-m) var(--space-s) calc(var(--space-m) + 16px); font-size: var(--text-xs); line-height: 1.6; color: var(--text); } -.changeListEmpty { margin: 0; padding: var(--space-s) var(--space-m); font-size: var(--text-xs); color: var(--text-subtle); } +.changeList { margin: 0; padding: var(--space-m) var(--space-l) var(--space-m) calc(var(--space-l) + 16px); border-radius: var(--panel-radius); background: var(--bg-surface-3); font-size: var(--text-s); line-height: 1.7; color: var(--text); } +.changeListEmpty { margin: 0; font-size: var(--text-s); color: var(--text-subtle); } /* ---------- field / schema / diff ---------- */ -.fieldTable { width: 100%; border-collapse: collapse; font-size: var(--text-xs); table-layout: fixed; } -.fieldTable th { text-align: left; padding: var(--space-xs) var(--space-s); font-size: var(--text-2xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-subtle); border-bottom: 1px solid var(--border-subtle); } -.fieldTable th:first-child { width: 140px; } -.fieldTable td { padding: var(--space-xs) var(--space-s); vertical-align: top; border-bottom: 1px solid var(--border-subtle); line-height: 1.5; overflow-wrap: anywhere; } -.fieldTable tr:last-child td { border-bottom: 0; } +.fieldTable { width: 100%; border-collapse: separate; border-spacing: 0 var(--space-px); font-size: var(--text-s); table-layout: fixed; } +.fieldTable th { text-align: left; padding: 0 var(--space-l) var(--space-2xs); font-size: var(--text-2xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-subtle); } +.fieldTable th:first-child { width: 160px; } +.fieldTable td { padding: var(--space-s) var(--space-l); vertical-align: top; background: var(--bg-surface-3); line-height: 1.5; overflow-wrap: anywhere; } +.fieldTable td:first-child { border-top-left-radius: var(--radius); border-bottom-left-radius: var(--radius); } +.fieldTable td:last-child { border-top-right-radius: var(--radius); border-bottom-right-radius: var(--radius); } .fieldName { color: var(--text-muted); } .fieldRow[data-conflict="true"] .fieldName { color: var(--warning-text); } .cellBefore { background: var(--danger-5); color: var(--text-muted); text-decoration: line-through; text-decoration-color: var(--danger-text); } .cellAfter { background: var(--success-10); } -.cellBefore[data-structured="true"], .cellAfter[data-structured="true"] { font-family: var(--font-mono); font-size: var(--text-2xs); } +.cellBefore[data-structured="true"], .cellAfter[data-structured="true"] { font-family: var(--font-mono); font-size: var(--text-xs); } .cellEmpty { color: var(--text-subtle); font-style: italic; text-decoration: none; } -.schemaList { margin: 0; padding: 0; list-style: none; } -.schemaRow { display: flex; align-items: center; gap: var(--space-s); padding: var(--space-xs) var(--space-s); border-top: 1px solid var(--border-subtle); font-size: var(--text-xs); } -.schemaRow:first-child { border-top: 0; } -.schemaType { font-family: var(--font-mono); color: var(--text-subtle); } -.schemaBadge { margin-left: auto; font-size: var(--text-3xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; padding: 1px 6px; border-radius: 999px; } -.schemaBadge[data-status="new"] { background: var(--success-20); color: var(--success-text); } -.schemaBadge[data-status="changed"] { background: var(--warning-20); color: var(--warning-text); } -.schemaBadge[data-status="removed"] { background: var(--danger-20); color: var(--danger-text); } -.schemaBadge[data-status="same"] { color: var(--text-subtle); } - -.diff { font-family: var(--font-mono); font-size: var(--text-xs); line-height: 1.55; overflow: auto; max-height: 480px; } +.schemaList { display: grid; gap: var(--space-px); margin: 0; padding: 0; list-style: none; } +.schemaRow { display: flex; align-items: center; gap: var(--space-m); padding: var(--space-s) var(--space-l); border-radius: var(--radius); background: var(--bg-surface-3); font-size: var(--text-s); } +.schemaType { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-subtle); } +.schemaStatus { margin-left: auto; } + +.diff { font-family: var(--font-mono); font-size: var(--text-xs); line-height: 1.55; overflow: auto; max-height: 480px; padding: var(--space-s) 0; border-radius: var(--panel-radius); background: var(--bg-surface); } .diffRow { display: grid; grid-template-columns: 34px 34px 16px minmax(0, 1fr); } .diffRow[data-type="add"] { background: var(--success-10); } .diffRow[data-type="del"] { background: var(--danger-5); } @@ -289,5 +360,7 @@ .diffRow[data-type="del"] .diffSign { color: var(--danger-text); } .diffCode { white-space: pre; padding-right: var(--space-s); color: var(--text); } +/* ---------- dialogs ---------- */ + .dialogBody { display: flex; flex-direction: column; gap: var(--space-s); } -.dialogHint { margin: 0; font-size: var(--text-xs); color: var(--text-muted); line-height: 1.5; } +.dialogHint { margin: 0; font-size: var(--text-s); color: var(--text-muted); line-height: 1.5; } diff --git a/src/admin/pages/branches/BranchReviewPage.tsx b/src/admin/pages/branches/BranchReviewPage.tsx index 97ecb4b81..bb4902638 100644 --- a/src/admin/pages/branches/BranchReviewPage.tsx +++ b/src/admin/pages/branches/BranchReviewPage.tsx @@ -2,10 +2,10 @@ * BranchReviewPage — the merge review of one branch * (`/admin/branches/:branchId/review`). * - * One timeline: the merge request opens it (its note, status, and what - * merging grants), then one node per change the plan lists — a page as + * Rows of tiles: the merge request opens the review (its note, status, and + * what merging does), then one row per change the plan lists — a page as * before/after frames, an entry as a field table, a table as its schema, - * a file as a line diff — each with its own comment thread, and the + * a file as a line diff — each beside its own comment thread, and the * decision closes it. Conflicts are decided in place; the footer's merge * stays disabled until every one has a side. Merging runs the same * step-up-gated apply the branch strip used to run from a dialog. @@ -26,14 +26,13 @@ import { FilterBar } from '@ui/components/FilterBar' import { Textarea } from '@ui/components/Input' import { Skeleton } from '@ui/components/Skeleton' import { Switch } from '@ui/components/Switch' +import { TagPill } from '@ui/components/TagPill' import { pushToast } from '@ui/components/Toast' import { CheckIcon } from 'pixel-art-icons/icons/check' import { GitMergeSolidIcon } from 'pixel-art-icons/icons/git-merge-solid' import { ReviewChangeCard } from './ReviewChangeCard' import { ReviewThread } from './ReviewThread' import { - ACTION_LETTER, - ACTION_WORD, FILTER_LABELS, REQUEST_ENTITY_KEY, REVIEW_FILTERS, @@ -215,26 +214,24 @@ function Review({ branchId, branchName }: ReviewProps) { <div className={styles.page}> <div className={styles.scroll}> <header className={styles.header}> - <div className={styles.eyebrow}> - <span>Merge review</span> - <span>·</span> - <span>{branchName} → main</span> - </div> - <h1 className={styles.title} data-testid="branch-review-title">{title}</h1> - <div className={styles.meta}> - {request ? ( - <span> - <strong>{request.requestedBy?.displayName ?? 'Removed user'}</strong> requested {relativeIso(request.createdAt)} ago - </span> - ) : ( - <span>No merge request yet</span> - )} - <span>{changeCountLabel}</span> - {plan.conflictCount > 0 && ( - <span className={styles.del}>{plan.conflictCount} conflict{plan.conflictCount === 1 ? '' : 's'}</span> - )} - <span>{review.comments.length} comment{review.comments.length === 1 ? '' : 's'}</span> - {request && <StatusPill status={request.status} unresolved={open ? unresolved.length : 0} />} + <div className={styles.headerMain}> + <div className={styles.eyebrow}>Merge review · {branchName} → main</div> + <h1 className={styles.title} data-testid="branch-review-title">{title}</h1> + <div className={styles.meta}> + {request && <StatusPill status={request.status} unresolved={open ? unresolved.length : 0} />} + {request ? ( + <span> + <strong>{request.requestedBy?.displayName ?? 'Removed user'}</strong> requested {relativeIso(request.createdAt)} ago + </span> + ) : ( + <span>No merge request yet</span> + )} + <span>{changeCountLabel}</span> + {plan.conflictCount > 0 && ( + <span className={styles.del}>{plan.conflictCount} conflict{plan.conflictCount === 1 ? '' : 's'}</span> + )} + <span>{review.comments.length} comment{review.comments.length === 1 ? '' : 's'}</span> + </div> </div> <div className={styles.filters}> <FilterBar @@ -259,7 +256,6 @@ function Review({ branchId, branchName }: ReviewProps) { {filter === 'all' && ( <TimelineNode id="review-request" - marker={request?.requestedBy ? <UserAvatar user={request.requestedBy} size={22} /> : <span className={styles.dot} />} left={( <ReviewThread title="Conversation" @@ -271,56 +267,54 @@ function Review({ branchId, branchName }: ReviewProps) { /> )} right={( - <section className={styles.card} data-testid="review-request-card"> - {request ? ( - <> - <div className={styles.cardHead}> - {request.requestedBy && <UserAvatar user={request.requestedBy} size={18} />} - <strong>{request.requestedBy?.displayName ?? 'Removed user'}</strong> - <span>asked to merge {branchName} into main</span> - <span>{relativeIso(request.createdAt)}</span> - <span className={styles.spacer} /> - <StatusPill status={request.status} unresolved={open ? unresolved.length : 0} /> - </div> - {request.note ? ( - <p className={styles.requestNote}>{request.note}</p> - ) : ( - <p className={styles.cardEmpty}>No note.</p> - )} - {!open && plan.changes.length > 0 && ( - <div className={styles.requestEmpty}> - <span> - {request.status === 'merged' - ? 'That request was merged. The changes below are new work since.' - : 'That request is closed. Request a merge again when the branch is ready.'} - </span> - <div> + <section className={styles.tileGroup} data-testid="review-request-card"> + <div className={`${styles.tile} ${styles.requestTile}`}> + {request ? ( + <> + <div className={styles.requestHead}> + {request.requestedBy && <UserAvatar user={request.requestedBy} size={22} />} + <strong>{request.requestedBy?.displayName ?? 'Removed user'}</strong> + <span>asked to merge {branchName} into main</span> + <span>{relativeIso(request.createdAt)}</span> + <span className={styles.spacer} /> + <StatusPill status={request.status} unresolved={open ? unresolved.length : 0} /> + </div> + {request.note ? ( + <p className={styles.requestNote}>{request.note}</p> + ) : ( + <p className={styles.cardEmpty}>No note.</p> + )} + {!open && plan.changes.length > 0 && ( + <div className={styles.requestEmpty}> + <span> + {request.status === 'merged' + ? 'That request was merged. The changes below are new work since.' + : 'That request is closed. Request a merge again when the branch is ready.'} + </span> <Button variant="secondary" size="sm" type="button" onClick={() => setDialog('request')} data-testid="review-request-open"> Request merge… </Button> </div> + )} + </> + ) : ( + <> + <div className={styles.requestHead}> + <strong>No merge request yet</strong> </div> - )} - </> - ) : ( - <> - <div className={styles.cardHead}> - <strong>No merge request yet</strong> - </div> - <div className={styles.requestEmpty}> - <span> - {canManage - ? 'You can merge from the bar below, or ask another manager to review by requesting a merge.' - : 'When the branch is ready, request a merge so a branch manager reviews it.'} - </span> - <div> + <div className={styles.requestEmpty}> + <span> + {canManage + ? 'You can merge from the bar below, or ask another manager to review by requesting a merge.' + : 'When the branch is ready, request a merge so a branch manager reviews it.'} + </span> <Button variant="secondary" size="sm" type="button" onClick={() => setDialog('request')} data-testid="review-request-open"> Request merge… </Button> </div> - </div> - </> - )} + </> + )} + </div> <div className={styles.facts}> <div className={styles.fact}> <span className={styles.factLabel}>Changes</span> @@ -376,11 +370,6 @@ function Review({ branchId, branchName }: ReviewProps) { <TimelineNode key={change.key} id={`review-change-${change.key}`} - marker={( - <span className={styles.actionBadge} data-action={change.action} aria-label={ACTION_WORD[change.action]}> - {ACTION_LETTER[change.action]} - </span> - )} left={( <ReviewThread title={change.kind === 'row' && change.tableName && !isPageChange(change) ? `${change.tableName}: ${change.label}` : change.label} @@ -406,8 +395,6 @@ function Review({ branchId, branchName }: ReviewProps) { {filter === 'all' && request && ( <TimelineNode id="review-decision" - last - marker={request.resolvedBy ? <UserAvatar user={request.resolvedBy} size={22} /> : <span className={styles.dot} />} left={( <div className={styles.thread} data-testid="review-decision"> <div className={styles.threadHead}> @@ -420,7 +407,7 @@ function Review({ branchId, branchName }: ReviewProps) { <div className={styles.decisionWho}> {request.resolvedBy && <UserAvatar user={request.resolvedBy} size={18} />} <strong>{request.resolvedBy?.displayName ?? 'A branch manager'}</strong> - <span>declined · {request.resolvedAt ? relativeIso(request.resolvedAt) : ''}</span> + <span className={styles.decisionWhen}>declined · {request.resolvedAt ? relativeIso(request.resolvedAt) : ''}</span> </div> <p>{request.resolutionNote}</p> </div> @@ -430,7 +417,7 @@ function Review({ branchId, branchName }: ReviewProps) { <div className={styles.decisionWho}> <CheckIcon size={12} aria-hidden="true" /> <strong>{request.resolvedBy?.displayName ?? 'A branch manager'}</strong> - <span>merged · {request.resolvedAt ? relativeIso(request.resolvedAt) : ''}</span> + <span className={styles.decisionWhen}>merged · {request.resolvedAt ? relativeIso(request.resolvedAt) : ''}</span> </div> <p>The changes are in main's draft. Publish main when you are ready.</p> </div> @@ -450,12 +437,9 @@ function Review({ branchId, branchName }: ReviewProps) { </div> )} right={( - <section className={styles.card}> - <div className={styles.cardHead}> - <strong>{open ? 'What the decision does' : 'Outcome'}</strong> - </div> + <section className={styles.tileGroup}> {open ? ( - <div className={styles.facts}> + <div className={styles.facts} data-cols="2"> <div className={styles.fact}> <span className={styles.factLabel}>Merge</span> <span className={styles.factValue}>Writes every change to main's draft, mirrors the result to the branch, and deletes the branch if chosen. Asks for a password.</span> @@ -466,13 +450,16 @@ function Review({ branchId, branchName }: ReviewProps) { </div> </div> ) : ( - <p className={styles.requestNote}> - {request.status === 'declined' - ? 'The branch stays as it is. Fix what the note asks for and request a merge again; every comment above stays with the branch.' - : request.status === 'merged' - ? 'Main’s draft now holds these changes. Publish main to make them live.' - : 'Nothing was merged.'} - </p> + <div className={styles.fact}> + <span className={styles.factLabel}>Outcome</span> + <span className={styles.factValue}> + {request.status === 'declined' + ? 'The branch stays as it is. Fix what the note asks for and request a merge again; every comment above stays with the branch.' + : request.status === 'merged' + ? 'Main’s draft now holds these changes. Publish main to make them live.' + : 'Nothing was merged.'} + </span> + </div> )} </section> )} @@ -600,25 +587,29 @@ function StatusPill({ status, unresolved }: { status: 'open' | 'declined' | 'mer const label = status === 'open' && unresolved > 0 ? `${requestStatusLabel(status)} · ${unresolved} conflict${unresolved === 1 ? '' : 's'}` : requestStatusLabel(status) + const tone = requestStatusTone(status) return ( - <span className={styles.statusPill} data-tone={requestStatusTone(status)} data-testid="review-status"> - {label} - </span> + <TagPill + label={label} + size="xs" + tone={tone ?? undefined} + muted={tone === null} + leading={<span className={styles.statusDot} aria-hidden="true" />} + testId="review-status" + /> ) } interface TimelineNodeProps { id: string - marker: React.ReactNode left: React.ReactNode right: React.ReactNode - last?: boolean } -function TimelineNode({ id, marker, left, right, last = false }: TimelineNodeProps) { +/** One row of the review: the thread tile beside what it discusses. */ +function TimelineNode({ id, left, right }: TimelineNodeProps) { return ( - <div id={id} className={styles.node} data-last={last ? 'true' : 'false'}> - <span className={styles.marker}>{marker}</span> + <div id={id} className={styles.node}> <div className={styles.left}>{left}</div> <div className={styles.right}>{right}</div> </div> diff --git a/src/admin/pages/branches/PageCompare.tsx b/src/admin/pages/branches/PageCompare.tsx index c549648b8..048bcf877 100644 --- a/src/admin/pages/branches/PageCompare.tsx +++ b/src/admin/pages/branches/PageCompare.tsx @@ -205,7 +205,7 @@ export function PageCompare({ branchId, rowId, label, action, tree, fieldLines, const lines = [...fieldLines, ...treeLines] return ( - <div> + <div className={styles.compare}> <div className={styles.compareBar}> <SegmentedControl value={mode} diff --git a/src/admin/pages/branches/ReviewChangeCard.tsx b/src/admin/pages/branches/ReviewChangeCard.tsx index e896b16d1..0c62a6e45 100644 --- a/src/admin/pages/branches/ReviewChangeCard.tsx +++ b/src/admin/pages/branches/ReviewChangeCard.tsx @@ -7,9 +7,10 @@ import type { MergeChange, MergeFieldChange, MergeResolution } from '@core/branches' import { countDiffLines, diffLines } from '@core/utils/lineDiff' import { SegmentedControl } from '@ui/components/SegmentedControl' +import { TagPill } from '@ui/components/TagPill' import { WarningDiamondSolidIcon } from 'pixel-art-icons/icons/warning-diamond-solid' import { PageCompare } from './PageCompare' -import { ACTION_WORD, changeKindLabel, isPageChange } from './reviewFormat' +import { ACTION_TONE, ACTION_WORD, changeKindLabel, isPageChange } from './reviewFormat' import styles from './BranchReviewPage.module.css' interface ReviewChangeCardProps { @@ -98,14 +99,14 @@ export function ReviewChangeCard({ branchId, change, resolution, canResolve, onR const { detail } = change const header = ( <div className={styles.cardHead}> - <span className={styles.cardKind}>{changeKindLabel(change)}</span> + <TagPill label={changeKindLabel(change)} size="xs" /> <strong className={detail.kind === 'file' ? styles.mono : undefined}>{change.label}</strong> {detail.kind === 'file' && detail.pathBefore && ( <span className={styles.cardPath}>was {detail.pathBefore}</span> )} <span className={styles.spacer} /> {detail.kind === 'file' && !detail.binary && <FileCounts before={detail.before ?? ''} after={detail.after ?? ''} />} - <span>{ACTION_WORD[change.action]}</span> + <TagPill label={ACTION_WORD[change.action]} size="xs" tone={ACTION_TONE[change.action]} /> </div> ) @@ -144,9 +145,13 @@ export function ReviewChangeCard({ branchId, change, resolution, canResolve, onR <li key={field.id} className={styles.schemaRow}> <span>{field.label}</span> <span className={styles.schemaType}>{field.type}</span> - <span className={styles.schemaBadge} data-status={field.status}> - {field.status === 'same' ? 'unchanged' : field.status} - </span> + <TagPill + className={styles.schemaStatus} + label={field.status === 'same' ? 'unchanged' : field.status} + size="xs" + muted={field.status === 'same'} + tone={field.status === 'new' ? 'success' : field.status === 'changed' ? 'warning' : field.status === 'removed' ? 'danger' : undefined} + /> </li> ))} </ul> @@ -164,7 +169,7 @@ export function ReviewChangeCard({ branchId, change, resolution, canResolve, onR <section className={styles.card} data-testid={`review-change-${change.key}`}> {header} <ConflictStrip change={change} resolution={resolution} canResolve={canResolve} onResolve={onResolve} /> - {body} + <div className={styles.cardBody}>{body}</div> </section> ) } diff --git a/src/admin/pages/branches/ReviewThread.tsx b/src/admin/pages/branches/ReviewThread.tsx index a561dc7c5..b500ec2f1 100644 --- a/src/admin/pages/branches/ReviewThread.tsx +++ b/src/admin/pages/branches/ReviewThread.tsx @@ -1,6 +1,6 @@ /** * ReviewThread — the comments on one change (or on the request itself): - * a boxed list with an always-present composer. Posting goes through the + * a tile with the comments and an always-present composer. Posting goes through the * review's `comment` action; the list re-renders from the server's copy. */ import { useState, type KeyboardEvent, type ReactNode } from 'react' @@ -59,24 +59,28 @@ export function ReviewThread({ title, comments, me, placeholder, onPost, testId <span className={styles.spacer} /> <span className={styles.threadCount}>{count === 0 ? 'No comments' : `${count} ${count === 1 ? 'comment' : 'comments'}`}</span> </div> - {comments.map((comment) => ( - <div key={comment.id} className={styles.threadItem} data-testid={`${testId}-comment`}> - <span className={styles.threadAvatar}> - {comment.author && <UserAvatar user={comment.author} size={20} />} - </span> - <div> - <div className={styles.threadItemHead}> - <strong>{comment.author?.displayName ?? 'Removed user'}</strong> - <span>{relativeIso(comment.createdAt)}</span> + {count > 0 && ( + <div className={styles.threadItems}> + {comments.map((comment) => ( + <div key={comment.id} className={styles.threadItem} data-testid={`${testId}-comment`}> + <span className={styles.threadAvatar}> + {comment.author && <UserAvatar user={comment.author} size={22} />} + </span> + <div> + <div className={styles.threadItemHead}> + <strong>{comment.author?.displayName ?? 'Removed user'}</strong> + <span>{relativeIso(comment.createdAt)}</span> + </div> + <p className={styles.threadItemText}>{comment.body}</p> + </div> </div> - <p className={styles.threadItemText}>{comment.body}</p> - </div> + ))} </div> - ))} + )} <div className={styles.threadComposer}> <div className={styles.threadComposerRow}> <span className={styles.threadAvatar}> - <UserAvatar user={me} size={20} /> + <UserAvatar user={me} size={22} /> </span> <Textarea fieldSize="sm" diff --git a/src/admin/pages/branches/reviewFormat.ts b/src/admin/pages/branches/reviewFormat.ts index bfa88bae8..5c16d7340 100644 --- a/src/admin/pages/branches/reviewFormat.ts +++ b/src/admin/pages/branches/reviewFormat.ts @@ -4,6 +4,7 @@ */ import type { MergeChange, MergeRequestStatus } from '@core/branches' import { formatRelativeTime } from '@core/utils/relativeTime' +import type { TagPillTone } from '@ui/components/TagPill' export const REVIEW_FILTERS = ['all', 'pages', 'content', 'files', 'conflicts', 'comments'] as const export type ReviewFilter = (typeof REVIEW_FILTERS)[number] @@ -30,7 +31,7 @@ export function changeKindLabel(change: MergeChange): string { return change.tableName ? `Entry · ${change.tableName}` : 'Entry' } -export const ACTION_LETTER: Record<MergeChange['action'], string> = { create: 'A', update: 'M', delete: 'D' } +export const ACTION_TONE: Record<MergeChange['action'], TagPillTone> = { create: 'success', update: 'warning', delete: 'danger' } export const ACTION_WORD: Record<MergeChange['action'], string> = { create: 'new', update: 'changed', delete: 'removed' } export function matchesFilter(change: MergeChange, filter: ReviewFilter, commentCount: number): boolean { @@ -63,7 +64,8 @@ export function requestStatusLabel(status: MergeRequestStatus): string { } } -export function requestStatusTone(status: MergeRequestStatus): 'warning' | 'danger' | 'success' | 'neutral' { +/** The state badge's tone; a withdrawn request is plain (muted). */ +export function requestStatusTone(status: MergeRequestStatus): TagPillTone | null { switch (status) { case 'open': return 'warning' @@ -72,7 +74,7 @@ export function requestStatusTone(status: MergeRequestStatus): 'warning' | 'dang case 'merged': return 'success' case 'withdrawn': - return 'neutral' + return null } } diff --git a/src/ui/components/TagPill/TagPill.module.css b/src/ui/components/TagPill/TagPill.module.css index 7119ed17b..514dc2efc 100644 --- a/src/ui/components/TagPill/TagPill.module.css +++ b/src/ui/components/TagPill/TagPill.module.css @@ -153,3 +153,19 @@ width: 20px; height: 20px; } + +/* State tones — the same gradient tint, coloured by the semantic state + tokens instead of the categorical accent (`--pill-accent` is set from + the tone by TagPill.tsx). Text takes the state's text token so it reads + on both themes. Declared last: same specificity as the accent rule. */ +.pill[data-tone="success"] { + --pill-color: var(--success-text-muted); +} + +.pill[data-tone="warning"] { + --pill-color: var(--warning-text); +} + +.pill[data-tone="danger"] { + --pill-color: var(--danger-text); +} diff --git a/src/ui/components/TagPill/TagPill.tsx b/src/ui/components/TagPill/TagPill.tsx index 9f267a085..31db74834 100644 --- a/src/ui/components/TagPill/TagPill.tsx +++ b/src/ui/components/TagPill/TagPill.tsx @@ -20,11 +20,14 @@ import { pillAccent, pillAccentVar, type PillAccent } from '@ui/pillAccent' import styles from './TagPill.module.css' type TagPillSize = 'xs' | 'sm' +/** A semantic state instead of a categorical accent: same tint, state-coloured. */ +export type TagPillTone = 'success' | 'warning' | 'danger' interface TagPillProps { label: string colorKey?: string accent?: PillAccent + tone?: TagPillTone active?: boolean muted?: boolean monospace?: boolean @@ -48,6 +51,7 @@ export function TagPill({ label, colorKey, accent, + tone, active = false, muted = false, monospace = false, @@ -69,7 +73,7 @@ export function TagPill({ const resolvedAccent = accent ?? pillAccent(colorKey ?? label) const removable = Boolean(onRemove) const style = { - '--pill-accent': pillAccentVar(resolvedAccent), + '--pill-accent': tone ? `var(--${tone})` : pillAccentVar(resolvedAccent), } as CSSProperties const labelContent = ( @@ -94,6 +98,7 @@ export function TagPill({ <span className={cn(styles.pill, className)} data-accent={resolvedAccent} + data-tone={tone} data-active={active ? 'true' : undefined} data-clickable={onClick ? 'true' : undefined} data-muted={muted ? 'true' : undefined} diff --git a/src/ui/components/TagPill/index.ts b/src/ui/components/TagPill/index.ts index d76780bf7..eb1fe1edb 100644 --- a/src/ui/components/TagPill/index.ts +++ b/src/ui/components/TagPill/index.ts @@ -1,2 +1,2 @@ -export { TagPill } from './TagPill' +export { TagPill, type TagPillTone } from './TagPill' diff --git a/tests/e2e/branch-review.e2e.ts b/tests/e2e/branch-review.e2e.ts index 1201d341f..aff403cac 100644 --- a/tests/e2e/branch-review.e2e.ts +++ b/tests/e2e/branch-review.e2e.ts @@ -141,8 +141,8 @@ test('owner prepares a branch and an editor without merge rights', async ({ page // heading, a paragraph and a button. const home = await homeRow(page) const root = home.cells.body.rootNodeId + // Exactly the seed, not the seed on top of whatever a previous run left. const nodes = { - ...home.cells.body.nodes, [root]: { ...home.cells.body.nodes[root]!, children: ['review-hero'] }, 'review-hero': node('review-hero', 'base.container', {}, ['review-heading', 'review-copy', 'review-cta']), 'review-heading': node('review-heading', 'base.text', { text: 'Ship your site faster', tag: 'h1' }, [], 'Headline'), From bf927b141aa86f5d25546c6512b683e43797c772 Mon Sep 17 00:00:00 2001 From: DavidBabinec <hello@davidbabinec.com> Date: Sat, 5 Sep 2026 10:39:15 +0200 Subject: [PATCH 06/16] fix(spotlight): stop the branch publish gate from outranking recency `when` does two jobs: it hides a command when false AND scores +250 when true, so a genuinely contextual predicate (you have a selection, an undoable edit) lifts the command above one that merely matches the query. Site branches used it as a plain environment gate on Publish (`isOnMainBranch()`), which on main is true essentially always. That standing +250 beat the +150 recency boost and pinned Publish to the top of the empty palette, so a recently run command never floated up again. Splits the two ideas: `available` hides without scoring, and the two environment gates (publish on main, branch actions off main) use it. Every remaining `when` is contextual, which is what the boost was built for. --- docs/features/spotlight.md | 8 ++- src/__tests__/spotlight/commandGating.test.ts | 64 +++++++++++++++++++ src/admin/spotlight/commandRegistry.ts | 10 +++ src/admin/spotlight/commands/branches.ts | 2 +- src/admin/spotlight/commands/editor.ts | 4 +- src/admin/spotlight/types.ts | 18 +++++- 6 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 src/__tests__/spotlight/commandGating.test.ts diff --git a/docs/features/spotlight.md b/docs/features/spotlight.md index 68845441b..c05a9540a 100644 --- a/docs/features/spotlight.md +++ b/docs/features/spotlight.md @@ -81,8 +81,10 @@ interface Command { capability?: string | readonly string[] /** Workspace gate — only show on these workspaces. 'any' = always. */ workspaces?: ReadonlyArray<AdminWorkspace | 'any'> - /** Predicate run at query time — finer-grained gating. */ + /** Contextual relevance — hides when false, scores +250 when true. */ when?: (ctx: CommandContext) => boolean + /** Environment gate — hides when false, never scores. */ + available?: (ctx: CommandContext) => boolean /** Boosts ranking when `when` returns true. Default 1.0. */ priorityBoost?: number @@ -161,7 +163,9 @@ The subscription is **dropped on close** to avoid spurious re-renders. | `account` / `users`| Account security, session revocation, user management | | `branches` | Switch branch…, Create branch…, Switch to main, Manage branches… (`commands/branches.ts`) | -Each command's `when(ctx)` / `workspaces` / `capability` fields filter by user capability + workspace context. `filterCommands(commands, ctx)` runs once per palette open. +Each command's `when(ctx)` / `available(ctx)` / `workspaces` / `capability` fields filter by user capability + workspace context. `filterCommands(commands, ctx)` runs once per palette open. + +**`when` vs `available`** — both hide the command when they return false, and the difference is what a `true` means. `when` is *contextual relevance*: the user has something to act on right now (a selection, an undoable edit, an open page), so a `true` also scores **+250** and lifts the command above one that merely matches the query text. `available` is an *environment gate*: whether the command exists here at all (publishing only on main, branch actions only off main). It scores nothing, because a gate that is true almost all the time would otherwise carry a standing +250 — enough to outrank the +150 recency boost and pin the command to the top of an empty palette forever. Reach for `available` whenever the predicate is not about something the user is pointing at. --- diff --git a/src/__tests__/spotlight/commandGating.test.ts b/src/__tests__/spotlight/commandGating.test.ts new file mode 100644 index 000000000..c908a2fc6 --- /dev/null +++ b/src/__tests__/spotlight/commandGating.test.ts @@ -0,0 +1,64 @@ +/** + * The two gates a command can carry, and why they are not the same thing. + * + * `when` says "you have something to act on right now" — a selection, an + * undoable edit. It hides the command when false AND scores +250 when true, + * so a command that matches the moment beats one that merely matches the + * query text. + * + * `available` says "this command exists in this environment at all" — + * publishing on main, branch actions off main. It hides when false and + * scores nothing. Using `when` for that kind of gate hands the command a + * standing +250 wherever it applies, which outranks the +150 recency boost + * and pins it to the top of an empty palette forever. + */ +import { describe, expect, it } from 'bun:test' +import { rankCommands } from '@admin/spotlight/matcher' +import type { Command, CommandContext } from '@admin/spotlight/types' + +const ctx = { + workspace: 'dashboard', + pathname: '/admin/dashboard', + user: { capabilities: [] }, +} as unknown as CommandContext + +function command(id: string, title: string, extra: Partial<Command> = {}): Command { + return { + id: id as Command['id'], + title, + group: 'navigation', + run: () => {}, + ...extra, + } as Command +} + +describe('spotlight command gating', () => { + it('lets a recently run command outrank an environment-gated one', () => { + const recent = command('nav-content', 'Go to Content') + const gated = command('editor-publish', 'Publish', { available: () => true }) + + const ranked = rankCommands([gated, recent], '', ctx, ['nav-content']) + + expect(ranked[0]?.command.id).toBe('nav-content') + }) + + it('still lets a contextually relevant command outrank recency', () => { + const recent = command('nav-content', 'Go to Content') + // `when` is the "you have a selection right now" case: it should win. + const contextual = command('layers-duplicate', 'Duplicate layer', { when: () => true }) + + const ranked = rankCommands([recent, contextual], '', ctx, ['nav-content']) + + expect(ranked[0]?.command.id).toBe('layers-duplicate') + }) + + it('scores an available-gated command exactly like an ungated one', () => { + const plain = command('a-plain', 'Same Title') + const gated = command('b-gated', 'Same Title', { available: () => true }) + + const ranked = rankCommands([plain, gated], 'same title', ctx, []) + + expect(ranked).toHaveLength(2) + expect(ranked[0]?.score).toBe(ranked[1]!.score) + }) +}) diff --git a/src/admin/spotlight/commandRegistry.ts b/src/admin/spotlight/commandRegistry.ts index 19f32bcb1..567387c15 100644 --- a/src/admin/spotlight/commandRegistry.ts +++ b/src/admin/spotlight/commandRegistry.ts @@ -102,6 +102,16 @@ export function filterCommands(commands: Command[], ctx: CommandContext): Comman } } + // available() — does this command exist in this environment at all? + // Unlike when(), it carries no relevance claim and never scores. + if (cmd.available) { + try { + if (!cmd.available(ctx)) return false + } catch (_err) { + return false + } + } + // when() predicate — false (or thrown) means "hide this command." if (cmd.when) { try { diff --git a/src/admin/spotlight/commands/branches.ts b/src/admin/spotlight/commands/branches.ts index 6793b6fd4..488f2e7b1 100644 --- a/src/admin/spotlight/commands/branches.ts +++ b/src/admin/spotlight/commands/branches.ts @@ -48,7 +48,7 @@ export function getBranchesCommands(): Command[] { keywords: ['branch', 'main', 'live', 'switch'], workspaces: ['any'], capability: 'site.read', - when: () => !isOnMainBranch(), + available: () => !isOnMainBranch(), run: (ctx) => { ctx.closeSpotlight() switchBranch(MAIN_BRANCH_ID) diff --git a/src/admin/spotlight/commands/editor.ts b/src/admin/spotlight/commands/editor.ts index 51d905bc3..7f776b55e 100644 --- a/src/admin/spotlight/commands/editor.ts +++ b/src/admin/spotlight/commands/editor.ts @@ -36,7 +36,9 @@ export function getEditorCommands(): Command[] { workspaces: ['site'], capability: 'pages.publish', // Publishing only exists on main; on a branch the palette hides it. - when: () => isOnMainBranch(), + // A visibility gate, not a relevance one: `when` would hand Publish a + // standing +250 on main and pin it above recently run commands. + available: () => isOnMainBranch(), run: async (ctx) => { ctx.closeSpotlight() try { diff --git a/src/admin/spotlight/types.ts b/src/admin/spotlight/types.ts index 155147831..53d475c21 100644 --- a/src/admin/spotlight/types.ts +++ b/src/admin/spotlight/types.ts @@ -124,8 +124,24 @@ export interface Command { capability?: string | readonly string[] /** Workspace gate — only show on these workspaces. 'any' = always. */ workspaces?: ReadonlyArray<AdminWorkspace | 'any'> - /** Predicate run at query time — finer-grained gating. */ + /** + * Contextual relevance — the command needs something to act on that the + * user has RIGHT NOW (a selection, an open page, an undoable edit). False + * hides the command; true also scores +250, because a command that matches + * the moment should outrank one that merely matches the query. + * + * Do NOT use this for an environment gate that is simply true most of the + * time (on main, holding a permission) — a standing +250 there outranks + * recency and pins the command to the top of an empty palette. Use + * `available` for that. + */ when?: (ctx: CommandContext) => boolean + /** + * Visibility gate — whether the command EXISTS in this environment at all + * (publishing only on main, branch actions only off main). False hides it; + * true says nothing about relevance and never scores. + */ + available?: (ctx: CommandContext) => boolean /** Boosts ranking when `when` returns true. Default 1.0. */ priorityBoost?: number /** Arguments collected via subcommand flow (Phase 2). */ From 36f825e70e7cb4cf7fb6024087598034040a96a1 Mon Sep 17 00:00:00 2001 From: DavidBabinec <hello@davidbabinec.com> Date: Sat, 5 Sep 2026 11:37:41 +0200 Subject: [PATCH 07/16] fix(branches): renumber the migrations after main took 026 Main landed `026_plugin_media_sources` (#487) while this branch was open, so both sides claimed 026. Since neither of these has shipped, the branch moves up rather than main: site branches becomes 027 and the merge review 028, in both dialect files, with the feature doc following. --- docs/features/branches.md | 2 +- server/db/migrations-pg.ts | 2 +- server/db/migrations-sqlite.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/features/branches.md b/docs/features/branches.md index 3f75d8549..ee1712e34 100644 --- a/docs/features/branches.md +++ b/docs/features/branches.md @@ -162,7 +162,7 @@ Endpoints: `GET|POST /admin/api/cms/branches/:id/merge` and `…/update`. `GET` Page frames: `GET /admin/api/cms/branches/:id/review/render?row=<page row id>&side=main|branch` (`site.read`) returns the page's HTML composed like the branch preview (template chain, draft loops, inlined CSS, `dynamicNodes: 'inline'`) with `annotateNodeIds` on, so every node's root element carries `uid="<node id>"`; no runtime scripts are bundled. The page fetches it through `apiTextRequest` and hands it to an `<iframe sandbox="allow-same-origin">` as `srcdoc` (scripts stay off). After load, the frame is measured and the nodes the plan's tree diff lists are found by `uid` and outlined in place — highlights come from the diff, never from guesses. Modes: side by side, swipe (one frame clipped over the other), and the plain change list. -Requests and comments (`server/branches/review.ts`, `server/repositories/branchReviews.ts`, migration `027_site_branch_reviews`): `site_branch_merge_requests` (one open per branch; `content_hash` of every branch entity at request time, so the page can say when the branch moved on) and `site_branch_review_comments` (keyed by branch and `entity_key`, `''` for the request itself; they outlive a declined request). Both cascade with the branch. +Requests and comments (`server/branches/review.ts`, `server/repositories/branchReviews.ts`, migration `028_site_branch_reviews`): `site_branch_merge_requests` (one open per branch; `content_hash` of every branch entity at request time, so the page can say when the branch moved on) and `site_branch_review_comments` (keyed by branch and `entity_key`, `''` for the request itself; they outlive a declined request). Both cascade with the branch. | Endpoint | Gate | Effect | |----------|------|--------| diff --git a/server/db/migrations-pg.ts b/server/db/migrations-pg.ts index dc86ab225..321406351 100644 --- a/server/db/migrations-pg.ts +++ b/server/db/migrations-pg.ts @@ -1292,7 +1292,7 @@ export const pgMigrations: Migration[] = [ `, }, { - id: '027_site_branch_reviews', + id: '028_site_branch_reviews', sql: ` create table if not exists site_branch_merge_requests ( id text primary key, diff --git a/server/db/migrations-sqlite.ts b/server/db/migrations-sqlite.ts index 00ec07382..1876e78fb 100644 --- a/server/db/migrations-sqlite.ts +++ b/server/db/migrations-sqlite.ts @@ -1376,7 +1376,7 @@ export const sqliteMigrations: Migration[] = [ `, }, { - id: '027_site_branch_reviews', + id: '028_site_branch_reviews', sql: ` create table if not exists site_branch_merge_requests ( id text primary key, From 7d8a80b097b48280bfb46a76329cd82f6a2747af Mon Sep 17 00:00:00 2001 From: DavidBabinec <hello@davidbabinec.com> Date: Sat, 5 Sep 2026 14:06:23 +0200 Subject: [PATCH 08/16] fix(branches): stop the merge request reading "requested now ago" The review header phrases the request stamp as "<name> requested <stamp> ago", but formatRelativeTime answers "now" under a minute, so every freshly opened request read "requested now ago" until the clock ticked past sixty seconds. That is the exact window a reviewer opens the page in. relativeIso stays the bare stamp for the columns that render one on its own; a new relativeIsoAgo owns the past-tense phrasing and answers "just now" for the sub-minute case. Unit test covers both. --- src/__tests__/admin/reviewFormat.test.ts | 45 +++++++++++++++++++ src/admin/pages/branches/BranchReviewPage.tsx | 3 +- src/admin/pages/branches/reviewFormat.ts | 13 ++++++ 3 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/admin/reviewFormat.test.ts diff --git a/src/__tests__/admin/reviewFormat.test.ts b/src/__tests__/admin/reviewFormat.test.ts new file mode 100644 index 000000000..6dd0caf13 --- /dev/null +++ b/src/__tests__/admin/reviewFormat.test.ts @@ -0,0 +1,45 @@ +/** + * Timestamp phrasing on the merge review page. + * + * `relativeIso` is the bare stamp a column renders on its own; `relativeIsoAgo` + * is the past-tense phrase a sentence embeds. They differ only under a minute, + * where the bare form is "now" and appending the word would read "now ago". + */ +import { describe, expect, it } from 'bun:test' +import { relativeIso, relativeIsoAgo } from '@admin/pages/branches/reviewFormat' + +function isoAgo(ms: number): string { + return new Date(Date.now() - ms).toISOString() +} + +describe('relativeIso', () => { + it('reads "now" under a minute', () => { + expect(relativeIso(isoAgo(5_000))).toBe('now') + }) + + it('counts minutes, hours and days', () => { + expect(relativeIso(isoAgo(3 * 60_000))).toBe('3m') + expect(relativeIso(isoAgo(2 * 3_600_000))).toBe('2h') + expect(relativeIso(isoAgo(4 * 86_400_000))).toBe('4d') + }) + + it('is empty for an unparsable stamp', () => { + expect(relativeIso('not a date')).toBe('') + }) +}) + +describe('relativeIsoAgo', () => { + it('says "just now" instead of "now ago"', () => { + expect(relativeIsoAgo(isoAgo(5_000))).toBe('just now') + }) + + it('appends "ago" to every older stamp', () => { + expect(relativeIsoAgo(isoAgo(3 * 60_000))).toBe('3m ago') + expect(relativeIsoAgo(isoAgo(2 * 3_600_000))).toBe('2h ago') + expect(relativeIsoAgo(isoAgo(4 * 86_400_000))).toBe('4d ago') + }) + + it('stays empty for an unparsable stamp rather than reading " ago"', () => { + expect(relativeIsoAgo('not a date')).toBe('') + }) +}) diff --git a/src/admin/pages/branches/BranchReviewPage.tsx b/src/admin/pages/branches/BranchReviewPage.tsx index bb4902638..661939a4d 100644 --- a/src/admin/pages/branches/BranchReviewPage.tsx +++ b/src/admin/pages/branches/BranchReviewPage.tsx @@ -39,6 +39,7 @@ import { isPageChange, matchesFilter, relativeIso, + relativeIsoAgo, requestStatusLabel, requestStatusTone, type ReviewFilter, @@ -221,7 +222,7 @@ function Review({ branchId, branchName }: ReviewProps) { {request && <StatusPill status={request.status} unresolved={open ? unresolved.length : 0} />} {request ? ( <span> - <strong>{request.requestedBy?.displayName ?? 'Removed user'}</strong> requested {relativeIso(request.createdAt)} ago + <strong>{request.requestedBy?.displayName ?? 'Removed user'}</strong> requested {relativeIsoAgo(request.createdAt)} </span> ) : ( <span>No merge request yet</span> diff --git a/src/admin/pages/branches/reviewFormat.ts b/src/admin/pages/branches/reviewFormat.ts index 5c16d7340..055fbbf42 100644 --- a/src/admin/pages/branches/reviewFormat.ts +++ b/src/admin/pages/branches/reviewFormat.ts @@ -85,5 +85,18 @@ export function relativeIso(iso: string): string { return formatRelativeTime(ms) } +/** + * The same stamp as a past-tense phrase — "3m ago", "4d ago". + * + * Under a minute `formatRelativeTime` says "now", which reads as "now ago" + * once a caller appends the word, so that case becomes "just now" instead. + * Callers that render a bare stamp want `relativeIso`. + */ +export function relativeIsoAgo(iso: string): string { + const stamp = relativeIso(iso) + if (!stamp) return '' + return stamp === 'now' ? 'just now' : `${stamp} ago` +} + /** Every comment on the request itself uses the empty key. */ export const REQUEST_ENTITY_KEY = '' From 8437c6f99d4087461933796a849dc89204edc0b1 Mon Sep 17 00:00:00 2001 From: DavidBabinec <hello@davidbabinec.com> Date: Sat, 5 Sep 2026 16:16:07 +0200 Subject: [PATCH 09/16] feat(branches): split forking from managing with site.branches.create One capability, site.branches.manage, used to cover both forking a branch and merging it into main. Forking is additive and private; merging rewrites main's drafts. Bundling them meant a contributor could never fork a branch and ask for review without also being able to land it, so the showcase persona had to hold merge rights it was not supposed to use. site.branches.create now forks a branch and covers the branches the user forked: rename, delete, update from main, share or revoke a preview link. site.branches.manage keeps its name and covers every branch, plus the two gatekeeper acts that touch the live site: merge into main and decline a merge request. It does not fork on its own. Owner and Admin hold both. The rule lives once, in src/core/branches/access.ts (canActOnBranch, canMergeBranches), and both the server gates and the admin UI use it, so a control is never offered and then refused: the chip's Create, the strip's share/update/rename/revoke/delete, the manage dialog's per-row rename/delete (disabled with the reason when out of reach), and the Spotlight commands. The merge/update handler gates by direction. The branch registry already recorded created_by_user_id, so no schema change. The unshipped 027_site_branches migration now seeds the new capability next to the old one in both dialects, and createTestDb runs syncSystemRoles after migrations the way boot does, so system roles in tests come from code rather than the seed snapshot. Without that the Owner in every capability test was frozen at the seed and forking 403'd. Docs: capabilities reference (counts corrected to the real array length, they had drifted to 36/38/39), the branches feature doc, picker labels. Verification: bun test cmsMigrations + migration-parity 9 pass bun test branches handler/preview/merge, access rule, picker coverage, handler-gate, spotlight 42 pass bun run build tsc + vite clean bun run lint clean bun test (full) 6909 pass, 0 fail --- docs/features/branches.md | 13 ++--- docs/reference/capabilities.md | 11 +++-- server/auth/capabilities.ts | 1 + server/db/migrations-pg.ts | 6 +++ server/db/migrations-sqlite.ts | 16 +++++- server/handlers/cms/branches.ts | 48 +++++++++++------- server/http.ts | 5 ++ src/__tests__/core/branches/access.test.ts | 49 +++++++++++++++++++ src/__tests__/helpers/createTestDb.ts | 5 ++ .../server/branchPreviewLinks.test.ts | 14 +++++- src/__tests__/server/branchesHandler.test.ts | 24 ++++++++- src/admin/pages/users/utils/capabilities.ts | 1 + .../shared/BranchSwitcher/BranchChip.tsx | 8 +-- .../BranchSwitcher/BranchContextStrip.tsx | 15 +++--- .../BranchSwitcher/ManageBranchesDialog.tsx | 17 +++++-- .../shared/CapabilityPicker/capabilityMeta.ts | 6 ++- src/admin/spotlight/commands/branches.ts | 6 ++- src/core/branches/access.ts | 47 ++++++++++++++++++ src/core/branches/index.ts | 1 + src/core/capabilities.ts | 8 ++- 20 files changed, 250 insertions(+), 51 deletions(-) create mode 100644 src/__tests__/core/branches/access.test.ts create mode 100644 src/core/branches/access.ts diff --git a/docs/features/branches.md b/docs/features/branches.md index ee1712e34..9dcf84da3 100644 --- a/docs/features/branches.md +++ b/docs/features/branches.md @@ -17,7 +17,7 @@ main with a three-way review. Publishing only ever happens on main. - **Merge** (branch → main) and **Update** (main → branch) are the same three-way merge over `site_branch_bases` (`server/branches/merge.ts`): field-level where both sides moved different fields, a reviewer decision where they moved the same one. An update never writes main. - **Merge review** (`/admin/branches/:id/review`) is where merging happens: one row of tiles per planned change, its comment thread beside it — pages as before/after renders with the changed nodes outlined, entries as field tables, tables as schemas, files as line diffs — each with its own comment thread; anyone with `site.read` can ask for a merge, comment, and read the plan, a branch manager declines or merges from the page's footer. - **Version history** lists a row's published versions and restores one into the draft on the active branch (`data_row_versions`, `GET/POST …/data/rows/:id/versions`). -- Capability: `site.branches.manage` (Owner, Admin). Audit: `branch.*`, `version.restore`. +- Capabilities: `site.branches.create` forks a branch and covers the branches you forked (rename, delete, update from main, preview links); `site.branches.manage` covers every branch plus merging into main and declining a request, and does not fork on its own. Owner and Admin hold both. The one rule is `canActOnBranch` in `@core/branches`, used by the server gates and the UI alike. Audit: `branch.*`, `version.restore`. --- @@ -27,6 +27,7 @@ main with a three-way review. Publishing only ever happens on main. src/core/branches/ ├── ids.ts MAIN_BRANCH_ID, id pattern, slugify, physicalId / logicalIdOf ├── schemas.ts SiteBranch, BranchPreview, MergePlan, request/response envelopes +├── access.ts canActOnBranch / canMergeBranches — who may act on a branch (server gates + UI) ├── threeWayMerge.ts mergeJson(base, ours, theirs) — the pure JSON three-way merge └── index.ts barrel (gated: no deep imports) @@ -105,7 +106,7 @@ Doc ids carry the branch: `page:<branch>:<rowId>`, `component:<branch>:<rowId>`, Everything lives in the shared toolbar (`src/admin/pages/site/toolbar/Toolbar.tsx`), so it is present on every admin route: - **Chip** (`BranchChip`) next to the site brand — always icon-only, tinted with the branch accent off main (the context strip right above it carries the name, so the chip does not repeat it). Opens a palette: search first (Enter switches to the first match, or starts creating when nothing matches), then *Current* and *Recent* (main first, then by `updatedAt`), then *Create branch…* (an in-place form: name → slug preview, start from main or the current branch) and *Manage branches…*. -- **Context strip** (`BranchContextStrip`) above the toolbar while on a branch, painted `--bg-surface-2` with the identity tint (`pillAccent(branch.id)`) on the icon and name only. Actions: *Share preview* / *New preview link*, *Merge into main…* (*Request merge…* without `site.branches.manage`; both open the review page), and a menu with *Update from main…*, *Rename…*, *Revoke preview link*, *Switch to main*, *Delete branch*. +- **Context strip** (`BranchContextStrip`) above the toolbar while on a branch, painted `--bg-surface-2` with the identity tint (`pillAccent(branch.id)`) on the icon and name only. Actions: *Share preview* / *New preview link*, *Merge into main…* (*Request merge…* without `site.branches.manage`; both open the review page) — every other strip action is also available to the branch's creator, not only managers, and a menu with *Update from main…*, *Rename…*, *Revoke preview link*, *Switch to main*, *Delete branch*. - **Manage dialog** (`ManageBranchesDialog`) — search by name or id, open, rename inline, delete, create. - **Update dialog** (`UpdateBranchDialog`) — the update plan grouped by Site / Tables / entries per table, `New` / `Changed` / `Removed` badges, a two-way choice per conflict. Merging has no dialog: it happens on the review page (below). - **Delete** always confirms (`DeleteBranchDialog`) and steps up. @@ -119,9 +120,9 @@ Everything lives in the shared toolbar (`src/admin/pages/site/toolbar/Toolbar.ts | Endpoint | Gate | Effect | |----------|------|--------| -| `POST /admin/api/cms/branches/:id/preview` | `site.branches.manage` | Issues a new token (retiring the previous one) and returns `{ url, preview }`. Only the SHA-256 is stored. | +| `POST /admin/api/cms/branches/:id/preview` | `canActOnBranch` (manager, or the creator of this branch) | Issues a new token (retiring the previous one) and returns `{ url, preview }`. Only the SHA-256 is stored. | | `GET …/preview` | `site.read` | `{ preview }` — the active link's metadata, or `null`. | -| `DELETE …/preview` | `site.branches.manage` | Revokes. | +| `DELETE …/preview` | `canActOnBranch` | Revokes. | | `GET /_instatic/preview/<token>` | public | Validates, sets `instatic_branch_preview` (HttpOnly, SameSite=Lax, Path=/, 30 days) and redirects to `/`. A dead token clears the cookie instead. | | `GET /_instatic/preview/exit` | public | Clears the cookie. | @@ -147,7 +148,7 @@ Every planned change carries `detail` (`server/branches/changeDetail.ts`, schema `applyBranchMerge` flushes the relay, re-plans against live data (an unresolved conflict aborts before any write), then in one transaction writes each result to `into`. A **merge** also mirrors the result onto the branch so both sides agree afterwards, and the base becomes the result. An **update** never writes main: the branch takes the result and the base becomes main's content as of the update, so the branch's own changes stay pending. Entities identical on both sides whose base is stale move their base forward too, so a later edit on one side is not reported as a conflict. Row writes use the repositories with `collabInternal`; after commit the row/shell write notifications fire (open editors reset and reload) and, when main received rows, the `content.entry.*` plugin hooks fire for them. Table creates run before rows and restore a soft-deleted table under the same id; table deletes run last and abort the merge (`MergeApplyError`) when the table still has rows. -Endpoints: `GET|POST /admin/api/cms/branches/:id/merge` and `…/update`. `GET` (the plan) needs `site.read` — the review page shows it to whoever can read the site; `POST` needs `site.branches.manage` and steps up. `POST` body: `{ resolutions?: Record<key, 'into' | 'from'>, deleteBranch?: boolean }` → `{ plan, branchDeleted }`; unresolved conflicts answer `409 { code: 'merge_conflicts', keys }`. A successful merge closes the branch's open merge request as `merged`. +Endpoints: `GET|POST /admin/api/cms/branches/:id/merge` and `…/update`. `GET` (the plan) needs `site.read` — the review page shows it to whoever can read the site; `POST …/merge` needs `site.branches.manage`; `POST …/update` needs `canActOnBranch` (a manager, or the branch's creator); both step up. `POST` body: `{ resolutions?: Record<key, 'into' | 'from'>, deleteBranch?: boolean }` → `{ plan, branchDeleted }`; unresolved conflicts answer `409 { code: 'merge_conflicts', keys }`. A successful merge closes the branch's open merge request as `merged`. --- @@ -220,5 +221,5 @@ Add `branch_id text not null default 'main'` and the generated `logical_id` to t - [`site-shell.md`](site-shell.md) — collab document model - [`content-storage.md`](content-storage.md) — the branched tables - [`publisher.md`](publisher.md) — the public render path a preview mirrors -- [`../reference/capabilities.md`](../reference/capabilities.md) — `site.branches.manage` +- [`../reference/capabilities.md`](../reference/capabilities.md) — `site.branches.create`, `site.branches.manage` - [`audit-log.md`](audit-log.md) — `branch.*`, `branch.review.*`, `version.restore` diff --git a/docs/reference/capabilities.md b/docs/reference/capabilities.md index d5ee805c4..24d986128 100644 --- a/docs/reference/capabilities.md +++ b/docs/reference/capabilities.md @@ -8,7 +8,7 @@ For the broader auth flow (sessions, MFA, step-up), see [docs/features/auth-and- ## TL;DR -- Defined as a `const` array in `src/core/capabilities.ts` (`@core/capabilities`); `CoreCapability` is derived via `typeof CORE_CAPABILITIES[number]`. **38 capabilities.** +- Defined as a `const` array in `src/core/capabilities.ts` (`@core/capabilities`); `CoreCapability` is derived via `typeof CORE_CAPABILITIES[number]`. **40 capabilities.** - Handlers gate on capability, not on role: `requireCapability(req, db, 'site.read')`. - The **Owner AND Admin** roles get their capability lists force-resynced from `SYSTEM_ROLES` on every server boot. Hand-edits to either built-in role through the admin UI are restored at next boot — they are code-level decisions, not runtime ones. - Adding a capability: append the literal to `CORE_CAPABILITIES` in `src/core/capabilities.ts` (one place — server imports it), add it to the relevant `SYSTEM_ROLES` entries, wire `requireCapability(...)` at the gate point, and add picker meta + groups for the role-edit dialog. The two architecture tests (`capability-picker-coverage.test.ts`, `cms-handlers-capability-gated.test.ts`) catch missing pieces. @@ -16,7 +16,7 @@ For the broader auth flow (sessions, MFA, step-up), see [docs/features/auth-and- --- -## The 39 core capabilities +## The 40 core capabilities ### Read @@ -32,7 +32,8 @@ For the broader auth flow (sessions, MFA, step-up), see [docs/features/auth-and- | `site.structure.edit` | Add / remove / move / rename nodes; manage pages, VCs, classes | Owner, Admin | | `site.content.edit` | Modify content props (text, image src/alt, link href) on existing nodes — no structure or style edits | Owner, Admin, Client | | `site.style.edit` | Modify CSS classes, style overrides, breakpoints, framework tokens | Owner, Admin | -| `site.branches.manage` | Create, rename, delete, merge, and update site branches; share and revoke preview links (see [`features/branches.md`](../features/branches.md)) | Owner, Admin | +| `site.branches.create` | Fork a branch; rename, delete, update from main, and share or revoke a preview link for branches you forked (see [`features/branches.md`](../features/branches.md)) | Owner, Admin | +| `site.branches.manage` | Every branch action on any branch, plus merge into main and decline a merge request. Does not fork on its own. | Owner, Admin | `SITE_WRITE_CAPABILITIES` is the convenience set `['site.structure.edit', 'site.content.edit', 'site.style.edit']` — defined locally in `server/handlers/cms/siteDocument.ts` and `src/admin/access.ts` at each point of use, not in a shared capabilities module. The transactional site-document save (`PUT /admin/api/cms/site-document`) accepts any site writer, then diff-validates the batch by category: page deletions, page metadata, topology, module identity, non-content props, and dynamic bindings require `site.structure.edit`; content-category props (and site-wide SEO copy on the shell) require `site.content.edit`; inline styles/classes/breakpoint overrides and style rules require `site.style.edit`. Empty change sets are no-op saves any site writer may perform, but changed/deleted components and layouts remain structural work (`site.structure.edit`). @@ -151,8 +152,8 @@ Four built-in `SYSTEM_ROLES`: | Role | id | Capabilities | Boot behaviour | |----------|-----------|------------------------------------------------------------------------------|----------------| -| Owner | `owner` | All 36 (`CORE_CAPABILITIES`) | Force-resynced on every boot. Owner-only `roles.manage`. | -| Admin | `admin` | All 36 except `roles.manage` | **Force-resynced on every boot** (changed from previous "seeded once"). Hand-edits restored at boot. | +| Owner | `owner` | All 40 (`CORE_CAPABILITIES`) | Force-resynced on every boot. Owner-only `roles.manage`. | +| Admin | `admin` | All 40 except `roles.manage` | **Force-resynced on every boot** (changed from previous "seeded once"). Hand-edits restored at boot. | | Client | `client` | `dashboard.read`, `site.read`, `site.content.edit`, `media.read`, `data.custom.tables.read` | Seeded once; freely editable. Sees custom tables only — never the system tables. | | Member | `member` | (none) | Seeded once; freely editable. | diff --git a/server/auth/capabilities.ts b/server/auth/capabilities.ts index a0232cae3..6fce8ac89 100644 --- a/server/auth/capabilities.ts +++ b/server/auth/capabilities.ts @@ -57,6 +57,7 @@ const adminCapabilities: CoreCapability[] = [ 'content.publish.own', 'content.publish.any', 'content.manage', + 'site.branches.create', 'site.branches.manage', 'media.read', 'media.write', diff --git a/server/db/migrations-pg.ts b/server/db/migrations-pg.ts index 321406351..27a0ec21a 100644 --- a/server/db/migrations-pg.ts +++ b/server/db/migrations-pg.ts @@ -1284,6 +1284,12 @@ export const pgMigrations: Migration[] = [ create index if not exists site_branch_previews_branch_idx on site_branch_previews (branch_id); + update roles + set capabilities_json = capabilities_json || '["site.branches.create"]'::jsonb, + updated_at = current_timestamp + where id in ('owner', 'admin') + and not (capabilities_json ? 'site.branches.create'); + update roles set capabilities_json = capabilities_json || '["site.branches.manage"]'::jsonb, updated_at = current_timestamp diff --git a/server/db/migrations-sqlite.ts b/server/db/migrations-sqlite.ts index 1876e78fb..3b42b84e6 100644 --- a/server/db/migrations-sqlite.ts +++ b/server/db/migrations-sqlite.ts @@ -1362,8 +1362,20 @@ export const sqliteMigrations: Migration[] = [ create index if not exists site_branch_previews_branch_idx on site_branch_previews (branch_id); - -- Branch management is an Owner/Admin power; the boot-time role sync - -- re-applies this, the migration records it for the seed snapshot. + -- Branch powers are Owner/Admin by default: create forks a branch and + -- reaches the branches you forked; manage reaches every branch and + -- merges. The boot-time role sync re-applies both; the migration + -- records them for the seed snapshot. + update roles + set capabilities_json = json_insert(capabilities_json, '$[#]', 'site.branches.create'), + updated_at = current_timestamp + where id in ('owner', 'admin') + and not exists ( + select 1 + from json_each(roles.capabilities_json) + where value = 'site.branches.create' + ); + update roles set capabilities_json = json_insert(capabilities_json, '$[#]', 'site.branches.manage'), updated_at = current_timestamp diff --git a/server/handlers/cms/branches.ts b/server/handlers/cms/branches.ts index 5635b6535..b86b85bce 100644 --- a/server/handlers/cms/branches.ts +++ b/server/handlers/cms/branches.ts @@ -33,6 +33,8 @@ import { CreateReviewCommentBodySchema, DeclineMergeRequestBodySchema, RenameBranchBodySchema, + canActOnBranch, + canMergeBranches, isMainBranch, isValidBranchId, slugifyBranchName, @@ -49,7 +51,7 @@ import { } from '../../branches/review' import { renderBranchReviewPage } from '../../publish/branchReviewRender' import { getOpenMergeRequest } from '../../repositories/branchReviews' -import { userHasCapability } from '../../auth/authz' +import { requireAuthenticatedUser, userHasCapability } from '../../auth/authz' import { canReadTable } from './data/access' import { listDataTables } from '../../repositories/data' import { MAIN_SCOPE } from '../../branches/scope' @@ -65,7 +67,7 @@ import { runPublishFlush } from '../../publish/publishFlush' import { expectedOrigin } from '../../auth/security' import { getActiveBranchPreview, revokeBranchPreviews } from '../../repositories/branchPreviews' import { requireCapability, requireStepUp } from '../../auth/authz' -import { badRequest, jsonResponse, methodNotAllowed, readValidatedBody } from '../../http' +import { badRequest, forbidden, jsonResponse, methodNotAllowed, readValidatedBody } from '../../http' import { createAuditEvent } from '../../repositories/audit' import { branchExists, getBranch, listBranches, renameBranch } from '../../repositories/branches' import { CMS_API_PREFIX, requestAuditContext, type CmsHandlerOptions } from './shared' @@ -188,15 +190,18 @@ async function handleMergeApply( direction: MergeDirection, options: CmsHandlerOptions, ): Promise<Response> { - const user = await requireCapability(req, db, 'site.branches.manage') + const user = await requireAuthenticatedUser(req, db) if (user instanceof Response) return user if (isMainBranch(branchId)) return badRequest('Main is the live site; it is what branches merge into') - // Merging rewrites main's drafts wholesale; updating rewrites the branch. + const branch = await getBranch(db, branchId) + if (!branch) return branchNotFound(branchId) + // Merging rewrites main's drafts wholesale and is a manager's call alone. + // Updating rewrites only the branch, so its creator may do it too. + const allowed = direction === 'merge' ? canMergeBranches(user) : canActOnBranch(user, branch) + if (!allowed) return forbidden() // Both are re-verified like publishing is. const stepUp = await requireStepUp(req, db, user) if (stepUp) return stepUp - const branch = await getBranch(db, branchId) - if (!branch) return branchNotFound(branchId) const body = await readValidatedBody(req, ApplyMergeBodySchema) if (!body) return badRequest('Invalid merge payload') @@ -254,10 +259,12 @@ async function handlePreviewState(req: Request, db: DbClient, branchId: string): } async function handlePreviewIssue(req: Request, db: DbClient, branchId: string): Promise<Response> { - const user = await requireCapability(req, db, 'site.branches.manage') + const user = await requireAuthenticatedUser(req, db) if (user instanceof Response) return user if (isMainBranch(branchId)) return badRequest('Main is the live site; it has no preview link') - if (!(await getBranch(db, branchId))) return branchNotFound(branchId) + const branch = await getBranch(db, branchId) + if (!branch) return branchNotFound(branchId) + if (!canActOnBranch(user, branch)) return forbidden() const { token, preview } = await issueBranchPreviewLink(db, { branchId, createdByUserId: user.id }) await createAuditEvent(db, { actorUserId: user.id, @@ -271,9 +278,11 @@ async function handlePreviewIssue(req: Request, db: DbClient, branchId: string): } async function handlePreviewRevoke(req: Request, db: DbClient, branchId: string): Promise<Response> { - const user = await requireCapability(req, db, 'site.branches.manage') + const user = await requireAuthenticatedUser(req, db) if (user instanceof Response) return user - if (!(await getBranch(db, branchId))) return branchNotFound(branchId) + const branch = await getBranch(db, branchId) + if (!branch) return branchNotFound(branchId) + if (!canActOnBranch(user, branch)) return forbidden() const revoked = await revokeBranchPreviews(db, branchId) if (revoked > 0) { await createAuditEvent(db, { @@ -448,7 +457,9 @@ async function handleList(req: Request, db: DbClient): Promise<Response> { } async function handleCreate(req: Request, db: DbClient, options: CmsHandlerOptions): Promise<Response> { - const user = await requireCapability(req, db, 'site.branches.manage') + // Forking is additive and private, so it is its own capability; managing + // alone does not fork. + const user = await requireCapability(req, db, 'site.branches.create') if (user instanceof Response) return user const body = await readValidatedBody(req, CreateBranchBodySchema) if (!body) return badRequest('Invalid branch payload') @@ -482,16 +493,17 @@ async function handleCreate(req: Request, db: DbClient, options: CmsHandlerOptio } async function handleRename(req: Request, db: DbClient, branchId: string): Promise<Response> { - const user = await requireCapability(req, db, 'site.branches.manage') + const user = await requireAuthenticatedUser(req, db) if (user instanceof Response) return user if (isMainBranch(branchId)) return badRequest('The main branch cannot be renamed') + const previous = await getBranch(db, branchId) + if (!previous) return jsonResponse({ error: `Branch "${branchId}" does not exist` }, { status: 404 }) + if (!canActOnBranch(user, previous)) return forbidden() const body = await readValidatedBody(req, RenameBranchBodySchema) if (!body) return badRequest('Invalid branch payload') const name = normalizeName(body.name) if (!name) return badRequest(`Branch names are 1 to ${BRANCH_NAME_MAX_LENGTH} characters`) - const previous = await getBranch(db, branchId) - if (!previous) return jsonResponse({ error: `Branch "${branchId}" does not exist` }, { status: 404 }) const branch = await renameBranch(db, branchId, name) if (!branch) return jsonResponse({ error: `Branch "${branchId}" does not exist` }, { status: 404 }) await createAuditEvent(db, { @@ -511,16 +523,18 @@ async function handleDelete( branchId: string, options: CmsHandlerOptions, ): Promise<Response> { - const user = await requireCapability(req, db, 'site.branches.manage') + const user = await requireAuthenticatedUser(req, db) if (user instanceof Response) return user if (isMainBranch(branchId)) return badRequest('The main branch cannot be deleted') + const branch = await getBranch(db, branchId) + if (!branch) return jsonResponse({ error: `Branch "${branchId}" does not exist` }, { status: 404 }) + // Settle who may act before asking anyone to re-authenticate. + if (!canActOnBranch(user, branch)) return forbidden() // Deleting a branch discards every unmerged change on it — re-verify the // actor the same way user deletion does. const stepUp = await requireStepUp(req, db, user) if (stepUp) return stepUp - const branch = await getBranch(db, branchId) - if (!branch) return jsonResponse({ error: `Branch "${branchId}" does not exist` }, { status: 404 }) const deleted = await deleteBranch(db, branchId, options.collabRelay ?? null) if (!deleted) return jsonResponse({ error: `Branch "${branchId}" does not exist` }, { status: 404 }) await createAuditEvent(db, { diff --git a/server/http.ts b/server/http.ts index a8e658554..dc8d25b7c 100644 --- a/server/http.ts +++ b/server/http.ts @@ -35,6 +35,11 @@ export function badRequest(message: string): Response { return jsonResponse({ error: message }, { status: 400 }) } +/** The actor is signed in but this action is outside their capabilities. */ +export function forbidden(): Response { + return jsonResponse({ error: 'Forbidden' }, { status: 403 }) +} + export function payloadTooLarge(message: string): Response { return jsonResponse({ error: message }, { status: 413 }) } diff --git a/src/__tests__/core/branches/access.test.ts b/src/__tests__/core/branches/access.test.ts new file mode 100644 index 000000000..22da0f8c7 --- /dev/null +++ b/src/__tests__/core/branches/access.test.ts @@ -0,0 +1,49 @@ +/** + * The branch access rule shared by the server gates and the admin UI. + * + * `create` reaches only the branches the actor forked; `manage` reaches every + * branch and is the only capability that merges or declines. A branch with no + * recorded creator (its author was deleted) is a manager's to act on, never a + * creator's. + */ +import { describe, expect, it } from 'bun:test' +import { canActOnBranch, canMergeBranches } from '@core/branches' + +const mine = { createdByUserId: 'u1' } +const theirs = { createdByUserId: 'u2' } +const orphaned = { createdByUserId: null } + +const creator = { id: 'u1', capabilities: ['site.read', 'site.branches.create'] } +const manager = { id: 'u3', capabilities: ['site.read', 'site.branches.manage'] } +const reader = { id: 'u1', capabilities: ['site.read'] } + +describe('canActOnBranch', () => { + it('lets a creator act on the branches they forked and nothing else', () => { + expect(canActOnBranch(creator, mine)).toBe(true) + expect(canActOnBranch(creator, theirs)).toBe(false) + expect(canActOnBranch(creator, orphaned)).toBe(false) + }) + + it('lets a manager act on every branch, including orphaned ones', () => { + expect(canActOnBranch(manager, mine)).toBe(true) + expect(canActOnBranch(manager, theirs)).toBe(true) + expect(canActOnBranch(manager, orphaned)).toBe(true) + }) + + it('gives a plain reader nothing, even on a branch that names them', () => { + expect(canActOnBranch(reader, mine)).toBe(false) + }) + + it('gives a signed-out actor nothing', () => { + expect(canActOnBranch(null, mine)).toBe(false) + expect(canMergeBranches(null)).toBe(false) + }) +}) + +describe('canMergeBranches', () => { + it('is managers only: forking a branch never implies landing it', () => { + expect(canMergeBranches(manager)).toBe(true) + expect(canMergeBranches(creator)).toBe(false) + expect(canMergeBranches(reader)).toBe(false) + }) +}) diff --git a/src/__tests__/helpers/createTestDb.ts b/src/__tests__/helpers/createTestDb.ts index 996b5860d..43dbf8eda 100644 --- a/src/__tests__/helpers/createTestDb.ts +++ b/src/__tests__/helpers/createTestDb.ts @@ -3,6 +3,7 @@ import * as fs from 'node:fs/promises' import * as path from 'node:path' import { createDbClient, type DbClient } from '../../../server/db' import { runMigrations } from '../../../server/db/runMigrations' +import { syncSystemRoles } from '../../../server/repositories/roles' export interface TestDb { db: DbClient @@ -34,6 +35,8 @@ export async function createTestDb(): Promise<TestDb> { if (!url) throw new Error('TEST_POSTGRES_URL must be set when DB=postgres') const { db, migrations } = createDbClient(url) await runMigrations(db, migrations) + // Mirror boot: system roles come from code, not from the migration seed. + await syncSystemRoles(db) return { db, cleanup: async () => { @@ -47,6 +50,8 @@ export async function createTestDb(): Promise<TestDb> { const tmpFile = path.join(os.tmpdir(), `cms-test-${crypto.randomUUID()}`, 'test.db') const { db, migrations } = createDbClient(`sqlite:${tmpFile}`) await runMigrations(db, migrations) + // Mirror boot: system roles come from code, not from the migration seed. + await syncSystemRoles(db) return { db, diff --git a/src/__tests__/server/branchPreviewLinks.test.ts b/src/__tests__/server/branchPreviewLinks.test.ts index c42715e83..2bb30397c 100644 --- a/src/__tests__/server/branchPreviewLinks.test.ts +++ b/src/__tests__/server/branchPreviewLinks.test.ts @@ -101,7 +101,7 @@ describe('branch preview links', () => { expect(deadEntry.headers.get('set-cookie')).toContain('Max-Age=0') }) - it('rotates the link on every share and gates issuing on site.branches.manage', async () => { + it('rotates the link on every share and gates issuing on who may act on the branch', async () => { harness = await createCapabilityTestHarness() const owner = await harness.setupOwner() expect((await harness.cms(BRANCHES, { method: 'POST', cookie: owner, json: { name: 'Rotate' } })).status).toBe(201) @@ -123,6 +123,18 @@ describe('branch preview links', () => { await expectForbidden(await harness.cms(`${BRANCHES}/rotate/preview`, { method: 'POST', cookie: reader.cookie })) await expectForbidden(await harness.cms(`${BRANCHES}/rotate/preview`, { method: 'DELETE', cookie: reader.cookie })) + // A creator shares the branches they forked, not the owner's. + const creator = await harness.createRoleUser({ + name: 'Creator', + slug: 'creator', + capabilities: ['site.read', 'site.branches.create'], + }) + expect((await harness.cms(BRANCHES, { method: 'POST', cookie: creator.cookie, json: { name: 'Shared' } })).status).toBe(201) + expect((await harness.cms(`${BRANCHES}/shared/preview`, { method: 'POST', cookie: creator.cookie })).status).toBe(201) + expect((await harness.cms(`${BRANCHES}/shared/preview`, { method: 'DELETE', cookie: creator.cookie })).status).toBe(200) + await expectForbidden(await harness.cms(`${BRANCHES}/rotate/preview`, { method: 'POST', cookie: creator.cookie })) + await expectForbidden(await harness.cms(`${BRANCHES}/rotate/preview`, { method: 'DELETE', cookie: creator.cookie })) + expect((await harness.cms(`${BRANCHES}/main/preview`, { method: 'POST', cookie: owner })).status).toBe(400) }) }) diff --git a/src/__tests__/server/branchesHandler.test.ts b/src/__tests__/server/branchesHandler.test.ts index 3f9d369f8..d20ed1352 100644 --- a/src/__tests__/server/branchesHandler.test.ts +++ b/src/__tests__/server/branchesHandler.test.ts @@ -99,7 +99,7 @@ describe('branches endpoints', () => { expect(deleteMain.status).toBe(400) }) - it('gates management on site.branches.manage and deletion on step-up', async () => { + it('splits forking from managing: creators reach their own branches, managers reach every branch and merge', async () => { harness = await createCapabilityTestHarness() const owner = await harness.setupOwner() expect((await harness.cms(BRANCHES, { method: 'POST', cookie: owner, json: { name: 'Doomed' } })).status).toBe(201) @@ -113,17 +113,39 @@ describe('branches endpoints', () => { await expectForbidden(await harness.cms(BRANCHES, { method: 'POST', cookie: reader.cookie, json: { name: 'Nope' } })) await expectForbidden(await harness.cms(`${BRANCHES}/doomed`, { method: 'DELETE', cookie: reader.cookie })) + // A creator forks, and reaches only what they forked: the owner's branch + // stays out of bounds, and merging into main is never theirs. + const creator = await harness.createRoleUser({ + name: 'Branch creator', + slug: 'branch-creator', + capabilities: ['site.read', 'site.branches.create'], + }) + expect((await harness.cms(BRANCHES, { method: 'POST', cookie: creator.cookie, json: { name: 'Mine' } })).status).toBe(201) + expect((await harness.cms(`${BRANCHES}/mine`, { method: 'PATCH', cookie: creator.cookie, json: { name: 'Mine renamed' } })).status).toBe(200) + expect((await harness.cms(`${BRANCHES}/mine/preview`, { method: 'POST', cookie: creator.cookie })).status).toBe(201) + await expectForbidden(await harness.cms(`${BRANCHES}/doomed`, { method: 'PATCH', cookie: creator.cookie, json: { name: 'Not yours' } })) + await expectForbidden(await harness.cms(`${BRANCHES}/doomed/preview`, { method: 'POST', cookie: creator.cookie })) + await expectForbidden(await harness.cms(`${BRANCHES}/mine/merge`, { method: 'POST', cookie: creator.cookie, json: {} })) + + // A manager reaches every branch and is the one who merges, but managing + // alone does not fork. const manager = await harness.createRoleUser({ name: 'Branch manager', slug: 'branch-manager', capabilities: ['site.read', 'site.branches.manage'], }) + await expectForbidden(await harness.cms(BRANCHES, { method: 'POST', cookie: manager.cookie, json: { name: 'No fork' } })) + expect((await harness.cms(`${BRANCHES}/mine`, { method: 'PATCH', cookie: manager.cookie, json: { name: 'Mine, managed' } })).status).toBe(200) await expectStepUpRequired(await harness.cms(`${BRANCHES}/doomed`, { method: 'DELETE', cookie: manager.cookie })) const stepped = await harness.stepUp(manager.cookie) const deleted = await harness.cms(`${BRANCHES}/doomed`, { method: 'DELETE', cookie: stepped }) expect(deleted.status).toBe(200) + // The creator retires their own branch the same way, step-up included. + const creatorStepped = await harness.stepUp(creator.cookie) + expect((await harness.cms(`${BRANCHES}/mine`, { method: 'DELETE', cookie: creatorStepped })).status).toBe(200) + const gone = await harness.cms('/admin/api/cms/data/tables', { cookie: owner, headers: { [BRANCH_HEADER]: 'doomed' }, diff --git a/src/admin/pages/users/utils/capabilities.ts b/src/admin/pages/users/utils/capabilities.ts index f36a373ed..2a5a74f7f 100644 --- a/src/admin/pages/users/utils/capabilities.ts +++ b/src/admin/pages/users/utils/capabilities.ts @@ -24,6 +24,7 @@ export const CAPABILITY_GROUPS: CapabilityGroup[] = [ 'site.structure.edit', 'site.content.edit', 'site.style.edit', + 'site.branches.create', 'site.branches.manage', ], }, diff --git a/src/admin/shared/BranchSwitcher/BranchChip.tsx b/src/admin/shared/BranchSwitcher/BranchChip.tsx index 159413059..beba3050e 100644 --- a/src/admin/shared/BranchSwitcher/BranchChip.tsx +++ b/src/admin/shared/BranchSwitcher/BranchChip.tsx @@ -90,7 +90,7 @@ function BranchRow({ export function BranchChip() { const user = useCurrentAdminUser() - const canManage = hasCapability(user, 'site.branches.manage') + const canCreate = hasCapability(user, 'site.branches.create') const branches = useBranches() const current = useActiveBranch() const mode = useBranchStore((state) => state.switcher) @@ -162,7 +162,7 @@ export function BranchChip() { event.preventDefault() const first = filtered[0] if (first) select(first) - else if (canManage && slugifyBranchName(query)) startCreate() + else if (canCreate && slugifyBranchName(query)) startCreate() } return ( @@ -223,7 +223,7 @@ export function BranchChip() { onSelect={() => select(branch)} /> ))} - {filtered.length === 0 && (canManage && slugifyBranchName(query) ? ( + {filtered.length === 0 && (canCreate && slugifyBranchName(query) ? ( <ContextMenuItem className={styles.createRow} onClick={startCreate}> <PlusIcon size={12} aria-hidden="true" /> <span> @@ -255,7 +255,7 @@ export function BranchChip() { )} </> )} - {canManage && ( + {canCreate && ( <> <ContextMenuSeparator /> <ContextMenuItem data-testid="branch-create-action" onClick={startCreate}> diff --git a/src/admin/shared/BranchSwitcher/BranchContextStrip.tsx b/src/admin/shared/BranchSwitcher/BranchContextStrip.tsx index bacddab1b..fd9be24b0 100644 --- a/src/admin/shared/BranchSwitcher/BranchContextStrip.tsx +++ b/src/admin/shared/BranchSwitcher/BranchContextStrip.tsx @@ -19,7 +19,7 @@ import { LinkIcon } from 'pixel-art-icons/icons/link' import { MoreHorizontalSolidIcon } from 'pixel-art-icons/icons/more-horizontal-solid' import { ShareSolidIcon } from 'pixel-art-icons/icons/share-solid' import { TrashSolidIcon } from 'pixel-art-icons/icons/trash-solid' -import { MAIN_BRANCH_ID, type BranchPreview, type SiteBranch } from '@core/branches' +import { MAIN_BRANCH_ID, canActOnBranch, type BranchPreview, type SiteBranch } from '@core/branches' import { getCmsBranchPreview, issueCmsBranchPreview, revokeCmsBranchPreview } from '@core/persistence' import { isAbortError } from '@core/http' import { getErrorMessage } from '@core/utils/errorMessage' @@ -61,7 +61,10 @@ export function BranchContextStrip() { function BranchStripBody({ branch: current }: { branch: SiteBranch }) { const user = useCurrentAdminUser() + // Merge is a manager's call; everything else here belongs to whoever may + // act on THIS branch: a manager, or the creator who forked it. const canManage = hasCapability(user, 'site.branches.manage') + const canAuthor = canActOnBranch(user, current) const navigate = useNavigate() const openManage = useBranchStore((state) => state.openManage) const [moreOpen, setMoreOpen] = useState(false) @@ -139,7 +142,7 @@ function BranchStripBody({ branch: current }: { branch: SiteBranch }) { )} <span className={styles.stripSpacer} aria-hidden="true" /> - {canManage && ( + {canAuthor && ( <Button variant="secondary" size="xs" @@ -194,7 +197,7 @@ function BranchStripBody({ branch: current }: { branch: SiteBranch }) { width={240} zIndex={10000} > - {canManage && ( + {canAuthor && ( <ContextMenuItem data-testid="branch-strip-update" onClick={() => { @@ -206,7 +209,7 @@ function BranchStripBody({ branch: current }: { branch: SiteBranch }) { <span>Update from main…</span> </ContextMenuItem> )} - {canManage && ( + {canAuthor && ( <ContextMenuItem onClick={() => { setMoreOpen(false) @@ -217,7 +220,7 @@ function BranchStripBody({ branch: current }: { branch: SiteBranch }) { <span>Rename…</span> </ContextMenuItem> )} - {canManage && preview && ( + {canAuthor && preview && ( <ContextMenuItem data-testid="branch-strip-revoke" onClick={() => { @@ -239,7 +242,7 @@ function BranchStripBody({ branch: current }: { branch: SiteBranch }) { <CircleDotSolidIcon size={12} aria-hidden="true" /> <span>Switch to main</span> </ContextMenuItem> - {canManage && ( + {canAuthor && ( <> <ContextMenuSeparator /> <ContextMenuItem diff --git a/src/admin/shared/BranchSwitcher/ManageBranchesDialog.tsx b/src/admin/shared/BranchSwitcher/ManageBranchesDialog.tsx index 75572a9a2..429a3bec1 100644 --- a/src/admin/shared/BranchSwitcher/ManageBranchesDialog.tsx +++ b/src/admin/shared/BranchSwitcher/ManageBranchesDialog.tsx @@ -11,7 +11,7 @@ import { EditSolidIcon } from 'pixel-art-icons/icons/edit-solid' import { GitBranchSolidIcon } from 'pixel-art-icons/icons/git-branch-solid' import { PlusIcon } from 'pixel-art-icons/icons/plus' import { TrashSolidIcon } from 'pixel-art-icons/icons/trash-solid' -import { MAIN_BRANCH_ID, slugifyBranchName, type SiteBranch } from '@core/branches' +import { MAIN_BRANCH_ID, canActOnBranch, slugifyBranchName, type SiteBranch } from '@core/branches' import { getErrorMessage } from '@core/utils/errorMessage' import { createBranch, @@ -21,6 +21,8 @@ import { useBranchStore, useBranches, } from '@admin/state/branchStore' +import { hasCapability } from '@admin/access' +import { useCurrentAdminUser } from '@admin/sessionContext' import { useAdminUi } from '@admin/state/adminUi' import { Button } from '@ui/components/Button' import { Dialog } from '@ui/components/Dialog' @@ -41,6 +43,10 @@ interface ManageBranchesDialogProps { export function ManageBranchesDialog({ open, onClose }: ManageBranchesDialogProps) { const siteName = useAdminUi((state) => state.siteName) + const user = useCurrentAdminUser() + // Forking is its own capability; each row's rename/delete follows the + // same rule the server gates, so nothing here is offered and then refused. + const canCreate = hasCapability(user, 'site.branches.create') const branches = useBranches() const current = useActiveBranch() @@ -117,7 +123,8 @@ export function ManageBranchesDialog({ open, onClose }: ManageBranchesDialogProp variant="secondary" size="sm" type="button" - disabled={creating} + disabled={creating || !canCreate} + tooltip={canCreate ? undefined : 'Your role cannot create branches'} data-testid="branch-manage-new" onClick={() => setCreating(true)} > @@ -273,8 +280,9 @@ export function ManageBranchesDialog({ open, onClose }: ManageBranchesDialogProp size="xs" type="button" iconOnly + disabled={!canActOnBranch(user, branch)} aria-label={`Rename ${branch.name}`} - tooltip="Rename" + tooltip={canActOnBranch(user, branch) ? 'Rename' : 'Only its creator or a branch manager can rename this branch'} data-testid={`branch-manage-rename-${branch.id}`} onClick={() => { setRenamingId(branch.id) @@ -289,8 +297,9 @@ export function ManageBranchesDialog({ open, onClose }: ManageBranchesDialogProp type="button" iconOnly dangerHover + disabled={!canActOnBranch(user, branch)} aria-label={`Delete ${branch.name}`} - tooltip="Delete" + tooltip={canActOnBranch(user, branch) ? 'Delete' : 'Only its creator or a branch manager can delete this branch'} data-testid={`branch-manage-delete-${branch.id}`} onClick={() => setDeleting(branch)} > diff --git a/src/admin/shared/CapabilityPicker/capabilityMeta.ts b/src/admin/shared/CapabilityPicker/capabilityMeta.ts index 25fe60e89..ce8bb3349 100644 --- a/src/admin/shared/CapabilityPicker/capabilityMeta.ts +++ b/src/admin/shared/CapabilityPicker/capabilityMeta.ts @@ -37,9 +37,13 @@ export const CAPABILITY_META: Record<CoreCapability, CapabilityMeta> = { label: 'Edit site styles', description: 'Modify CSS classes, style overrides, breakpoints, and framework tokens.', }, + 'site.branches.create': { + label: 'Create branches', + description: 'Fork a branch, and rename, delete, update, or share a preview link for the branches you created.', + }, 'site.branches.manage': { label: 'Manage branches', - description: 'Create, rename, delete, merge, and update site branches, and share their preview links.', + description: 'Act on every branch, merge branches into main, and decline merge requests. Does not fork on its own.', }, 'pages.edit': { label: 'Edit pages', diff --git a/src/admin/spotlight/commands/branches.ts b/src/admin/spotlight/commands/branches.ts index 488f2e7b1..b639f332e 100644 --- a/src/admin/spotlight/commands/branches.ts +++ b/src/admin/spotlight/commands/branches.ts @@ -33,7 +33,7 @@ export function getBranchesCommands(): Command[] { iconName: 'git-branch-solid', keywords: ['branch', 'create', 'new', 'fork'], workspaces: ['any'], - capability: 'site.branches.manage', + capability: 'site.branches.create', run: (ctx) => { ctx.closeSpotlight() useBranchStore.getState().openSwitcher('create') @@ -62,7 +62,9 @@ export function getBranchesCommands(): Command[] { iconName: 'edit-solid', keywords: ['branch', 'manage', 'rename', 'delete'], workspaces: ['any'], - capability: 'site.branches.manage', + // Creators rename and delete their own branches from the same dialog; + // the server and the dialog's rows keep them off everyone else's. + capability: 'site.branches.create', run: (ctx) => { ctx.closeSpotlight() useBranchStore.getState().openManage() diff --git a/src/core/branches/access.ts b/src/core/branches/access.ts new file mode 100644 index 000000000..76c5ef4c6 --- /dev/null +++ b/src/core/branches/access.ts @@ -0,0 +1,47 @@ +/** + * Who may act on a branch — the one rule the server gates and the admin UI + * mirrors, so a control is never offered and then refused. + * + * Two capabilities, two reaches: + * + * site.branches.create — fork a branch, and act on the branches you forked: + * rename, delete, update from main, share or revoke + * a preview link. + * site.branches.manage — act on every branch, plus the two gatekeeper acts + * that touch the live site: merge into main and + * decline a merge request. It does not fork. + * + * Forking is additive and private; merging rewrites main's drafts. Keeping + * those on separate capabilities is what lets a contributor fork and ask for + * review without being able to land anything. + */ +import type { SiteBranch } from './schemas' + +/** The slice of a signed-in user the rule needs. */ +export interface BranchActor { + id: string + capabilities: readonly string[] +} + +/** + * Rename, delete, update from main, share or revoke a preview link. A + * signed-out actor (`null`, as the admin session reads before it loads) may + * act on nothing. + */ +export function canActOnBranch( + actor: BranchActor | null, + branch: Pick<SiteBranch, 'createdByUserId'>, +): boolean { + if (!actor) return false + if (actor.capabilities.includes('site.branches.manage')) return true + return ( + actor.capabilities.includes('site.branches.create') && + branch.createdByUserId !== null && + branch.createdByUserId === actor.id + ) +} + +/** Merge into main or decline a merge request: managers only. */ +export function canMergeBranches(actor: Pick<BranchActor, 'capabilities'> | null): boolean { + return actor !== null && actor.capabilities.includes('site.branches.manage') +} diff --git a/src/core/branches/index.ts b/src/core/branches/index.ts index 0ac7f6ac3..2c0f08425 100644 --- a/src/core/branches/index.ts +++ b/src/core/branches/index.ts @@ -14,6 +14,7 @@ export { physicalId, slugifyBranchName, } from './ids' +export { canActOnBranch, canMergeBranches, type BranchActor } from './access' export { jsonEquals, mergeJson, type JsonMergeResult } from './threeWayMerge' export { BranchEnvelopeSchema, diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index 51e72cd3b..e289e8629 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -28,8 +28,12 @@ export const CORE_CAPABILITIES = [ 'site.structure.edit', 'site.content.edit', 'site.style.edit', - // Branches — create, rename, delete, merge, update, and share preview links. - // Listing and switching branches only need `site.read`. + // Branches. `create` forks a branch and covers the branches the user + // forked: rename, delete, update from main, share and revoke a preview + // link. `manage` covers every branch, plus merging into main and declining + // a merge request; it does not fork on its own. Listing and switching + // branches only need `site.read`. + 'site.branches.create', 'site.branches.manage', 'pages.edit', 'pages.publish', From 2605d669d51c3528acbed9f5e0dee4ca8a028b8d Mon Sep 17 00:00:00 2001 From: DavidBabinec <hello@davidbabinec.com> Date: Sat, 5 Sep 2026 17:21:20 +0200 Subject: [PATCH 10/16] feat(branches): merge review polish, undo, and confirmations Fixes the issues found testing the merge review on a real templated site: - Highlights never appeared: template composition prefixes every node id (c0_ for the page, t<i>_ per outer template), and the review matched bare ids. composedNodeSourceId in @core/templates maps a rendered uid back to its page node; PageCompare resolves every uid through it and outlines a loop node once per item. - Swipe did not drag: pointer drag anywhere on the stack, a grab handle at the divider; the range keeps the keyboard path. - A loop section rendered on the branch side only: loops on main read published versions (imported rows have none), the branch read drafts. SourceFetchContext.drafts is an explicit switch; the review passes it for both sides, because a merge compares drafts with drafts. - "What changed" was thin and wrong: per-node details from the tree diff (text: "old" -> "new"; structured props and node fields by name), field labels for SEO title, SEO description, featured media, cleared fields keep the old value in view, and empty cells (absent, null, "") compare equal so rows written by different paths never read as changed. The SEO loss itself is the relay bug fixed in fix/relay-row-cells. - Undo: every apply is recorded in site_branch_merges (migration 029) with each entity's before-image on the target, the branch, and the base. POST .../merge/undo and .../update/undo reverse the latest apply behind the same gates and step-up; refused with 409 merge_undo when the target moved since. Undoing a merge reopens the request it answered. The review footer shows Undo merge while lastMerge is set and the success toast carries an Undo action; the page stays on the review after a merge. Delete branch after merging now defaults to off because a merge that deleted the branch cannot be undone. - Confirmations before merging into main and updating from main: useConfirmAction on the confirm primitive, which gains a primary tone. - The strip button only opens the review, so it reads Review merge... and hides on the review page, whose footer carries the merge. - BranchReviewPage footer extracted to ReviewFooter.tsx (700-line budget). Verification: bun run build clean (tsc + vite) bun run lint clean bun test full suite, all pass (incl. new merge undo + HTTP gate tests and the relay cell-merge regression) --- docs/features/branches.md | 17 ++- docs/features/site-shell.md | 6 +- server/branches/changeDetail.ts | 57 +++++++- server/branches/contentHash.ts | 17 ++- server/branches/merge.ts | 131 ++++++++++++++++- server/branches/review.ts | 6 +- server/collab/relayPersistence.ts | 29 +++- server/db/migrations-pg.ts | 18 +++ server/db/migrations-sqlite.ts | 18 +++ server/handlers/cms/branches.ts | 74 ++++++++-- server/publish/branchReviewRender.ts | 5 +- server/publish/loopPrefetch.ts | 4 + server/repositories/audit.ts | 2 + server/repositories/branchMerges.ts | 110 +++++++++++++++ server/repositories/branchReviews.ts | 19 +++ src/__tests__/server/branchMerge.test.ts | 81 ++++++++++- .../server/collabRelayIntegration.test.ts | 32 +++++ .../branches/BranchReviewPage.module.css | 8 +- src/admin/pages/branches/BranchReviewPage.tsx | 121 +++++++--------- src/admin/pages/branches/PageCompare.tsx | 108 +++++++++++--- src/admin/pages/branches/ReviewChangeCard.tsx | 12 +- src/admin/pages/branches/ReviewFooter.tsx | 133 ++++++++++++++++++ src/admin/pages/branches/useBranchReview.ts | 10 +- .../BranchSwitcher/BranchContextStrip.tsx | 31 ++-- .../BranchSwitcher/UpdateBranchDialog.tsx | 16 ++- .../ConfirmDeleteContext.tsx | 1 + .../ConfirmDeleteDialog.tsx | 7 +- .../ConfirmDeleteDialog/confirmDeleteHook.ts | 17 +++ .../dialogs/ConfirmDeleteDialog/index.ts | 2 +- src/admin/state/branchStore.ts | 21 ++- src/core/branches/index.ts | 5 + src/core/branches/schemas.ts | 30 ++++ src/core/loops/sources/dataRows.ts | 2 +- src/core/loops/types.ts | 6 + src/core/persistence/cmsBranches.ts | 14 +- src/core/persistence/index.ts | 1 + src/core/templates/index.ts | 1 + src/core/templates/templateCompose.ts | 13 ++ tests/e2e/branch-review.e2e.ts | 4 + tests/e2e/branches.e2e.ts | 4 + 40 files changed, 1045 insertions(+), 148 deletions(-) create mode 100644 server/repositories/branchMerges.ts create mode 100644 src/admin/pages/branches/ReviewFooter.tsx diff --git a/docs/features/branches.md b/docs/features/branches.md index 9dcf84da3..3d1b9dc7d 100644 --- a/docs/features/branches.md +++ b/docs/features/branches.md @@ -38,15 +38,16 @@ server/branches/ ├── changeDetail.ts describeChange — per-change detail for the review (fields, page tree diff, file text) ├── fork.ts forkBranch — copy shell, tables, rows; record bases (one transaction) ├── deleteBranch.ts deleteBranch — rows, tables, shell, collab docs, registry row -├── merge.ts planBranchMerge / applyBranchMerge (merge + update directions) -├── review.ts merge requests, comments, branch content hash (stale detection) +├── merge.ts planBranchMerge / applyBranchMerge / undoBranchMerge (merge + update directions) +├── review.ts merge requests, comments, branch content hash (stale detection), last merge └── previewLinks.ts tokens, cookie, resolvePreviewCookie, entry/exit paths server/repositories/ ├── branches.ts site_branches registry ├── branchBases.ts site_branch_bases — base hash + content per entity ├── branchPreviews.ts site_branch_previews — hashed tokens, one active link per branch -└── branchReviews.ts site_branch_merge_requests + site_branch_review_comments +├── branchReviews.ts site_branch_merge_requests + site_branch_review_comments +└── branchMerges.ts site_branch_merges — one record per apply with every entity's before-images; undo reads it server/handlers/cms/branches.ts /admin/api/cms/branches[/…] endpoints server/publish/branchPreview.ts render a branch draft for a public URL @@ -106,7 +107,7 @@ Doc ids carry the branch: `page:<branch>:<rowId>`, `component:<branch>:<rowId>`, Everything lives in the shared toolbar (`src/admin/pages/site/toolbar/Toolbar.tsx`), so it is present on every admin route: - **Chip** (`BranchChip`) next to the site brand — always icon-only, tinted with the branch accent off main (the context strip right above it carries the name, so the chip does not repeat it). Opens a palette: search first (Enter switches to the first match, or starts creating when nothing matches), then *Current* and *Recent* (main first, then by `updatedAt`), then *Create branch…* (an in-place form: name → slug preview, start from main or the current branch) and *Manage branches…*. -- **Context strip** (`BranchContextStrip`) above the toolbar while on a branch, painted `--bg-surface-2` with the identity tint (`pillAccent(branch.id)`) on the icon and name only. Actions: *Share preview* / *New preview link*, *Merge into main…* (*Request merge…* without `site.branches.manage`; both open the review page) — every other strip action is also available to the branch's creator, not only managers, and a menu with *Update from main…*, *Rename…*, *Revoke preview link*, *Switch to main*, *Delete branch*. +- **Context strip** (`BranchContextStrip`) above the toolbar while on a branch, painted `--bg-surface-2` with the identity tint (`pillAccent(branch.id)`) on the icon and name only. Actions: *Share preview* / *New preview link*, *Review merge…* (*Request merge…* without `site.branches.manage`; both only open the review page, where the footer carries the actual merge, and the button is hidden while on `/admin/branches/…`) — every other strip action is also available to the branch's creator, not only managers, and a menu with *Update from main…*, *Rename…*, *Revoke preview link*, *Switch to main*, *Delete branch*. - **Manage dialog** (`ManageBranchesDialog`) — search by name or id, open, rename inline, delete, create. - **Update dialog** (`UpdateBranchDialog`) — the update plan grouped by Site / Tables / entries per table, `New` / `Changed` / `Removed` badges, a two-way choice per conflict. Merging has no dialog: it happens on the review page (below). - **Delete** always confirms (`DeleteBranchDialog`) and steps up. @@ -150,6 +151,8 @@ Every planned change carries `detail` (`server/branches/changeDetail.ts`, schema Endpoints: `GET|POST /admin/api/cms/branches/:id/merge` and `…/update`. `GET` (the plan) needs `site.read` — the review page shows it to whoever can read the site; `POST …/merge` needs `site.branches.manage`; `POST …/update` needs `canActOnBranch` (a manager, or the branch's creator); both step up. `POST` body: `{ resolutions?: Record<key, 'into' | 'from'>, deleteBranch?: boolean }` → `{ plan, branchDeleted }`; unresolved conflicts answer `409 { code: 'merge_conflicts', keys }`. A successful merge closes the branch's open merge request as `merged`. +**Undo.** Every apply is recorded in `site_branch_merges` (migration `029_site_branch_merges`, `server/repositories/branchMerges.ts`): per touched entity, its before-image on the target, on the branch (a merge mirrors), and in the bases, plus the hash of what was written. `POST …/merge/undo` and `…/update/undo` (same gates and step-up as the apply) reverse the latest apply that has not been undone: the target goes back to before, the bases with it, and after a merge the branch too — for every entity still holding the merged content, so an edit made on the branch since is kept. It is refused with `409 { code: 'merge_undo' }` when the *target* moved since the apply; an undo never silently discards work that landed after a merge. Undoing a merge reopens the request it answered. A merge that deleted the branch records nothing (there is nothing to restore into), which is why the review's *Delete branch after merging* defaults to off. The response is `{ merge, restoredCount }`; the apply's envelope carries the `merge` record (`null` when the branch was deleted) so the client can offer *Undo* right away. + --- ## Merge review @@ -159,15 +162,15 @@ Endpoints: `GET|POST /admin/api/cms/branches/:id/merge` and `…/update`. `GET` - **The request row** — the open or last merge request (who, note, state badge: *Awaiting review* / *Changes requested* / *Merged* / *Withdrawn*, a `TagPill` with a state `tone`), with the general conversation beside it and a row of fact tiles (changes by kind, conflicts left, freshness, what merging does). Without a request it offers *Request merge…*. - **One row per change**, its kind (*Page*, *Entry*, *Table*, *File*) and action (*new* / *changed* / *removed*) as badges in the card head, with the thread tile on the left (comments keyed by the change's `key`, an always-present composer) and the change on the right: a page (`row` in the `pages` table) as **before/after frames**, an entry as a field table, a table as its schema, the shell as a settings table, a file as a line diff (`@core/utils/lineDiff`). A change with conflicts carries a strip with *Keep main* / *Take branch* (managers only). - **The decision row** — the decline note, the merge outcome, or the wait. -- **The footer** — managers: *Delete branch after merging*, *Decline…* (open request only; a note is required) and *Merge N changes*, disabled with the count while conflicts are undecided; the merge runs the existing step-up-gated `POST …/merge`. Requesters: *Withdraw request*; everyone else: *Request merge…*. +- **The footer** — managers: *Delete branch after merging* (off by default; on, the merge cannot be undone and the label says so), *Decline…* (open request only; a note is required) and *Merge N changes*, disabled with the count while conflicts are undecided. Merging asks first (`useConfirmAction`, the confirm primitive in its non-destructive tone; *Update from main…* asks the same way), then runs the step-up-gated `POST …/merge`; the success toast carries an *Undo* action, and while the review state's `lastMerge` is set the footer shows *Undo merge* beside the merge button (`POST …/merge/undo`, step-up gated). The page stays on the review after a merge so the undo is at hand; it leaves for the site only when the branch was deleted. Requesters: *Withdraw request*; everyone else: *Request merge…*. -Page frames: `GET /admin/api/cms/branches/:id/review/render?row=<page row id>&side=main|branch` (`site.read`) returns the page's HTML composed like the branch preview (template chain, draft loops, inlined CSS, `dynamicNodes: 'inline'`) with `annotateNodeIds` on, so every node's root element carries `uid="<node id>"`; no runtime scripts are bundled. The page fetches it through `apiTextRequest` and hands it to an `<iframe sandbox="allow-same-origin">` as `srcdoc` (scripts stay off). After load, the frame is measured and the nodes the plan's tree diff lists are found by `uid` and outlined in place — highlights come from the diff, never from guesses. Modes: side by side, swipe (one frame clipped over the other), and the plain change list. +Page frames: `GET /admin/api/cms/branches/:id/review/render?row=<page row id>&side=main|branch` (`site.read`) returns the page's HTML composed like the branch preview (template chain, draft loops, inlined CSS, `dynamicNodes: 'inline'`) with `annotateNodeIds` on, so every node's root element carries `uid="<composed node id>"`; no runtime scripts are bundled. Both sides read DRAFT rows in their loops (`prefetchLoopData({ drafts: true })`, an explicit switch on `SourceFetchContext`): a merge compares main's draft with the branch's draft, and main's *published* versions are what visitors see, not what the merge writes over — without this a post-type loop rendered its cards on the branch and nothing on main. The page fetches the HTML through `apiTextRequest` and hands it to an `<iframe sandbox="allow-same-origin">` as `srcdoc` (scripts stay off). After load, the frame is measured and every `uid` is mapped back to its page node through `composedNodeSourceId` (`@core/templates`; template composition prefixes ids with `c0_` and `t<i>_`), then the nodes the plan's tree diff lists are outlined in place — one node inside a loop is outlined once per item. Highlights come from the diff, never from guesses. Modes: side by side, swipe (one frame clipped over the other; drag anywhere on the stack, or use the range below it with the keyboard), and the plain change list, where a changed node reads `Changed text: “old” → “new”` from the diff's per-node `details` (`server/branches/changeDetail.ts`: scalar props are quoted, structured ones and node fields are named). Field changes are labelled by field (`Title`, `Slug`, `SEO title`, `SEO description`, `Featured media`), and a cleared field keeps its old value in view. Row content is compared with empty cells normalized (`compactCells` in `contentHash.ts`: absent, `null`, and `""` are the same empty cell), so rows written by different paths never read as changed. Requests and comments (`server/branches/review.ts`, `server/repositories/branchReviews.ts`, migration `028_site_branch_reviews`): `site_branch_merge_requests` (one open per branch; `content_hash` of every branch entity at request time, so the page can say when the branch moved on) and `site_branch_review_comments` (keyed by branch and `entity_key`, `''` for the request itself; they outlive a declined request). Both cascade with the branch. | Endpoint | Gate | Effect | |----------|------|--------| -| `GET /admin/api/cms/branches/:id/review` | `site.read` | `{ branch, request, comments, contentHash }` | +| `GET /admin/api/cms/branches/:id/review` | `site.read` | `{ branch, request, comments, contentHash, lastMerge }` (`lastMerge`: the newest merge into main not yet undone, or `null`) | | `POST …/review/request` | `site.read` | Opens a request `{ note }`; `409 merge_request_open` while one is open | | `POST …/review/withdraw` | requester or `site.branches.manage` | Closes it as withdrawn | | `POST …/review/decline` | `site.branches.manage` | Closes it as declined; `{ note }` required | diff --git a/docs/features/site-shell.md b/docs/features/site-shell.md index 511bf3876..31a5b02d3 100644 --- a/docs/features/site-shell.md +++ b/docs/features/site-shell.md @@ -550,7 +550,11 @@ across all their docs via a routing-group stack. seeds them from the stored JSON (the server is the ONLY seeder — fixed seed clientID, so two clients can never build divergent initial histories), persists each doc's update blob to `collab_documents` AND the derived row -JSON to `data_rows`/site on a short debounce (~800 ms), applies +JSON to `data_rows`/site on a short debounce (~800 ms) — replacing only the +cells the doc owns (`OWNED_CELLS` in `relayPersistence.ts`: title, slug, body +and the template cells for pages; name, slug, body, params, classIds for +components; name, slug, body, classes for layouts), so SEO, featured media, +and plugin cells edited elsewhere survive every relay write — applies roster-driven soft-deletes, and RESETS docs whose row was written outside the relay (`rowWriteEvents.ts`) — clients rebind and reseed. The publish endpoint flushes the relay first so the baked snapshot includes edits still diff --git a/server/branches/changeDetail.ts b/server/branches/changeDetail.ts index b81c203d4..44b4cd82e 100644 --- a/server/branches/changeDetail.ts +++ b/server/branches/changeDetail.ts @@ -94,6 +94,49 @@ function normalizeNode(node: Record<string, unknown>): Record<string, unknown> { } } +const NODE_TEXT_LIMIT = 80 + +function quote(value: unknown): string { + const text = typeof value === 'string' ? value : String(value) + const shown = text.length > NODE_TEXT_LIMIT ? `${text.slice(0, NODE_TEXT_LIMIT)}…` : text + return `“${shown}”` +} + +/** A scalar the review can print inline; objects and arrays are "changed". */ +function isScalar(value: unknown): value is string | number | boolean { + return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' +} + +/** + * What moved between two versions of one node, as lines the review prints: + * a prop with scalar values on both sides is quoted (`text: “a” → “b”`), a + * prop or node field that is structured or missing on one side is named. + * Children are not compared here; a child list change is the child's own + * add or remove. + */ +function nodeChangeDetails(before: Record<string, unknown>, after: Record<string, unknown>): string[] { + const lines: string[] = [] + const a = normalizeNode(before) + const b = normalizeNode(after) + const aProps = isRecord(a.props) ? a.props : {} + const bProps = isRecord(b.props) ? b.props : {} + for (const key of [...new Set([...Object.keys(aProps), ...Object.keys(bProps)])].sort()) { + const x = aProps[key] + const y = bProps[key] + if (canonicalJson(x ?? null) === canonicalJson(y ?? null)) continue + if (isScalar(x) && isScalar(y)) lines.push(`${key}: ${quote(x)} → ${quote(y)}`) + else if (x === undefined || x === null) lines.push(`${key}: set`) + else if (y === undefined || y === null) lines.push(`${key}: cleared`) + else lines.push(`${key} changed`) + } + for (const key of Object.keys({ ...a, ...b }).sort()) { + if (key === 'props' || key === 'children' || key === 'parentId' || key === 'id') continue + if (canonicalJson(a[key] ?? null) === canonicalJson(b[key] ?? null)) continue + lines.push(`${key} changed`) + } + return lines +} + function nodeLabel(node: unknown): string { if (!isRecord(node)) return 'node' if (typeof node.label === 'string' && node.label.trim()) return node.label.trim() @@ -108,7 +151,7 @@ export function treeDiff(before: unknown, after: unknown): MergeTreeDiff | null if (!beforeNodes && !afterNodes) return null const a = beforeNodes ?? {} const b = afterNodes ?? {} - const diff: MergeTreeDiff = { added: [], changed: [], removed: [], labels: {} } + const diff: MergeTreeDiff = { added: [], changed: [], removed: [], labels: {}, details: {} } for (const id of Object.keys(b)) { if (!(id in a)) { diff.added.push(id) @@ -116,6 +159,9 @@ export function treeDiff(before: unknown, after: unknown): MergeTreeDiff | null } else if (nodeSignature(a[id]) !== nodeSignature(b[id])) { diff.changed.push(id) diff.labels[id] = nodeLabel(b[id]) + const before = a[id] + const after = b[id] + if (isRecord(before) && isRecord(after)) diff.details[id] = nodeChangeDetails(before, after) } } for (const id of Object.keys(a)) { @@ -159,7 +205,14 @@ function schemaDiff(before: readonly unknown[], after: readonly unknown[]): Merg return out } -const ROW_LABELS: Record<string, string> = { title: 'Title', slug: 'Slug', body: 'Body' } +const ROW_LABELS: Record<string, string> = { + title: 'Title', + slug: 'Slug', + body: 'Body', + seoTitle: 'SEO title', + seoDescription: 'SEO description', + featuredMediaId: 'Featured media', +} const TABLE_LABELS: Record<string, string> = { name: 'Name', slug: 'Slug', diff --git a/server/branches/contentHash.ts b/server/branches/contentHash.ts index 134bd5477..c53837fe7 100644 --- a/server/branches/contentHash.ts +++ b/server/branches/contentHash.ts @@ -44,8 +44,23 @@ export interface SiteContent { /** A site file minus its id (the logical id) and timestamps (never merged). */ export type FileContent = Omit<SiteFile, 'id' | 'createdAt' | 'updatedAt'> +/** + * A cell that is absent, null, or the empty string is the same empty cell. + * Rows written by different paths disagree on which of the three they store + * (an import keeps `""`, the relay drops the key), and that must never read + * as a change, a conflict, or a "cleared" field in the review. + */ +export function compactCells(cells: Record<string, unknown>): Record<string, unknown> { + const out: Record<string, unknown> = {} + for (const [key, value] of Object.entries(cells)) { + if (value === undefined || value === null || value === '') continue + out[key] = value + } + return out +} + export function rowContent(row: Pick<DataRow, 'tableId' | 'cells' | 'slug'>): RowContent { - return { tableId: row.tableId, cells: row.cells, slug: row.slug } + return { tableId: row.tableId, cells: compactCells(row.cells), slug: row.slug } } export function tableContent(table: DataTable): TableContent { diff --git a/server/branches/merge.ts b/server/branches/merge.ts index 9c2b5ddb5..68ba6c1d4 100644 --- a/server/branches/merge.ts +++ b/server/branches/merge.ts @@ -22,6 +22,7 @@ import { MAIN_BRANCH_ID, mergeJson, + type BranchMergeRecord, type MergeChange, type MergeDirection, type MergePlan, @@ -45,6 +46,13 @@ import { describeChange } from './changeDetail' import { collectBranchEntities, type BranchEntity } from './entities' import { deleteBranchBases, listBranchBases, upsertBranchBases, type BranchBase } from '../repositories/branchBases' import { touchBranch } from '../repositories/branches' +import { + getLatestBranchMerge, + insertBranchMerge, + listMergeUndoEntries, + markBranchMergeUndone, + type MergeUndoEntry, +} from '../repositories/branchMerges' import { createDataTable, getDataRow, @@ -440,6 +448,8 @@ export interface ApplyMergeInput { export interface ApplyMergeResult { plan: MergePlan + /** The record `undoBranchMerge` reverses. */ + merge: BranchMergeRecord } /** @@ -454,7 +464,7 @@ export async function applyBranchMerge(db: DbClient, input: ApplyMergeInput): Pr // Everything that writes runs on the collab-aware lane; the plugin hooks // fire AFTER it releases — a listener that writes content takes the same // lane and would otherwise wait on the very merge that is waiting on it. - const { plan, into, intoNotices } = await serializeCollabAwareWrite(async () => { + const { plan, into, intoNotices, merge } = await serializeCollabAwareWrite(async () => { const { plan, work, converged, stale } = await planBranchMerge(db, input.branchId, input.direction) const unresolved = plan.changes .filter((change) => change.conflicts.length > 0 && !input.resolutions[change.key]) @@ -465,13 +475,27 @@ export async function applyBranchMerge(db: DbClient, input: ApplyMergeInput): Pr const mirrorOntoFrom = input.direction === 'merge' const intoNotices: WriteNotices = { rows: [], shell: false } const fromNotices: WriteNotices = { rows: [], shell: false } + let merge: BranchMergeRecord | null = null await db.transaction(async (tx) => { const bases: BranchBase[] = [...converged] const removed: Array<{ kind: BranchEntityKind; logicalId: string }> = [...stale] + // Before-images for undo: what every written entity held on each side, + // and the base it was judged against. + const basesBefore = new Map( + (await listBranchBases(tx, input.branchId)).map((base) => [baseKey(base.kind, base.logicalId), base.content]), + ) + const undoEntries: MergeUndoEntry[] = [] for (const entry of work) { const result = resolvedResult(entry, input.resolutions) const resultHash = result === null ? null : contentHash(result) + undoEntries.push({ + change: entry.change, + intoBefore: entry.ours?.content ?? null, + fromBefore: mirrorOntoFrom ? entry.theirs?.content ?? null : null, + baseBefore: basesBefore.get(baseKey(entry.change.kind, entry.change.logicalId)) ?? null, + resultHash, + }) const oursHash = entry.ours ? contentHash(entry.ours.content) : null const theirsHash = entry.theirs ? contentHash(entry.theirs.content) : null if (resultHash !== oursHash) await writeEntity(tx, into, entry, result, input.actorUserId, intoNotices) @@ -488,12 +512,113 @@ export async function applyBranchMerge(db: DbClient, input: ApplyMergeInput): Pr await upsertBranchBases(tx, input.branchId, bases) await deleteBranchBases(tx, input.branchId, removed) await touchBranch(tx, input.branchId) + merge = await insertBranchMerge(tx, { + branchId: input.branchId, + direction: input.direction, + appliedByUserId: input.actorUserId, + entries: undoEntries, + }) }) emitCollabNotices(into, intoNotices) if (mirrorOntoFrom) emitCollabNotices(from, fromNotices) - return { plan, into, intoNotices } + if (!merge) throw new Error('[branches] the merge transaction committed without a record') + return { plan, into, intoNotices, merge } + }) + if (isMainScope(into)) await emitContentEvents(db, intoNotices, input.actorUserId) + return { plan, merge } +} + +function baseKey(kind: BranchEntityKind, logicalId: string): string { + return `${kind}\n${logicalId}` +} + +function contentHashOrNull(content: unknown | null): string | null { + return content === null ? null : contentHash(content) +} + +function entityHash(entity: BranchEntity | undefined): string | null { + return entity ? contentHash(entity.content) : null +} + +/** The latest apply cannot be reversed: nothing is recorded, or the target moved since. */ +export class MergeUndoError extends Error { + constructor(message: string) { + super(message) + this.name = 'MergeUndoError' + } +} + +export interface UndoMergeInput { + branchId: string + direction: MergeDirection + actorUserId: string | null +} + +export interface UndoMergeResult { + merge: BranchMergeRecord + /** Entities put back on the target. */ + restoredCount: number +} + +/** + * Reverse the latest merge or update on the branch. Every entity the apply + * wrote goes back to what it was on the target, the bases return with it, + * and after a merge the branch's mirrored copy goes back too, for every + * entity that still holds the merged content (an edit made on the branch + * since is kept). Refused outright when the TARGET moved since the apply: + * an undo must never silently discard work that landed after the merge. + */ +export async function undoBranchMerge(db: DbClient, input: UndoMergeInput): Promise<UndoMergeResult> { + await runPublishFlush() + const { from, into } = scopesFor(input.branchId, input.direction) + const mirrorOntoFrom = input.direction === 'merge' + const { merge, restoredCount, intoNotices } = await serializeCollabAwareWrite(async () => { + const record = await getLatestBranchMerge(db, input.branchId, input.direction) + if (!record) throw new MergeUndoError('There is no merge to undo') + const entries = await listMergeUndoEntries(db, record.id) + const intoNow = await collectBranchEntities(db, into) + const moved = entries.filter((entry) => entityHash(intoNow.get(entry.change.key)) !== entry.resultHash) + if (moved.length > 0) { + const names = moved.slice(0, 3).map((entry) => entry.change.label).join(', ') + throw new MergeUndoError( + `${into.branchId} changed since the merge (${names}${moved.length > 3 ? ', ...' : ''}); put it back by hand`, + ) + } + const fromNow = mirrorOntoFrom ? await collectBranchEntities(db, from) : null + const intoNotices: WriteNotices = { rows: [], shell: false } + const fromNotices: WriteNotices = { rows: [], shell: false } + let restored = 0 + await db.transaction(async (tx) => { + const bases: BranchBase[] = [] + const removed: Array<{ kind: BranchEntityKind; logicalId: string }> = [] + for (const entry of entries) { + const work: Work = { change: entry.change, ours: undefined, theirs: undefined, result: null } + const intoBefore = entry.intoBefore ?? null + if (contentHashOrNull(intoBefore) !== entry.resultHash) { + await writeEntity(tx, into, work, intoBefore, input.actorUserId, intoNotices) + restored += 1 + } + if (fromNow && entityHash(fromNow.get(entry.change.key)) === entry.resultHash) { + const fromBefore = entry.fromBefore ?? null + if (contentHashOrNull(fromBefore) !== entry.resultHash) { + await writeEntity(tx, from, work, fromBefore, input.actorUserId, fromNotices) + } + } + const { kind, logicalId } = entry.change + const baseBefore = entry.baseBefore ?? null + if (baseBefore === null) removed.push({ kind, logicalId }) + else bases.push({ kind, logicalId, contentHash: contentHash(baseBefore), content: baseBefore }) + } + await upsertBranchBases(tx, input.branchId, bases) + await deleteBranchBases(tx, input.branchId, removed) + await touchBranch(tx, input.branchId) + await markBranchMergeUndone(tx, record.id) + }) + emitCollabNotices(into, intoNotices) + if (mirrorOntoFrom) emitCollabNotices(from, fromNotices) + return { merge: { ...record, undoneAt: new Date().toISOString() }, restoredCount: restored, intoNotices } }) if (isMainScope(into)) await emitContentEvents(db, intoNotices, input.actorUserId) - return { plan } + return { merge, restoredCount } } diff --git a/server/branches/review.ts b/server/branches/review.ts index 8219c5edf..74dfd523c 100644 --- a/server/branches/review.ts +++ b/server/branches/review.ts @@ -13,6 +13,7 @@ import type { DbClient } from '../db/client' import { contentHash } from './contentHash' import { runPublishFlush } from '../publish/publishFlush' import { collectBranchEntities } from './entities' +import { getLatestBranchMerge } from '../repositories/branchMerges' import { closeOpenMergeRequests, getLatestMergeRequest, @@ -52,12 +53,13 @@ export async function branchContentHash(db: DbClient, branchId: string): Promise export async function readBranchReviewState(db: DbClient, branch: SiteBranch): Promise<BranchReviewState> { // Same reason as the plan: the hash must see what the editors see. await runPublishFlush() - const [request, comments, hash] = await Promise.all([ + const [request, comments, hash, lastMerge] = await Promise.all([ getLatestMergeRequest(db, branch.id), listReviewComments(db, branch.id), branchContentHash(db, branch.id), + getLatestBranchMerge(db, branch.id, 'merge'), ]) - return { branch, request, comments, contentHash: hash } + return { branch, request, comments, contentHash: hash, lastMerge } } /** True when the error is the partial unique index on open requests firing. */ diff --git a/server/collab/relayPersistence.ts b/server/collab/relayPersistence.ts index 2e3e62dfe..1acaf8ff7 100644 --- a/server/collab/relayPersistence.ts +++ b/server/collab/relayPersistence.ts @@ -52,6 +52,30 @@ const KIND_TABLE: Record<Exclude<CollabDocKind, 'site'>, string> = { layout: 'layouts', } +/** + * The cells each doc kind derives from its Y doc. Every other cell on the + * row (SEO title and description, featured media, plugin-owned fields) is + * edited elsewhere and must survive a relay write untouched; an owned cell + * the projection no longer emits (a page that stopped being a template) + * is cleared. + */ +const OWNED_CELLS: Record<Exclude<CollabDocKind, 'site'>, readonly string[]> = { + page: ['title', 'slug', 'body', 'templateEnabled', 'templateTarget', 'templatePriority'], + component: ['name', 'slug', 'body', 'params', 'classIds'], + layout: ['name', 'slug', 'body', 'classes'], +} + +/** The row's stored cells with the doc-owned ones replaced by what the doc derives. */ +export function mergeDerivedCells( + existing: Record<string, unknown> | undefined, + derived: Record<string, unknown>, + owned: readonly string[], +): Record<string, unknown> { + const merged: Record<string, unknown> = { ...existing } + for (const key of owned) delete merged[key] + return { ...merged, ...derived } +} + export type DerivedWrite = 'written' | 'incomplete' | 'invalid' type SiteRosters = ReturnType<typeof projectSiteDoc>['rosters'] @@ -330,10 +354,13 @@ export function createRelayPersistence( slug = layoutSlugFromName(layout.name) } + // The doc carries only the cells the editor owns; the rest of the row + // was edited elsewhere and stays exactly as stored. + const existing = await getDataRow(db, scope, parsed.rowId) await upsertDataRowDraft( db, scope, - { id: parsed.rowId, tableId: table, cells, slug }, + { id: parsed.rowId, tableId: table, cells: mergeDerivedCells(existing?.cells, cells, OWNED_CELLS[parsed.kind]), slug }, null, { collabInternal: true }, ) diff --git a/server/db/migrations-pg.ts b/server/db/migrations-pg.ts index 27a0ec21a..cec1f6752 100644 --- a/server/db/migrations-pg.ts +++ b/server/db/migrations-pg.ts @@ -1335,4 +1335,22 @@ export const pgMigrations: Migration[] = [ on site_branch_review_comments (branch_id, created_at); `, }, + { + id: '029_site_branch_merges', + sql: ` + create table if not exists site_branch_merges ( + id text primary key, + branch_id text not null references site_branches(id) on delete cascade, + direction text not null, + applied_by_user_id text references users(id) on delete set null, + change_count integer not null default 0, + entries_json jsonb not null default '[]', + undone_at timestamptz, + created_at timestamptz not null default now() + ); + + create index if not exists site_branch_merges_branch_idx + on site_branch_merges (branch_id, created_at desc); + `, + }, ] diff --git a/server/db/migrations-sqlite.ts b/server/db/migrations-sqlite.ts index 3b42b84e6..606ff71c5 100644 --- a/server/db/migrations-sqlite.ts +++ b/server/db/migrations-sqlite.ts @@ -1425,4 +1425,22 @@ export const sqliteMigrations: Migration[] = [ on site_branch_review_comments (branch_id, created_at); `, }, + { + id: '029_site_branch_merges', + sql: ` + create table if not exists site_branch_merges ( + id text primary key, + branch_id text not null references site_branches(id) on delete cascade, + direction text not null, + applied_by_user_id text references users(id) on delete set null, + change_count integer not null default 0, + entries_json text not null default '[]', + undone_at text, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ); + + create index if not exists site_branch_merges_branch_idx + on site_branch_merges (branch_id, created_at desc); + `, + }, ] diff --git a/server/handlers/cms/branches.ts b/server/handlers/cms/branches.ts index b86b85bce..8af38180a 100644 --- a/server/handlers/cms/branches.ts +++ b/server/handlers/cms/branches.ts @@ -15,6 +15,8 @@ * POST /admin/api/cms/branches/:id/merge merge into main (site.branches.manage + step-up) * GET /admin/api/cms/branches/:id/update plan updating from main (site.read) * POST /admin/api/cms/branches/:id/update update from main (site.branches.manage) + * POST /admin/api/cms/branches/:id/merge/undo reverse the latest merge (same gates as the merge) + * POST /admin/api/cms/branches/:id/update/undo reverse the latest update (same gates as the update) * GET /admin/api/cms/branches/:id/review request + comments + content hash (site.read) * POST /admin/api/cms/branches/:id/review/request ask for a merge (site.read) * POST /admin/api/cms/branches/:id/review/withdraw withdraw the open request (requester or site.branches.manage) @@ -50,7 +52,7 @@ import { readBranchReviewState, } from '../../branches/review' import { renderBranchReviewPage } from '../../publish/branchReviewRender' -import { getOpenMergeRequest } from '../../repositories/branchReviews' +import { getOpenMergeRequest, reopenMergedRequest } from '../../repositories/branchReviews' import { requireAuthenticatedUser, userHasCapability } from '../../auth/authz' import { canReadTable } from './data/access' import { listDataTables } from '../../repositories/data' @@ -62,7 +64,10 @@ import type { BranchScope } from '../../branches/scope' import { forkBranch } from '../../branches/fork' import { deleteBranch } from '../../branches/deleteBranch' import { issueBranchPreviewLink, previewEntryPath } from '../../branches/previewLinks' -import { MergeApplyError, MergeConflictsUnresolvedError, applyBranchMerge, planBranchMerge } from '../../branches/merge' +import { MergeApplyError, MergeConflictsUnresolvedError, MergeUndoError, + applyBranchMerge, planBranchMerge, + undoBranchMerge, +} from '../../branches/merge' import { runPublishFlush } from '../../publish/publishFlush' import { expectedOrigin } from '../../auth/security' import { getActiveBranchPreview, revokeBranchPreviews } from '../../repositories/branchPreviews' @@ -126,11 +131,18 @@ export async function handleBranchesRoutes( } return null } - if (segments.length === 2 && (segments[1] === 'merge' || segments[1] === 'update')) { + if (segments[1] === 'merge' || segments[1] === 'update') { const direction: MergeDirection = segments[1] - if (req.method === 'GET') return handleMergePlan(req, db, branchId, direction) - if (req.method === 'POST') return handleMergeApply(req, db, branchId, direction, options) - return methodNotAllowed() + if (segments.length === 2) { + if (req.method === 'GET') return handleMergePlan(req, db, branchId, direction) + if (req.method === 'POST') return handleMergeApply(req, db, branchId, direction, options) + return methodNotAllowed() + } + if (segments.length === 3 && segments[2] === 'undo') { + if (req.method === 'POST') return handleMergeUndo(req, db, branchId, direction) + return methodNotAllowed() + } + return null } return null } @@ -205,14 +217,14 @@ async function handleMergeApply( const body = await readValidatedBody(req, ApplyMergeBodySchema) if (!body) return badRequest('Invalid merge payload') - let plan + let applied try { - plan = (await applyBranchMerge(db, { + applied = await applyBranchMerge(db, { branchId, direction, resolutions: body.resolutions ?? {}, actorUserId: user.id, - })).plan + }) } catch (err) { if (err instanceof MergeConflictsUnresolvedError) { return jsonResponse({ error: err.message, code: 'merge_conflicts', keys: err.keys }, { status: 409 }) @@ -222,6 +234,7 @@ async function handleMergeApply( } throw err } + const { plan, merge } = applied await createAuditEvent(db, { actorUserId: user.id, action: direction === 'merge' ? 'branch.merge' : 'branch.update', @@ -247,7 +260,7 @@ async function handleMergeApply( }) } } - return jsonResponse({ plan, branchDeleted }) + return jsonResponse({ plan, branchDeleted, merge: branchDeleted ? null : merge }) } async function handlePreviewState(req: Request, db: DbClient, branchId: string): Promise<Response> { @@ -547,3 +560,44 @@ async function handleDelete( }) return jsonResponse({ ok: true }) } + +async function handleMergeUndo( + req: Request, + db: DbClient, + branchId: string, + direction: MergeDirection, +): Promise<Response> { + const user = await requireAuthenticatedUser(req, db) + if (user instanceof Response) return user + if (isMainBranch(branchId)) return badRequest('Main is the live site; it is what branches merge into') + const branch = await getBranch(db, branchId) + if (!branch) return branchNotFound(branchId) + // Undoing rewrites exactly what the apply rewrote: same gates, same step-up. + const allowed = direction === 'merge' ? canMergeBranches(user) : canActOnBranch(user, branch) + if (!allowed) return forbidden() + const stepUp = await requireStepUp(req, db, user) + if (stepUp) return stepUp + + let result + try { + result = await undoBranchMerge(db, { branchId, direction, actorUserId: user.id }) + } catch (err) { + if (err instanceof MergeUndoError) { + return jsonResponse({ error: err.message, code: 'merge_undo' }, { status: 409 }) + } + if (err instanceof MergeApplyError) { + return jsonResponse({ error: err.message, code: 'merge_apply', key: err.key }, { status: 409 }) + } + throw err + } + if (direction === 'merge') await reopenMergedRequest(db, branchId) + await createAuditEvent(db, { + actorUserId: user.id, + action: direction === 'merge' ? 'branch.merge.undo' : 'branch.update.undo', + targetType: 'branch', + targetId: branchId, + metadata: { name: branch.name, restored: result.restoredCount, mergeId: result.merge.id }, + ...requestAuditContext(req), + }) + return jsonResponse({ merge: result.merge, restoredCount: result.restoredCount }) +} diff --git a/server/publish/branchReviewRender.ts b/server/publish/branchReviewRender.ts index 91104a656..4b945d162 100644 --- a/server/publish/branchReviewRender.ts +++ b/server/publish/branchReviewRender.ts @@ -50,7 +50,10 @@ export async function renderBranchReviewPage( slug: page.slug || null, cookies: {}, } - const loopData = await prefetchLoopData(merged, site, db, url, { branchId: scope.branchId, request }) + // Both sides read DRAFT rows: a merge compares main's draft with the + // branch's draft, and main's published versions are what visitors see, + // not what the merge would write over. + const loopData = await prefetchLoopData(merged, site, db, url, { branchId: scope.branchId, request, drafts: true }) const mediaAssets = await prefetchMediaAssets(merged, site, registry, db, { templateContext, loopData }) const rendered = publishPage(merged, site, registry, { templateContext, diff --git a/server/publish/loopPrefetch.ts b/server/publish/loopPrefetch.ts index 53a22de2f..e4c921980 100644 --- a/server/publish/loopPrefetch.ts +++ b/server/publish/loopPrefetch.ts @@ -229,6 +229,7 @@ async function resolveOneLoop( url?: URL request?: SourceRequestContext branchId?: string + drafts?: boolean }, ): Promise<ResolvedLoopData> { const props = readLoopProps(node) @@ -253,6 +254,7 @@ async function resolveOneLoop( // Built-in publish-time sources ignore it. request: ctx.request, branchId: ctx.branchId, + drafts: ctx.drafts, } try { @@ -291,6 +293,7 @@ export async function prefetchLoopData( rootNodeId?: string /** Branch whose rows loops read; absent means main (publishing, public routes). */ branchId?: string + drafts?: boolean }, ): Promise<LoopDataMap> { const nodes = collectLoopNodes(page, site, options?.rootNodeId) @@ -312,6 +315,7 @@ export async function prefetchLoopData( url, request: options?.request, branchId: options?.branchId, + drafts: options?.drafts, }) return [node.id, data] as [string, ResolvedLoopData] }), diff --git a/server/repositories/audit.ts b/server/repositories/audit.ts index 5c2013276..4110bbb8d 100644 --- a/server/repositories/audit.ts +++ b/server/repositories/audit.ts @@ -38,6 +38,8 @@ const AuditActionSchema = Type.Union([ Type.Literal('branch.delete'), Type.Literal('branch.merge'), Type.Literal('branch.update'), + Type.Literal('branch.merge.undo'), + Type.Literal('branch.update.undo'), Type.Literal('branch.preview.share'), Type.Literal('branch.preview.revoke'), Type.Literal('branch.review.request'), diff --git a/server/repositories/branchMerges.ts b/server/repositories/branchMerges.ts new file mode 100644 index 000000000..821ad8955 --- /dev/null +++ b/server/repositories/branchMerges.ts @@ -0,0 +1,110 @@ +/** + * Branch merges — `site_branch_merges`: every applied merge or update, with + * what each touched entity looked like before it, so the whole thing can be + * put back. One row per apply; `undone_at` marks a reversed one. The entries + * are the server's business (they carry full entity content); the client + * sees the record without them. + */ +import { Type, type Static } from '@sinclair/typebox' +import { Value } from '@sinclair/typebox/value' +import { nanoid } from 'nanoid' +import { MergeChangeSchema, type BranchMergeRecord, type MergeDirection } from '@core/branches' +import type { DbClient } from '../db/client' + +/** What one entity looked like on every side before the apply wrote it. */ +export const MergeUndoEntrySchema = Type.Object({ + change: MergeChangeSchema, + /** The merge target's content before; null when the entity did not exist there. */ + intoBefore: Type.Unknown(), + /** The other side's content before; only a merge writes that side (the mirror). */ + fromBefore: Type.Unknown(), + /** The recorded base before; null when there was none. */ + baseBefore: Type.Unknown(), + /** Hash of what the apply wrote on both sides; null when it deleted the entity. */ + resultHash: Type.Union([Type.String(), Type.Null()]), +}) +export type MergeUndoEntry = Static<typeof MergeUndoEntrySchema> +const MergeUndoEntriesSchema = Type.Array(MergeUndoEntrySchema) + +interface BranchMergeRow { + id: string + branch_id: string + direction: MergeDirection + applied_by_user_id: string | null + change_count: number + created_at: string | Date + undone_at: string | Date | null +} + +function toIso(value: string | Date): string { + return value instanceof Date ? value.toISOString() : value +} + +function toRecord(row: BranchMergeRow): BranchMergeRecord { + return { + id: row.id, + branchId: row.branch_id, + direction: row.direction, + appliedByUserId: row.applied_by_user_id, + changeCount: row.change_count, + createdAt: toIso(row.created_at), + undoneAt: row.undone_at === null ? null : toIso(row.undone_at), + } +} + +export async function insertBranchMerge( + db: DbClient, + input: { branchId: string; direction: MergeDirection; appliedByUserId: string | null; entries: MergeUndoEntry[] }, +): Promise<BranchMergeRecord> { + const id = nanoid() + const now = new Date().toISOString() + await db` + insert into site_branch_merges (id, branch_id, direction, applied_by_user_id, change_count, entries_json, created_at) + values (${id}, ${input.branchId}, ${input.direction}, ${input.appliedByUserId}, ${input.entries.length}, ${JSON.stringify(input.entries)}, ${now}) + ` + const record = await getBranchMerge(db, id) + if (!record) throw new Error('[branches] merge record vanished after insert') + return record +} + +export async function getBranchMerge(db: DbClient, id: string): Promise<BranchMergeRecord | null> { + const { rows } = await db<BranchMergeRow>` + select id, branch_id, direction, applied_by_user_id, change_count, created_at, undone_at from site_branch_merges where id = ${id} + ` + const row = rows[0] + return row ? toRecord(row) : null +} + +/** The newest apply in the given direction that has not been undone. */ +export async function getLatestBranchMerge( + db: DbClient, + branchId: string, + direction: MergeDirection, +): Promise<BranchMergeRecord | null> { + const { rows } = await db<BranchMergeRow>` + select id, branch_id, direction, applied_by_user_id, change_count, created_at, undone_at from site_branch_merges + where branch_id = ${branchId} and direction = ${direction} and undone_at is null + order by created_at desc + limit 1 + ` + const row = rows[0] + return row ? toRecord(row) : null +} + +/** The stored before-images of one apply, validated on the way out of the JSON column. */ +export async function listMergeUndoEntries(db: DbClient, mergeId: string): Promise<MergeUndoEntry[]> { + const { rows } = await db<{ entries_json: unknown }>` + select entries_json from site_branch_merges where id = ${mergeId} + ` + const raw = rows[0]?.entries_json + const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw + if (!Value.Check(MergeUndoEntriesSchema, parsed)) { + throw new Error(`[branches] merge ${mergeId} has unreadable undo entries`) + } + return parsed +} + +export async function markBranchMergeUndone(db: DbClient, id: string): Promise<void> { + const now = new Date().toISOString() + await db`update site_branch_merges set undone_at = ${now} where id = ${id}` +} diff --git a/server/repositories/branchReviews.ts b/server/repositories/branchReviews.ts index 17cb02639..abe02a490 100644 --- a/server/repositories/branchReviews.ts +++ b/server/repositories/branchReviews.ts @@ -229,3 +229,22 @@ export async function insertReviewComment( if (!comment) throw new Error('[branches] review comment vanished after insert') return comment } + +/** After an undone merge, the request that merge answered is open again. */ +export async function reopenMergedRequest(db: DbClient, branchId: string): Promise<BranchMergeRequest | null> { + const { rows } = await db<{ id: string }>` + select id from site_branch_merge_requests + where branch_id = ${branchId} and status = 'merged' + order by updated_at desc + limit 1 + ` + const id = rows[0]?.id + if (!id) return null + const now = new Date().toISOString() + await db` + update site_branch_merge_requests + set status = 'open', resolved_by_user_id = null, resolved_at = null, resolution_note = '', updated_at = ${now} + where id = ${id} + ` + return getMergeRequestById(db, id) +} diff --git a/src/__tests__/server/branchMerge.test.ts b/src/__tests__/server/branchMerge.test.ts index a0885fd55..97bced3bf 100644 --- a/src/__tests__/server/branchMerge.test.ts +++ b/src/__tests__/server/branchMerge.test.ts @@ -5,7 +5,7 @@ */ import { afterEach, describe, expect, it } from 'bun:test' import { MAIN_SCOPE } from '../../../server/branches/scope' -import { applyBranchMerge, planBranchMerge } from '../../../server/branches/merge' +import { MergeUndoError, applyBranchMerge, planBranchMerge, undoBranchMerge } from '../../../server/branches/merge' import { getDataRow, listDataRows, saveDataRowDraft, softDeleteDataRow, upsertDataRowDraft } from '../../../server/repositories/data' import { getDraftSite, saveDraftSite } from '../../../server/repositories/site' import { @@ -176,6 +176,85 @@ describe('branch merge', () => { const remaining = await readJson<{ branches: Array<{ id: string }> }>(await harness.cms(BRANCHES, { cookie: owner })) expect(remaining.branches.map((branch) => branch.id)).toEqual(['main']) }) + + it('records every apply and undoes the latest one, putting main and the base back', async () => { + harness = await createCapabilityTestHarness() + const owner = await harness.setupOwner() + const branchId = await forkViaApi(harness, owner, 'Undo Me') + const branch = { branchId } + const [home] = await listDataRows(harness.db, branch, 'pages') + const originalTitle = home!.cells.title + await saveDataRowDraft(harness.db, branch, home!.id, { + cells: { ...home!.cells, title: 'Merged title' }, + slug: home!.slug, + }) + await upsertDataRowDraft(harness.db, branch, { + id: 'undo-post', + tableId: 'posts', + cells: { title: 'Merged post', slug: 'merged-post' }, + slug: 'merged-post', + }) + + const applied = await applyBranchMerge(harness.db, { branchId, direction: 'merge', resolutions: {}, actorUserId: null }) + expect(applied.merge).toMatchObject({ branchId, direction: 'merge', changeCount: 2, undoneAt: null }) + expect((await getDataRow(harness.db, MAIN_SCOPE, home!.id))!.cells.title).toBe('Merged title') + + const undone = await undoBranchMerge(harness.db, { branchId, direction: 'merge', actorUserId: null }) + expect(undone.restoredCount).toBe(2) + expect(undone.merge.undoneAt).not.toBeNull() + expect((await getDataRow(harness.db, MAIN_SCOPE, home!.id))!.cells.title).toBe(originalTitle) + expect(await getDataRow(harness.db, MAIN_SCOPE, 'undo-post')).toBeNull() + // The branch keeps its work and the base is back where the fork put it, + // so the very same plan comes back. + expect((await getDataRow(harness.db, branch, 'undo-post'))!.cells.title).toBe('Merged post') + const replanned = await planBranchMerge(harness.db, branchId, 'merge') + expect(replanned.plan.changes.map((change) => `${change.action} ${change.label}`).sort()).toEqual([ + 'create Merged post', + 'update Merged title', + ]) + // Undone is undone: a second undo has nothing to reverse. + await expect(undoBranchMerge(harness.db, { branchId, direction: 'merge', actorUserId: null })).rejects.toBeInstanceOf(MergeUndoError) + + // Main edited after a merge: the undo is refused and main keeps the edit. + await applyBranchMerge(harness.db, { branchId, direction: 'merge', resolutions: {}, actorUserId: null }) + const merged = (await getDataRow(harness.db, MAIN_SCOPE, home!.id))! + await saveDataRowDraft(harness.db, MAIN_SCOPE, home!.id, { + cells: { ...merged.cells, title: 'Edited on main after' }, + slug: merged.slug, + }) + await expect(undoBranchMerge(harness.db, { branchId, direction: 'merge', actorUserId: null })).rejects.toBeInstanceOf(MergeUndoError) + expect((await getDataRow(harness.db, MAIN_SCOPE, home!.id))!.cells.title).toBe('Edited on main after') + }) + + it('exposes undo over HTTP behind the merge gates and reports a moved target as 409', async () => { + harness = await createCapabilityTestHarness() + const owner = await harness.setupOwner() + const branchId = await forkViaApi(harness, owner, 'Undo Http') + await upsertDataRowDraft(harness.db, { branchId }, { + id: 'undo-http', + tableId: 'posts', + cells: { title: 'Undo HTTP', slug: 'undo-http' }, + slug: 'undo-http', + }) + const applied = await harness.cms(`${BRANCHES}/${branchId}/merge`, { method: 'POST', cookie: owner, json: {} }) + expect(applied.status).toBe(200) + expect(await readJson<{ merge: { changeCount: number } | null }>(applied)).toMatchObject({ merge: { changeCount: 1 } }) + + const manager = await harness.createRoleUser({ + name: 'Undoer', + slug: 'undoer', + capabilities: ['site.read', 'site.branches.manage'], + }) + await expectStepUpRequired( + await harness.cms(`${BRANCHES}/${branchId}/merge/undo`, { method: 'POST', cookie: manager.cookie }), + ) + const undone = await harness.cms(`${BRANCHES}/${branchId}/merge/undo`, { method: 'POST', cookie: owner }) + expect(undone.status).toBe(200) + expect(await readJson<{ restoredCount: number }>(undone)).toMatchObject({ restoredCount: 1 }) + expect(await getDataRow(harness.db, MAIN_SCOPE, 'undo-http')).toBeNull() + const again = await harness.cms(`${BRANCHES}/${branchId}/merge/undo`, { method: 'POST', cookie: owner }) + expect(again.status).toBe(409) + }) }) describe('branch merge — direction and base bookkeeping', () => { diff --git a/src/__tests__/server/collabRelayIntegration.test.ts b/src/__tests__/server/collabRelayIntegration.test.ts index 6fccacd10..83236f97b 100644 --- a/src/__tests__/server/collabRelayIntegration.test.ts +++ b/src/__tests__/server/collabRelayIntegration.test.ts @@ -207,6 +207,38 @@ describe('collab relay integration (real server, real sockets)', () => { }) }) + it('keeps the cells the doc does not own when it persists the derived row', async () => { + const stack = await startStack() + const docId = `page:main:${stack.homeId}` + // SEO lives on the row, never in the doc. Seeded as a collab-internal + // write so the roster's already-loaded doc is not reset under the client; + // what is under test is what the relay's own persist keeps on the row. + const seeded = (await getDataRow(stack.harness.db, MAIN_SCOPE, stack.homeId))! + await saveDataRowDraft( + stack.harness.db, + MAIN_SCOPE, + stack.homeId, + { cells: { ...seeded.cells, seoTitle: 'Kept title', seoDescription: 'Kept description' }, slug: seeded.slug }, + null, + null, + { collabInternal: true }, + ) + + const client = connectClient(stack) + const bound = client.bind(docId) + await bound.whenSynced + const rootId = treeMap(bound.doc).get('rootNodeId') as string + setNodeLabel(bound.doc, rootId, 'Edited in the doc') + + await waitFor(async () => { + const row = await getDataRow(stack.harness.db, MAIN_SCOPE, stack.homeId) + return row !== null && pageFromRow(row).nodes[rootId]?.label === 'Edited in the doc' + }) + const row = (await getDataRow(stack.harness.db, MAIN_SCOPE, stack.homeId))! + expect(row.cells.seoTitle).toBe('Kept title') + expect(row.cells.seoDescription).toBe('Kept description') + }) + it('refuses a read-only edit AND resets the viewer so its own screen reverts', async () => { const stack = await startStack() const docId = `page:main:${stack.homeId}` diff --git a/src/admin/pages/branches/BranchReviewPage.module.css b/src/admin/pages/branches/BranchReviewPage.module.css index 90dd7d3e5..11ac9414d 100644 --- a/src/admin/pages/branches/BranchReviewPage.module.css +++ b/src/admin/pages/branches/BranchReviewPage.module.css @@ -319,9 +319,11 @@ .highlight[data-tone="removed"] .highlightLabel { background: var(--danger-light); } .swipe { display: grid; gap: var(--space-xs); } -.swipeStack { position: relative; } -.swipeTop { position: absolute; inset: 0; clip-path: inset(0 calc(100% - var(--split)) 0 0); } -.swipeLine { position: absolute; top: 0; bottom: 0; left: var(--split); width: 2px; background: var(--warning); transform: translateX(-1px); pointer-events: none; } +.swipeStack { position: relative; cursor: ew-resize; touch-action: none; user-select: none; border-radius: var(--panel-radius); overflow: hidden; } +.swipeTop { position: absolute; inset: 0; clip-path: inset(0 calc(100% - var(--split)) 0 0); pointer-events: none; } +.swipeLine { position: absolute; top: 0; bottom: 0; left: var(--split); width: 3px; background: var(--warning); transform: translateX(-50%); pointer-events: none; } +.swipeHandle { position: absolute; top: 50%; left: var(--split); width: 32px; height: 32px; transform: translate(-50%, -50%); border-radius: 999px; background: var(--warning); display: grid; place-items: center; pointer-events: none; box-shadow: 0 2px 8px var(--scrim-40); } +.swipeHandleGrip { width: 10px; height: 14px; border-left: 2px solid var(--bg-body); border-right: 2px solid var(--bg-body); } .swipeTagLeft, .swipeTagRight { position: absolute; top: 8px; padding: 2px 8px; border-radius: 999px; font-size: var(--text-3xs); font-weight: 700; color: var(--bg-body); background: var(--text); pointer-events: none; } .swipeTagLeft { left: 8px; } .swipeTagRight { right: 8px; } diff --git a/src/admin/pages/branches/BranchReviewPage.tsx b/src/admin/pages/branches/BranchReviewPage.tsx index 661939a4d..565572fbc 100644 --- a/src/admin/pages/branches/BranchReviewPage.tsx +++ b/src/admin/pages/branches/BranchReviewPage.tsx @@ -19,18 +19,18 @@ import { hasCapability } from '@admin/access' import { useAuthenticatedAdminUser } from '@admin/sessionContext' import { mergeBranch, refreshBranches, switchBranch, useActiveBranchId, useBranchStore } from '@admin/state/branchStore' import { StepUpCancelledMessage, useStepUp } from '@admin/shared/StepUp' +import { useConfirmAction } from '@admin/shared/dialogs/ConfirmDeleteDialog' import { UserAvatar } from '@admin/shared/UserAvatar/UserAvatar' import { Button } from '@ui/components/Button' import { Dialog } from '@ui/components/Dialog' import { FilterBar } from '@ui/components/FilterBar' import { Textarea } from '@ui/components/Input' import { Skeleton } from '@ui/components/Skeleton' -import { Switch } from '@ui/components/Switch' import { TagPill } from '@ui/components/TagPill' import { pushToast } from '@ui/components/Toast' import { CheckIcon } from 'pixel-art-icons/icons/check' -import { GitMergeSolidIcon } from 'pixel-art-icons/icons/git-merge-solid' import { ReviewChangeCard } from './ReviewChangeCard' +import { ReviewFooter } from './ReviewFooter' import { ReviewThread } from './ReviewThread' import { FILTER_LABELS, @@ -101,10 +101,12 @@ function Review({ branchId, branchName }: ReviewProps) { const user = useAuthenticatedAdminUser() const canManage = hasCapability(user, 'site.branches.manage') const { runStepUp } = useStepUp() + const confirmAction = useConfirmAction() const data = useBranchReview(branchId) const [filter, setFilter] = useState<ReviewFilter>('all') const [resolutions, setResolutions] = useState<Record<string, MergeResolution>>({}) - const [deleteAfter, setDeleteAfter] = useState(true) + // Off by default: a merge that deletes the branch cannot be undone. + const [deleteAfter, setDeleteAfter] = useState(false) const [dialog, setDialog] = useState<'request' | 'decline' | null>(null) const [busy, setBusy] = useState(false) @@ -186,18 +188,45 @@ function Review({ branchId, branchName }: ReviewProps) { } } - async function merge(): Promise<void> { + function merge(): void { if (unresolved.length > 0 || loadedPlan.changes.length === 0) return - const done = await withBusy('merge the branch', async () => { + const count = loadedPlan.changes.length + const changes = `${count} change${count === 1 ? '' : 's'}` + confirmAction({ + title: `Merge ${branchName} into main?`, + description: deleteAfter + ? `${changes} land in main's draft and the branch is deleted, so this cannot be undone. Nothing is published.` + : `${changes} land in main's draft. Nothing is published, and the merge can be undone from this page while main stays as merged.`, + confirmLabel: 'Merge into main', + commit: () => { void runMerge() }, + }) + } + + async function runMerge(): Promise<void> { + await withBusy('merge the branch', async () => { const result = await runStepUp(() => mergeBranch(branchId, 'merge', { resolutions, deleteBranch: deleteAfter })) const count = result.plan.changes.length pushToast({ kind: 'success', title: `Merged ${branchName} into main`, body: `${count} change${count === 1 ? '' : 's'} landed in main's draft. Publish when you're ready.${result.branchDeleted ? ' The branch was deleted.' : ''}`, + ...(result.merge ? { action: { label: 'Undo', onSelect: () => { void undo() } } } : {}), + }) + if (result.branchDeleted) navigate('/admin/site') + else await data.reload() + }) + } + + async function undo(): Promise<void> { + await withBusy('undo the merge', async () => { + const result = await runStepUp(() => data.undo()) + const count = result.restoredCount + pushToast({ + kind: 'success', + title: `Undid the merge of ${branchName}`, + body: `${count} change${count === 1 ? '' : 's'} put back. Main's draft is as it was before the merge.`, }) }) - if (done) navigate('/admin/site') } const changeCountLabel = `${plan.changes.length} change${plan.changes.length === 1 ? '' : 's'}` @@ -469,70 +498,22 @@ function Review({ branchId, branchName }: ReviewProps) { </div> </div> - <footer className={styles.footer} data-testid="branch-review-footer"> - {canManage ? ( - <> - <label className={styles.footerToggle}> - <Switch checked={deleteAfter} onCheckedChange={setDeleteAfter} switchSize="sm" aria-label="Delete branch after merging" data-testid="review-delete-toggle" /> - <span>Delete branch after merging</span> - </label> - <span className={styles.footerStatus}> - {plan.changes.length === 0 - ? '' - : unresolved.length > 0 - ? `${unresolved.length} conflict${unresolved.length === 1 ? '' : 's'} still need${unresolved.length === 1 ? 's' : ''} a decision.` - : `Merging writes ${changeCountLabel} to main's draft.`} - </span> - {open && ( - <Button variant="secondary" size="sm" type="button" disabled={busy} onClick={() => setDialog('decline')} data-testid="review-decline-open"> - Decline… - </Button> - )} - <Button - variant="primary" - size="sm" - type="button" - busy={busy} - disabled={busy || plan.changes.length === 0 || unresolved.length > 0} - tooltip={plan.changes.length === 0 ? 'Nothing to merge' : unresolved.length > 0 ? `${unresolved.length} conflict${unresolved.length === 1 ? '' : 's'} still need a decision` : undefined} - onClick={() => { void merge() }} - data-testid="review-merge" - > - <GitMergeSolidIcon size={12} aria-hidden="true" /> - <span>Merge {changeCountLabel}</span> - </Button> - </> - ) : open ? ( - <> - <span className={styles.footerStatus}>Waiting for a branch manager to review.</span> - {request.requestedBy?.id === user.id && ( - <Button - variant="secondary" - size="sm" - type="button" - busy={busy} - onClick={() => { void withBusy('withdraw the request', () => data.withdraw()) }} - data-testid="review-withdraw" - > - Withdraw request - </Button> - )} - </> - ) : ( - <> - <span className={styles.footerStatus}> - {request?.status === 'declined' - ? 'Fix what the note asks for, then request a merge again.' - : request?.status === 'merged' - ? 'The last request was merged. New work on this branch can be requested again.' - : 'Request a merge when the branch is ready for review.'} - </span> - <Button variant="primary" size="sm" type="button" disabled={busy || plan.changes.length === 0} tooltip={plan.changes.length === 0 ? 'Nothing to merge yet' : undefined} onClick={() => setDialog('request')} data-testid="review-request-open"> - {request?.status === 'declined' ? 'Request merge again…' : 'Request merge…'} - </Button> - </> - )} - </footer> + <ReviewFooter + canManage={canManage} + plan={plan} + request={request} + lastMerge={review.lastMerge} + unresolvedCount={unresolved.length} + busy={busy} + userId={user.id} + deleteAfter={deleteAfter} + onDeleteAfterChange={setDeleteAfter} + onMerge={merge} + onUndo={() => { void undo() }} + onDecline={() => setDialog('decline')} + onWithdraw={() => { void withBusy('withdraw the request', () => data.withdraw()) }} + onRequest={() => setDialog('request')} + /> </div> {dialog === 'request' && ( diff --git a/src/admin/pages/branches/PageCompare.tsx b/src/admin/pages/branches/PageCompare.tsx index 048bcf877..876189853 100644 --- a/src/admin/pages/branches/PageCompare.tsx +++ b/src/admin/pages/branches/PageCompare.tsx @@ -7,11 +7,16 @@ * by their `uid` attribute and outlined in place, so the highlights come * from the tree diff, not from guesses. Side by side, a swipe with one * frame clipped over the other, or the plain change list. + * + * Rendered ids are the COMPOSED ids (a page spliced into its template + * chain gets a prefix), so every `uid` is resolved back to its page node + * through `composedNodeSourceId` before it is matched against the plan. */ -import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react' +import { useCallback, useEffect, useRef, useState, type CSSProperties, type PointerEvent } from 'react' import type { MergeTreeDiff, ReviewRenderSide } from '@core/branches' import { apiTextRequest, isAbortError } from '@core/http' import { cmsBranchReviewRenderUrl } from '@core/persistence' +import { composedNodeSourceId } from '@core/templates' import { getErrorMessage } from '@core/utils/errorMessage' import { SegmentedControl } from '@ui/components/SegmentedControl' import { Switch } from '@ui/components/Switch' @@ -22,7 +27,7 @@ const MIN_HEIGHT = 360 const MAX_HEIGHT = 2400 interface HighlightBox { - id: string + key: string label: string tone: 'added' | 'changed' | 'removed' x: number @@ -41,6 +46,25 @@ interface FrameProps { showHighlights: boolean } +/** + * Every rendered element that carries a node id, keyed by the PAGE node id + * it came from. A node inside a loop renders once per item, so one id can + * map to several elements; all of them are outlined. + */ +function elementsByNodeId(doc: Document): Map<string, HTMLElement[]> { + const byId = new Map<string, HTMLElement[]>() + const HTMLElementCtor = doc.defaultView?.HTMLElement + if (!HTMLElementCtor) return byId + for (const element of doc.querySelectorAll('[uid]')) { + if (!(element instanceof HTMLElementCtor)) continue + const uid = element.getAttribute('uid') + if (!uid) continue + const id = composedNodeSourceId(uid) + byId.set(id, [...(byId.get(id) ?? []), element]) + } + return byId +} + function ScaledFrame({ branchId, rowId, side, title, marks, showHighlights }: FrameProps) { const hostRef = useRef<HTMLDivElement | null>(null) const frameRef = useRef<HTMLIFrameElement | null>(null) @@ -92,21 +116,24 @@ function ScaledFrame({ branchId, rowId, side, title, marks, showHighlights }: Fr if (!frame || !doc?.documentElement) return const height = Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, doc.documentElement.scrollHeight)) setDocHeight(height) + const byId = elementsByNodeId(doc) + const scrollX = doc.defaultView?.scrollX ?? 0 + const scrollY = doc.defaultView?.scrollY ?? 0 const next: HighlightBox[] = [] for (const mark of marksRef.current) { - const element = doc.querySelector(`[uid="${CSS.escape(mark.id)}"]`) - if (!(element instanceof doc.defaultView!.HTMLElement)) continue - const rect = element.getBoundingClientRect() - if (rect.width === 0 && rect.height === 0) continue - next.push({ - id: mark.id, - label: mark.label, - tone: mark.tone, - x: rect.left + (doc.defaultView?.scrollX ?? 0), - y: rect.top + (doc.defaultView?.scrollY ?? 0), - width: rect.width, - height: rect.height, - }) + for (const [index, element] of (byId.get(mark.id) ?? []).entries()) { + const rect = element.getBoundingClientRect() + if (rect.width === 0 && rect.height === 0) continue + next.push({ + key: `${mark.id}:${index}`, + label: mark.label, + tone: mark.tone, + x: rect.left + scrollX, + y: rect.top + scrollY, + width: rect.width, + height: rect.height, + }) + } } setBoxes(next) setLoaded(true) @@ -145,7 +172,7 @@ function ScaledFrame({ branchId, rowId, side, title, marks, showHighlights }: Fr )} {showHighlights && boxes.map((box) => ( <span - key={box.id} + key={box.key} className={styles.highlight} data-tone={box.tone} style={{ '--hl-x': `${box.x}px`, '--hl-y': `${box.y}px`, '--hl-w': `${box.width}px`, '--hl-h': `${box.height}px` } as CSSProperties} @@ -169,6 +196,17 @@ function markLabel(verb: string, nodeLabel: string | undefined): string { return `${verb} · ${nodeLabel}` } +/** "Changed text: “a” → “b”" when the plan knows what moved; "Changed text" when it does not. */ +function changedLine(tree: MergeTreeDiff, id: string): string { + const head = `Changed ${tree.labels[id] ?? id}` + const details = tree.details[id] ?? [] + return details.length > 0 ? `${head}: ${details.join('; ')}` : head +} + +function clampPercent(value: number): number { + return Math.min(100, Math.max(0, value)) +} + interface PageCompareProps { branchId: string rowId: string @@ -184,6 +222,7 @@ export function PageCompare({ branchId, rowId, label, action, tree, fieldLines, const [mode, setMode] = useState<Mode>('side') const [showHighlights, setShowHighlights] = useState(true) const [split, setSplit] = useState(50) + const stackRef = useRef<HTMLDivElement | null>(null) const hasMain = action !== 'create' const hasBranch = action !== 'delete' const bothSides = hasMain && hasBranch @@ -198,12 +237,33 @@ export function PageCompare({ branchId, rowId, label, action, tree, fieldLines, const treeLines = tree ? [ ...tree.added.map((id) => `Added ${tree.labels[id] ?? id}`), - ...tree.changed.map((id) => `Changed ${tree.labels[id] ?? id}`), + ...tree.changed.map((id) => changedLine(tree, id)), ...tree.removed.map((id) => `Removed ${tree.labels[id] ?? id}`), ] : [] const lines = [...fieldLines, ...treeLines] + // Drag anywhere on the stack to move the divider; the frames ignore the + // pointer, so the stack sees every event. The range below keeps the + // keyboard path. + function splitFromPointer(event: PointerEvent<HTMLDivElement>): number { + const rect = stackRef.current?.getBoundingClientRect() + if (!rect || rect.width === 0) return split + return clampPercent(((event.clientX - rect.left) / rect.width) * 100) + } + function onStackPointerDown(event: PointerEvent<HTMLDivElement>): void { + if (event.button !== 0) return + event.currentTarget.setPointerCapture(event.pointerId) + setSplit(splitFromPointer(event)) + } + function onStackPointerMove(event: PointerEvent<HTMLDivElement>): void { + if (!event.currentTarget.hasPointerCapture(event.pointerId)) return + setSplit(splitFromPointer(event)) + } + function onStackPointerUp(event: PointerEvent<HTMLDivElement>): void { + if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId) + } + return ( <div className={styles.compare}> <div className={styles.compareBar}> @@ -242,12 +302,24 @@ export function PageCompare({ branchId, rowId, label, action, tree, fieldLines, ) ) : mode === 'swipe' && bothSides ? ( <div className={styles.swipe}> - <div className={styles.swipeStack} style={{ '--split': `${split}%` } as CSSProperties}> + <div + ref={stackRef} + className={styles.swipeStack} + style={{ '--split': `${split}%` } as CSSProperties} + onPointerDown={onStackPointerDown} + onPointerMove={onStackPointerMove} + onPointerUp={onStackPointerUp} + onPointerCancel={onStackPointerUp} + data-testid="review-swipe-stack" + > <ScaledFrame branchId={branchId} rowId={rowId} side="branch" title={`${label} on the branch`} marks={branchMarks} showHighlights={showHighlights} /> <div className={styles.swipeTop}> <ScaledFrame branchId={branchId} rowId={rowId} side="main" title={`${label} on main`} marks={mainMarks} showHighlights={showHighlights} /> </div> <span className={styles.swipeLine} /> + <span className={styles.swipeHandle} aria-hidden="true"> + <span className={styles.swipeHandleGrip} /> + </span> <span className={styles.swipeTagLeft}>{mainLabel}</span> <span className={styles.swipeTagRight}>Branch</span> </div> diff --git a/src/admin/pages/branches/ReviewChangeCard.tsx b/src/admin/pages/branches/ReviewChangeCard.tsx index 0c62a6e45..b6865f9a3 100644 --- a/src/admin/pages/branches/ReviewChangeCard.tsx +++ b/src/admin/pages/branches/ReviewChangeCard.tsx @@ -90,7 +90,7 @@ function FieldTable({ fields, action }: { fields: MergeFieldChange[]; action: Me function fieldLines(fields: MergeFieldChange[]): string[] { return fields.map((field) => { if (field.before === null) return `${field.label}: set to “${field.after ?? ''}”` - if (field.after === null) return `${field.label}: cleared` + if (field.after === null) return `${field.label}: cleared (was “${field.before}”)` return `${field.label}: “${field.before}” → “${field.after}”` }) } @@ -130,7 +130,15 @@ export function ReviewChangeCard({ branchId, change, resolution, canResolve, onR {detail.tree && ( <ul className={styles.changeList}> {detail.tree.added.map((id) => <li key={`a-${id}`}>Added {detail.tree!.labels[id] ?? id}</li>)} - {detail.tree.changed.map((id) => <li key={`c-${id}`}>Changed {detail.tree!.labels[id] ?? id}</li>)} + {detail.tree.changed.map((id) => { + const details = detail.tree!.details[id] ?? [] + return ( + <li key={`c-${id}`}> + Changed {detail.tree!.labels[id] ?? id} + {details.length > 0 && `: ${details.join('; ')}`} + </li> + ) + })} {detail.tree.removed.map((id) => <li key={`r-${id}`}>Removed {detail.tree!.labels[id] ?? id}</li>)} </ul> )} diff --git a/src/admin/pages/branches/ReviewFooter.tsx b/src/admin/pages/branches/ReviewFooter.tsx new file mode 100644 index 000000000..b9a0a3ed4 --- /dev/null +++ b/src/admin/pages/branches/ReviewFooter.tsx @@ -0,0 +1,133 @@ +/** + * ReviewFooter — the review's decision bar. A manager decides here (keep or + * delete the branch after merging, undo the last merge, decline, merge); a + * requester waits or withdraws; everyone else asks for a merge. Presentation + * only: every action is a callback the page owns, and the page runs the + * confirmations, step-ups, and toasts. + */ +import type { BranchMergeRecord, BranchMergeRequest, MergePlan } from '@core/branches' +import { Button } from '@ui/components/Button' +import { Switch } from '@ui/components/Switch' +import { GitMergeSolidIcon } from 'pixel-art-icons/icons/git-merge-solid' +import { relativeIsoAgo } from './reviewFormat' +import styles from './BranchReviewPage.module.css' + +interface ReviewFooterProps { + canManage: boolean + plan: MergePlan + request: BranchMergeRequest | null + /** The newest merge into main not yet undone; shows the undo control. */ + lastMerge: BranchMergeRecord | null + unresolvedCount: number + busy: boolean + userId: string + deleteAfter: boolean + onDeleteAfterChange: (value: boolean) => void + onMerge: () => void + onUndo: () => void + onDecline: () => void + onWithdraw: () => void + onRequest: () => void +} + +export function ReviewFooter({ + canManage, + plan, + request, + lastMerge, + unresolvedCount, + busy, + userId, + deleteAfter, + onDeleteAfterChange, + onMerge, + onUndo, + onDecline, + onWithdraw, + onRequest, +}: ReviewFooterProps) { + const open = request?.status === 'open' + const changeCountLabel = `${plan.changes.length} change${plan.changes.length === 1 ? '' : 's'}` + + return ( + <footer className={styles.footer} data-testid="branch-review-footer"> + {canManage ? ( + <> + <label className={styles.footerToggle}> + <Switch checked={deleteAfter} onCheckedChange={onDeleteAfterChange} switchSize="sm" aria-label="Delete branch after merging" data-testid="review-delete-toggle" /> + <span>Delete branch after merging{deleteAfter ? ' (cannot be undone)' : ''}</span> + </label> + <span className={styles.footerStatus}> + {plan.changes.length === 0 + ? lastMerge + ? `Merged ${relativeIsoAgo(lastMerge.createdAt)}. Undo puts main back while nothing on main has changed since.` + : '' + : unresolvedCount > 0 + ? `${unresolvedCount} conflict${unresolvedCount === 1 ? '' : 's'} still need${unresolvedCount === 1 ? 's' : ''} a decision.` + : `Merging writes ${changeCountLabel} to main's draft.`} + </span> + {lastMerge && ( + <Button + variant="secondary" + size="sm" + type="button" + disabled={busy} + tooltip="Put main back the way it was before this merge" + onClick={onUndo} + data-testid="review-undo-merge" + > + Undo merge + </Button> + )} + {open && ( + <Button variant="secondary" size="sm" type="button" disabled={busy} onClick={onDecline} data-testid="review-decline-open"> + Decline… + </Button> + )} + <Button + variant="primary" + size="sm" + type="button" + busy={busy} + disabled={busy || plan.changes.length === 0 || unresolvedCount > 0} + tooltip={plan.changes.length === 0 ? 'Nothing to merge' : unresolvedCount > 0 ? `${unresolvedCount} conflict${unresolvedCount === 1 ? '' : 's'} still need a decision` : undefined} + onClick={onMerge} + data-testid="review-merge" + > + <GitMergeSolidIcon size={12} aria-hidden="true" /> + <span>Merge {changeCountLabel}</span> + </Button> + </> + ) : open ? ( + <> + <span className={styles.footerStatus}>Waiting for a branch manager to review.</span> + {request.requestedBy?.id === userId && ( + <Button + variant="secondary" + size="sm" + type="button" + busy={busy} + onClick={onWithdraw} + data-testid="review-withdraw" + > + Withdraw request + </Button> + )} + </> + ) : ( + <> + <span className={styles.footerStatus}> + {request?.status === 'declined' + ? 'Fix what the note asks for, then request a merge again.' + : request?.status === 'merged' + ? 'The last request was merged. New work on this branch can be requested again.' + : 'Request a merge when the branch is ready for review.'} + </span> + <Button variant="primary" size="sm" type="button" disabled={busy || plan.changes.length === 0} tooltip={plan.changes.length === 0 ? 'Nothing to merge yet' : undefined} onClick={onRequest} data-testid="review-request-open"> + {request?.status === 'declined' ? 'Request merge again…' : 'Request merge…'} + </Button> + </> + )} + </footer> + ) +} diff --git a/src/admin/pages/branches/useBranchReview.ts b/src/admin/pages/branches/useBranchReview.ts index ba75be64c..169592461 100644 --- a/src/admin/pages/branches/useBranchReview.ts +++ b/src/admin/pages/branches/useBranchReview.ts @@ -5,7 +5,7 @@ * copy from the server's response so the page never guesses. */ import { useEffect, useState } from 'react' -import type { BranchMergeRequest, BranchReviewComment, BranchReviewState, MergePlan } from '@core/branches' +import type { BranchMergeRequest, BranchReviewComment, BranchReviewState, MergePlan, UndoMergeEnvelope } from '@core/branches' import { isAbortError } from '@core/http' import { addCmsBranchReviewComment, @@ -16,6 +16,7 @@ import { withdrawCmsBranchMergeRequest, } from '@core/persistence' import { getErrorMessage } from '@core/utils/errorMessage' +import { undoBranchMerge } from '@admin/state/branchStore' export interface BranchReviewData { plan: MergePlan | null @@ -27,6 +28,8 @@ export interface BranchReviewData { withdraw: () => Promise<BranchMergeRequest> decline: (note: string) => Promise<BranchMergeRequest> comment: (entityKey: string, body: string) => Promise<BranchReviewComment> + /** Reverse the latest merge into main, then reload: the plan is live again. */ + undo: () => Promise<UndoMergeEnvelope> } async function fetchReviewData( @@ -103,5 +106,10 @@ export function useBranchReview(branchId: string): BranchReviewData { setReview((current) => (current ? { ...current, comments: [...current.comments, comment] } : current)) return comment }, + undo: async () => { + const result = await undoBranchMerge(branchId, 'merge') + await reload() + return result + }, } } diff --git a/src/admin/shared/BranchSwitcher/BranchContextStrip.tsx b/src/admin/shared/BranchSwitcher/BranchContextStrip.tsx index fd9be24b0..f2c884b95 100644 --- a/src/admin/shared/BranchSwitcher/BranchContextStrip.tsx +++ b/src/admin/shared/BranchSwitcher/BranchContextStrip.tsx @@ -8,7 +8,7 @@ */ import { Suspense, lazy, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' -import { useNavigate } from '@admin/lib/routing' +import { useLocation, useNavigate } from '@admin/lib/routing' import { ArrowDownIcon } from 'pixel-art-icons/icons/arrow-down' import { CircleDotSolidIcon } from 'pixel-art-icons/icons/circle-dot-solid' import { EditSolidIcon } from 'pixel-art-icons/icons/edit-solid' @@ -66,6 +66,9 @@ function BranchStripBody({ branch: current }: { branch: SiteBranch }) { const canManage = hasCapability(user, 'site.branches.manage') const canAuthor = canActOnBranch(user, current) const navigate = useNavigate() + // The review page carries the real merge control in its footer, so the + // strip's button only leads there and steps aside once you have arrived. + const onReviewPage = useLocation().pathname.startsWith('/admin/branches/') const openManage = useBranchStore((state) => state.openManage) const [moreOpen, setMoreOpen] = useState(false) const [deleting, setDeleting] = useState(false) @@ -158,18 +161,20 @@ function BranchStripBody({ branch: current }: { branch: SiteBranch }) { </Button> )} - <Button - variant="primary" - size="xs" - type="button" - data-testid="branch-strip-merge" - tooltip={canManage ? 'Review every change, then merge' : 'Review the changes and request a merge'} - tooltipSide="bottom" - onClick={() => navigate(`/admin/branches/${encodeURIComponent(current.id)}/review`)} - > - <GitMergeSolidIcon size={12} aria-hidden="true" /> - <span>{canManage ? 'Merge into main…' : 'Request merge…'}</span> - </Button> + {!onReviewPage && ( + <Button + variant="primary" + size="xs" + type="button" + data-testid="branch-strip-merge" + tooltip={canManage ? 'Open the merge review; merging happens there' : 'Review the changes and request a merge'} + tooltipSide="bottom" + onClick={() => navigate(`/admin/branches/${encodeURIComponent(current.id)}/review`)} + > + <GitMergeSolidIcon size={12} aria-hidden="true" /> + <span>{canManage ? 'Review merge…' : 'Request merge…'}</span> + </Button> + )} <Button ref={moreRef} diff --git a/src/admin/shared/BranchSwitcher/UpdateBranchDialog.tsx b/src/admin/shared/BranchSwitcher/UpdateBranchDialog.tsx index dc577136a..2a9ca8118 100644 --- a/src/admin/shared/BranchSwitcher/UpdateBranchDialog.tsx +++ b/src/admin/shared/BranchSwitcher/UpdateBranchDialog.tsx @@ -23,6 +23,7 @@ import { getCmsBranchMergePlan } from '@core/persistence' import { getErrorMessage } from '@core/utils/errorMessage' import { mergeBranch } from '@admin/state/branchStore' import { StepUpCancelledMessage, useStepUp } from '@admin/shared/StepUp' +import { useConfirmAction } from '@admin/shared/dialogs/ConfirmDeleteDialog' import { Button } from '@ui/components/Button' import { Dialog } from '@ui/components/Dialog' import { SegmentedControl } from '@ui/components/SegmentedControl' @@ -66,6 +67,7 @@ function describeConflicts(conflicts: string[]): string { export function UpdateBranchDialog({ branch, onClose }: UpdateBranchDialogProps) { const { runStepUp } = useStepUp() + const confirmAction = useConfirmAction() const [plan, setPlan] = useState<MergePlan | null>(null) const [loadError, setLoadError] = useState<string | null>(null) const [resolutions, setResolutions] = useState<Record<string, MergeResolution>>({}) @@ -95,8 +97,18 @@ export function UpdateBranchDialog({ branch, onClose }: UpdateBranchDialogProps) : 0 const total = plan?.changes.length ?? 0 - async function apply(): Promise<void> { + function apply(): void { if (!plan || busy || unresolved > 0) return + confirmAction({ + title: `Update ${branch.name} from main?`, + description: `${total} change${total === 1 ? '' : 's'} from main will be written over the branch's draft. Undo is offered right after, as long as the branch is not edited in between.`, + confirmLabel: 'Update branch', + commit: () => { void applyUpdate() }, + }) + } + + async function applyUpdate(): Promise<void> { + if (!plan) return setBusy(true) try { const result = await runStepUp(() => mergeBranch(branch.id, 'update', { resolutions })) @@ -143,7 +155,7 @@ export function UpdateBranchDialog({ branch, onClose }: UpdateBranchDialogProps) disabled={!plan || total === 0 || unresolved > 0} tooltip={unresolved > 0 ? `${unresolved} conflict${unresolved === 1 ? '' : 's'} still need a decision` : undefined} data-testid="branch-merge-apply" - onClick={() => { void apply() }} + onClick={apply} > <ArrowDownIcon size={12} aria-hidden="true" /> <span>{`Update with ${total} change${total === 1 ? '' : 's'}`}</span> diff --git a/src/admin/shared/dialogs/ConfirmDeleteDialog/ConfirmDeleteContext.tsx b/src/admin/shared/dialogs/ConfirmDeleteDialog/ConfirmDeleteContext.tsx index 82ba0edbe..0a9c54874 100644 --- a/src/admin/shared/dialogs/ConfirmDeleteDialog/ConfirmDeleteContext.tsx +++ b/src/admin/shared/dialogs/ConfirmDeleteDialog/ConfirmDeleteContext.tsx @@ -58,6 +58,7 @@ export function ConfirmDeleteProvider({ children }: { children: ReactNode }) { title={pending.request.title} description={pending.request.description} confirmLabel={pending.request.confirmLabel} + tone={pending.request.tone} onCancel={handleCancel} onConfirm={handleConfirm} /> diff --git a/src/admin/shared/dialogs/ConfirmDeleteDialog/ConfirmDeleteDialog.tsx b/src/admin/shared/dialogs/ConfirmDeleteDialog/ConfirmDeleteDialog.tsx index cd3016b23..b761c556d 100644 --- a/src/admin/shared/dialogs/ConfirmDeleteDialog/ConfirmDeleteDialog.tsx +++ b/src/admin/shared/dialogs/ConfirmDeleteDialog/ConfirmDeleteDialog.tsx @@ -26,6 +26,8 @@ interface ConfirmDeleteDialogProps { cancelLabel?: string onCancel: () => void onConfirm: () => void + /** `danger` (default) or `primary`; see `ConfirmDeleteRequest.tone`. */ + tone?: 'danger' | 'primary' } export function ConfirmDeleteDialog({ @@ -35,6 +37,7 @@ export function ConfirmDeleteDialog({ cancelLabel = 'Cancel', onCancel, onConfirm, + tone = 'danger', }: ConfirmDeleteDialogProps) { const confirmRef = useRef<HTMLButtonElement>(null) @@ -57,7 +60,7 @@ export function ConfirmDeleteDialog({ <Dialog open onClose={onCancel} - tone="danger" + tone={tone === 'danger' ? 'danger' : undefined} title={title} size="sm" initialFocusRef={confirmRef} @@ -68,7 +71,7 @@ export function ConfirmDeleteDialog({ </Button> <Button ref={confirmRef} - variant="destructive" + variant={tone === 'danger' ? 'destructive' : 'primary'} size="sm" type="button" onClick={onConfirm} diff --git a/src/admin/shared/dialogs/ConfirmDeleteDialog/confirmDeleteHook.ts b/src/admin/shared/dialogs/ConfirmDeleteDialog/confirmDeleteHook.ts index 711319db3..7a6d42cc9 100644 --- a/src/admin/shared/dialogs/ConfirmDeleteDialog/confirmDeleteHook.ts +++ b/src/admin/shared/dialogs/ConfirmDeleteDialog/confirmDeleteHook.ts @@ -23,6 +23,12 @@ export interface ConfirmDeleteRequest { alwaysConfirm?: boolean /** Action to execute on confirm or, when confirmation is skipped, immediately. */ commit: () => void + /** + * `danger` (the default) styles the dialog and its confirm button as + * destructive. `primary` is for consequential but non-destructive acts + * (merging a branch into main, updating a branch from main). + */ + tone?: 'danger' | 'primary' } export interface PendingConfirmState { @@ -54,3 +60,14 @@ export function useConfirmDelete(): ConfirmDeleteContextValue['confirmDelete'] { const ctx = use(ConfirmDeleteContext) return ctx?.confirmDelete ?? ((request) => request.commit()) } + + +/** + * Confirm a consequential, non-destructive action. Unlike `useConfirmDelete` + * it never honours the `confirmBeforeDelete` preference: that preference is + * about deletes, and a merge or an update should always ask. + */ +export function useConfirmAction(): (request: Omit<ConfirmDeleteRequest, 'alwaysConfirm'>) => void { + const confirmDelete = useConfirmDelete() + return (request) => confirmDelete({ tone: 'primary', ...request, alwaysConfirm: true }) +} diff --git a/src/admin/shared/dialogs/ConfirmDeleteDialog/index.ts b/src/admin/shared/dialogs/ConfirmDeleteDialog/index.ts index e9ffb9aef..52fe3ea8a 100644 --- a/src/admin/shared/dialogs/ConfirmDeleteDialog/index.ts +++ b/src/admin/shared/dialogs/ConfirmDeleteDialog/index.ts @@ -1,2 +1,2 @@ export { ConfirmDeleteProvider } from './ConfirmDeleteContext' -export { useConfirmDelete } from './confirmDeleteHook' +export { useConfirmAction, useConfirmDelete } from './confirmDeleteHook' diff --git a/src/admin/state/branchStore.ts b/src/admin/state/branchStore.ts index 17e3ce9b4..9cbc65ab2 100644 --- a/src/admin/state/branchStore.ts +++ b/src/admin/state/branchStore.ts @@ -22,8 +22,9 @@ import { type ApplyMergeBody, type CreateBranchBody, type MergeDirection, - type MergePlan, type SiteBranch, + type ApplyMergeEnvelope, + type UndoMergeEnvelope, } from '@core/branches' import { registerApiErrorListener } from '@core/http' import { @@ -32,6 +33,7 @@ import { deleteCmsBranch, listCmsBranches, renameCmsBranch, + undoCmsBranchMerge, } from '@core/persistence' import { pushToast } from '@ui/components/Toast' import { BRANCH_HEADER, currentBranchId, rememberBranchId } from './activeBranch' @@ -211,10 +213,7 @@ export async function deleteBranch(branchId: string): Promise<void> { await refreshBranchesAfterMutation() } -export interface BranchMergeResult { - plan: MergePlan - branchDeleted: boolean -} +export type BranchMergeResult = ApplyMergeEnvelope /** * Merge a branch into main, or update it from main. Callers wrap this in @@ -240,6 +239,18 @@ export async function mergeBranch( return result } +/** + * Reverse the latest merge or update on a branch. Callers wrap this in + * `runStepUp`. Content moved on the target (and, after a merge, on the + * branch), so every branch-scoped workspace reloads. + */ +export async function undoBranchMerge(branchId: string, direction: MergeDirection): Promise<UndoMergeEnvelope> { + const result = await undoCmsBranchMerge(branchId, direction) + useBranchStore.getState().bumpEpoch() + await refreshBranchesAfterMutation() + return result +} + // --------------------------------------------------------------------------- // Hooks // --------------------------------------------------------------------------- diff --git a/src/core/branches/index.ts b/src/core/branches/index.ts index 2c0f08425..615cfeeab 100644 --- a/src/core/branches/index.ts +++ b/src/core/branches/index.ts @@ -27,6 +27,8 @@ export { SiteBranchSchema, ApplyMergeBodySchema, ApplyMergeEnvelopeSchema, + BranchMergeRecordSchema, + UndoMergeEnvelopeSchema, MergeChangeSchema, MergeChangeDetailSchema, MergeEntityKindSchema, @@ -51,6 +53,9 @@ export { MergePlanSchema, MergeResolutionSchema, type ApplyMergeBody, + type ApplyMergeEnvelope, + type BranchMergeRecord, + type UndoMergeEnvelope, type BranchListEnvelope, type BranchPreview, type CreateBranchBody, diff --git a/src/core/branches/schemas.ts b/src/core/branches/schemas.ts index 20ff1b9b0..4a25e29cd 100644 --- a/src/core/branches/schemas.ts +++ b/src/core/branches/schemas.ts @@ -99,6 +99,8 @@ export const MergeTreeDiffSchema = Type.Object({ removed: Type.Array(Type.String()), /** Human label per node id that appears above. */ labels: Type.Record(Type.String(), Type.String()), + /** For each changed node, what moved: `text: “old” → “new”`, `hidden changed`, … */ + details: Type.Record(Type.String(), Type.Array(Type.String())), }) export type MergeTreeDiff = Static<typeof MergeTreeDiffSchema> @@ -182,10 +184,36 @@ export const ApplyMergeBodySchema = Type.Object({ }) export type ApplyMergeBody = Static<typeof ApplyMergeBodySchema> +/** + * One applied merge or update, kept so it can be undone. The server holds + * what every touched entity looked like before; the client sees only the + * record. `undoneAt` is set once it has been reversed. + */ +export const BranchMergeRecordSchema = Type.Object({ + id: Type.String(), + branchId: Type.String(), + direction: MergeDirectionSchema, + appliedByUserId: Type.Union([Type.String(), Type.Null()]), + changeCount: Type.Integer(), + createdAt: Type.String(), + undoneAt: Type.Union([Type.String(), Type.Null()]), +}) +export type BranchMergeRecord = Static<typeof BranchMergeRecordSchema> + export const ApplyMergeEnvelopeSchema = Type.Object({ plan: MergePlanSchema, branchDeleted: Type.Boolean(), + /** The record to undo by; null when the branch was deleted with the merge. */ + merge: Type.Union([BranchMergeRecordSchema, Type.Null()]), +}) +export type ApplyMergeEnvelope = Static<typeof ApplyMergeEnvelopeSchema> + +export const UndoMergeEnvelopeSchema = Type.Object({ + merge: BranchMergeRecordSchema, + /** Entities put back the way they were. */ + restoredCount: Type.Integer(), }) +export type UndoMergeEnvelope = Static<typeof UndoMergeEnvelopeSchema> // --------------------------------------------------------------------------- // Merge review — requests and comments on a branch @@ -242,6 +270,8 @@ export const BranchReviewStateSchema = Type.Object({ comments: Type.Array(BranchReviewCommentSchema), /** Hash of the branch's content right now (compare with `request.contentHash`). */ contentHash: Type.String(), + /** The newest merge into main that has not been undone, if any. */ + lastMerge: Type.Union([BranchMergeRecordSchema, Type.Null()]), }) export type BranchReviewState = Static<typeof BranchReviewStateSchema> diff --git a/src/core/loops/sources/dataRows.ts b/src/core/loops/sources/dataRows.ts index 944b38391..2e2abe3d2 100644 --- a/src/core/loops/sources/dataRows.ts +++ b/src/core/loops/sources/dataRows.ts @@ -598,7 +598,7 @@ export const DataRowsSource: LoopEntitySource = { const logicalTableId = typeof ctx.filters.tableId === 'string' ? ctx.filters.tableId : '' return fetchPublishedDataRowItems(ctx.db, { tableId: logicalTableId ? physicalId(ctx.branchId ?? MAIN_BRANCH_ID, logicalTableId) : '', - drafts: (ctx.branchId ?? MAIN_BRANCH_ID) !== MAIN_BRANCH_ID, + drafts: ctx.drafts ?? (ctx.branchId ?? MAIN_BRANCH_ID) !== MAIN_BRANCH_ID, orderBy: ctx.orderBy, direction: ctx.direction, limit: ctx.limit, diff --git a/src/core/loops/types.ts b/src/core/loops/types.ts index 810d5cdbd..c50a03f41 100644 --- a/src/core/loops/types.ts +++ b/src/core/loops/types.ts @@ -131,6 +131,12 @@ export interface SourceFetchContext { * because cookies would fragment the Layer B cache per visitor. */ request?: SourceRequestContext + /** + * Read draft rows instead of published versions. Unset, the source decides + * from `branchId` (drafts off main). The merge review sets it on both + * sides, because a merge compares drafts with drafts. + */ + drafts?: boolean /** * Branch whose rows the source reads. Publishing and public rendering run * on `main`; the editor's runtime preview and branch previews pass the diff --git a/src/core/persistence/cmsBranches.ts b/src/core/persistence/cmsBranches.ts index c197054af..7caf894b7 100644 --- a/src/core/persistence/cmsBranches.ts +++ b/src/core/persistence/cmsBranches.ts @@ -11,6 +11,9 @@ import { apiRequest } from '@core/http' import { ApplyMergeEnvelopeSchema, + UndoMergeEnvelopeSchema, + type ApplyMergeEnvelope, + type UndoMergeEnvelope, BranchEnvelopeSchema, BranchListEnvelopeSchema, BranchPreviewLinkEnvelopeSchema, @@ -107,7 +110,7 @@ export async function applyCmsBranchMerge( id: string, direction: MergeDirection, body: ApplyMergeBody, -): Promise<{ plan: MergePlan; branchDeleted: boolean }> { +): Promise<ApplyMergeEnvelope> { return apiRequest(`${BRANCHES_PATH}/${encodeURIComponent(id)}/${direction}`, { method: 'POST', body, @@ -116,6 +119,15 @@ export async function applyCmsBranchMerge( }) } +/** Reverse the latest merge (or update) on the branch; 409 when the target moved since. */ +export async function undoCmsBranchMerge(id: string, direction: MergeDirection): Promise<UndoMergeEnvelope> { + return apiRequest(`${BRANCHES_PATH}/${encodeURIComponent(id)}/${direction}/undo`, { + method: 'POST', + schema: UndoMergeEnvelopeSchema, + fallbackMessage: direction === 'merge' ? 'Failed to undo the merge' : 'Failed to undo the update', + }) +} + // --------------------------------------------------------------------------- // Merge review // --------------------------------------------------------------------------- diff --git a/src/core/persistence/index.ts b/src/core/persistence/index.ts index 7e3756b0c..c5e989626 100644 --- a/src/core/persistence/index.ts +++ b/src/core/persistence/index.ts @@ -107,6 +107,7 @@ export { revokeCmsBranchPreview, getCmsBranchMergePlan, applyCmsBranchMerge, + undoCmsBranchMerge, getCmsBranchReview, requestCmsBranchMerge, withdrawCmsBranchMergeRequest, diff --git a/src/core/templates/index.ts b/src/core/templates/index.ts index 295ce2381..7c01bb37b 100644 --- a/src/core/templates/index.ts +++ b/src/core/templates/index.ts @@ -19,3 +19,4 @@ export { } from './templateMatching' export { composeTemplateChain } from './templateCompose' export { firstOutletId, treeHasOutlet, subtreeHasOutlet } from './outlet' +export { composedNodeSourceId } from './templateCompose' diff --git a/src/core/templates/templateCompose.ts b/src/core/templates/templateCompose.ts index 1189cfa4e..f64cabbb0 100644 --- a/src/core/templates/templateCompose.ts +++ b/src/core/templates/templateCompose.ts @@ -13,6 +13,19 @@ function hasMeaningfulBodyProps(node: PageNode): boolean { || Object.keys(node.breakpointOverrides ?? {}).length > 0 } +/** + * The page-tree id a composed node came from. Composition prefixes ids + * (`c0_` for the terminal page, then `t<i>_` per outer template, applied + * innermost first), so a rendered `uid` reads `t0_c0_<id>`. Anything that + * maps rendered elements back to the plan's node ids (the merge review's + * highlights) resolves them through this, never by guessing the prefix. + * Ids the composer invents (`<prefix>bodyprops`) come back as `bodyprops`, + * which matches no page node, which is right. + */ +export function composedNodeSourceId(composedId: string): string { + return composedId.replace(/^(?:t\d+_)*c0_/, '') +} + /** Clone a tree's nodes with every id prefixed, returning the remapped root id. */ function rekey(nodes: Nodes, rootId: string, prefix: string): { nodes: Nodes; rootId: string } { const map = new Map<string, string>() diff --git a/tests/e2e/branch-review.e2e.ts b/tests/e2e/branch-review.e2e.ts index aff403cac..eaf94a3e3 100644 --- a/tests/e2e/branch-review.e2e.ts +++ b/tests/e2e/branch-review.e2e.ts @@ -255,7 +255,11 @@ test('the owner resolves the conflict and merges with a step-up', async ({ page await expect(page.getByTestId('review-merge')).toBeEnabled() await shot(page, '8-owner-resolved') + // Deleting after the merge is opt-in (a merge that keeps the branch can be undone). + await page.getByTestId('review-delete-toggle').click() await page.getByTestId('review-merge').click() + // Merging asks first, then steps up. + await page.getByRole('button', { name: 'Merge into main' }).click() await completeStepUp(page, OWNER.password) await expect(page.getByText(/Merged Launch review into main/)).toBeVisible({ timeout: 20_000 }) await expect(page).toHaveURL(/\/admin\/site/) diff --git a/tests/e2e/branches.e2e.ts b/tests/e2e/branches.e2e.ts index 549a4b14a..7a797b7e6 100644 --- a/tests/e2e/branches.e2e.ts +++ b/tests/e2e/branches.e2e.ts @@ -201,7 +201,11 @@ test('merge a branch into main from the review page (BRANCH-005)', async ({ page await page.waitForTimeout(400) await shot(page, '8-merge-review', 'full') + // Deleting after the merge is opt-in (a merge that keeps the branch can be undone). + await page.getByTestId('review-delete-toggle').click() await page.getByTestId('review-merge').click() + // Merging asks first, then steps up. + await page.getByRole('button', { name: 'Merge into main' }).click() await completeStepUp(page) await expect(page).toHaveURL(/\/admin\/site/) // The branch was deleted after merging, so the tab is back on main … From 7d41e7835a725cb97779309ebaebc5367da8c990 Mon Sep 17 00:00:00 2001 From: DavidBabinec <hello@davidbabinec.com> Date: Sat, 5 Sep 2026 17:35:50 +0200 Subject: [PATCH 11/16] fix(admin): mount the confirm provider once at the root so the merge review can ask Testing the merge on the showcase skipped the new confirmation: the review's Review component owns the confirm hook and renders the workspace layout itself, and ConfirmDeleteProvider was mounted inside that layout (and again inside the editor body), so the hook found no provider and fell back to committing at once. One provider now wraps the whole admin in AuthenticatedAdmin, inside StepUpProvider; the two layout-level copies are gone. docs/editor.md and docs/features/editor-preferences.md describe the placement. Also from the same test pass: - The change list read "Changed text: text: ...": the diff names the prop in full and the node label was the same word. changedNodeLine in reviewFormat.ts is the one formatter for both the compare and the change card, and drops a prop name the label already says. - The swipe's pointer release closes over nothing; it lives at module scope as releaseSwipePointer. Verification: bun run build clean bun run lint clean bun test full suite, all pass headless Chromium on the showcase: a real pointer drag moves the swipe divider (--split 50% -> 72%, capture held across moves) --- docs/editor.md | 2 +- docs/features/editor-preferences.md | 2 +- src/admin/AuthenticatedAdmin.tsx | 72 ++++++++++--------- .../AdminCanvasEditorBody.tsx | 69 ++++++++---------- .../AdminWorkspaceCanvasLayout.tsx | 41 +++++------ src/admin/pages/branches/PageCompare.tsx | 21 +++--- src/admin/pages/branches/ReviewChangeCard.tsx | 12 +--- src/admin/pages/branches/reviewFormat.ts | 16 ++++- 8 files changed, 114 insertions(+), 121 deletions(-) diff --git a/docs/editor.md b/docs/editor.md index ad44ca2ec..b41fde6c7 100644 --- a/docs/editor.md +++ b/docs/editor.md @@ -180,7 +180,7 @@ Every admin page picks one of three root layouts from `src/admin/layouts/`. Impo | `AdminWorkspaceCanvasLayout` | Content, Data, Media | Canvas chrome (toolbar, sidebar, full-height canvas) WITHOUT site-only modules (no editor store, PropertiesPanel, DnD, or CodeMirror). | | `AdminPageLayout` | Plugins, Users, Account, plugin admin pages | Lightweight — toolbar + centered scrollable page body. **Must not import the editor store.** Site name and favicon come from `useSiteSummary` + the `adminUi` Zustand store. | -`AdminCanvasLayout` keeps the real editor shell mounted while `usePersistence()` loads the draft site document. In production it renders the toolbar/chrome first and lazy-loads `AdminCanvasEditorBody` after paint. The body owns the permanent rail, sidebars, canvas, DnD context, `ConfirmDeleteProvider`, `CodeEditorPanel`, first-party module registration, and loop-source registration. Rare modal surfaces such as `ImportHtmlModal` stay behind their own open-state lazy boundary inside the body. Loading states use the same local skeleton vocabulary: the editor-body lazy fallback and the canvas no-site fallback both render `CanvasFrameSkeletonFrame`, and sidebars use compact skeleton rows or blocks. Once the document is in the store, every breakpoint frame mounts immediately — the tree is already in memory, so there is nothing to stagger. +`AdminCanvasLayout` keeps the real editor shell mounted while `usePersistence()` loads the draft site document. In production it renders the toolbar/chrome first and lazy-loads `AdminCanvasEditorBody` after paint. The body owns the permanent rail, sidebars, canvas, DnD context, `CodeEditorPanel`, first-party module registration, and loop-source registration (the confirm dialog's `ConfirmDeleteProvider` sits once at the admin root in `AuthenticatedAdmin`, so pages that render a layout can ask through it too). Rare modal surfaces such as `ImportHtmlModal` stay behind their own open-state lazy boundary inside the body. Loading states use the same local skeleton vocabulary: the editor-body lazy fallback and the canvas no-site fallback both render `CanvasFrameSkeletonFrame`, and sidebars use compact skeleton rows or blocks. Once the document is in the store, every breakpoint frame mounts immediately — the tree is already in memory, so there is nothing to stagger. The `adminUi` store (`src/admin/state/adminUi.ts`) is the small cross-shell state store: settings-modal open flag, site-import modal open flag, site name/favicon for the toolbar brand position, and `activeLivePath` — the public path the "Open live page" toolbar button opens. The toolbar renders a compact skeleton while the site identity is loading, then renders the configured site favicon when present; otherwise it shows the site name with the same compact bold typography as the admin navigation. The site name is exposed through the shared tooltip after identity loads. It lives outside `@site/` so `AdminPageLayout` can subscribe without pulling in the 165 KB editor graph. The editor's `settingsSlice` mirrors its state into `adminUi` via a registered bridge so both are always in sync. diff --git a/docs/features/editor-preferences.md b/docs/features/editor-preferences.md index cec9814b5..752c8f8bd 100644 --- a/docs/features/editor-preferences.md +++ b/docs/features/editor-preferences.md @@ -280,7 +280,7 @@ The Settings → Preferences screen renders this list automatically from the cat ### Confirm-before-delete flow -`confirmBeforeDelete` runs through a single shared `<ConfirmDeleteProvider/>` mounted in `AdminCanvasLayout`. Components call `useConfirmDelete()` and pass a `commit` callback: +`confirmBeforeDelete` runs through a single shared `<ConfirmDeleteProvider/>` mounted once at the admin root (`AuthenticatedAdmin`, inside `StepUpProvider`), so every route — the editor body, the workspace layouts, and pages such as the merge review that render a layout themselves — asks through the same dialog. Components call `useConfirmDelete()` and pass a `commit` callback (`useConfirmAction()` is the always-ask, non-destructive variant for merges and updates; a request may carry `tone: 'primary'`): ```tsx const confirmDelete = useConfirmDelete() diff --git a/src/admin/AuthenticatedAdmin.tsx b/src/admin/AuthenticatedAdmin.tsx index bc273ee02..6d539e910 100644 --- a/src/admin/AuthenticatedAdmin.tsx +++ b/src/admin/AuthenticatedAdmin.tsx @@ -5,6 +5,7 @@ * - SpotlightRoot (Cmd+K palette) + its keybinding listener * - AdminSessionProvider (session context for authenticated children) * - StepUpProvider (auth re-verification for sensitive actions) + * - ConfirmDeleteProvider (the one confirm dialog every admin route asks through) * - The 10 workspace page components (DashboardPage, SitePage, …) * - installPluginRuntime() (populates globalThis.__instatic for plugins) * @@ -54,6 +55,7 @@ import { AppLoadingScreen } from './AppLoadingScreen' import type { AdminWorkspace } from './workspace' import { AdminSessionProvider } from './session' import { StepUpProvider } from './shared/StepUp' +import { ConfirmDeleteProvider } from './shared/dialogs/ConfirmDeleteDialog' import { canAccessWorkspace, firstAccessibleWorkspace, workspacePath } from './access' import { Navigate, useInRouterContext } from './lib/routing' import { SpotlightRoot } from './spotlight' @@ -306,41 +308,43 @@ export default function AuthenticatedAdmin({ section, currentUser }: Authenticat palette and the step-up dialog are available across every workspace. */} <StepUpProvider> - <SpotlightRoot> - {/* Suspense catches: - - First-visit cold-path of a prewarmedLazy page (it throws - the pending import promise the first time). On subsequent - visits the prewarmedLazy renders synchronously and this - boundary never fires. - - Downstream `React.lazy()` inside pages (e.g. content body - editor / LiveCanvas / CodeMirrorEditor). Those remain - legitimately lazy because the editor surfaces are large and - shouldn't ship until needed. */} - <Suspense fallback={<AppLoadingScreen />}> - {section === 'dashboard' ? <DashboardPage /> : - section === 'site' ? <SitePage key={branchKey} /> : - section === 'content' ? <ContentPage key={branchKey} /> : - section === 'data' ? <DataPage key={branchKey} /> : - section === 'media' ? <MediaPage /> : - section === 'plugins' ? <PluginsPage /> : - section === 'users' ? <UsersPage /> : - section === 'ai' ? <AiPage /> : - section === 'branchReview' ? <BranchReviewPage /> : - section === 'pluginPage' ? <PluginPage /> : - section === 'account' ? <AccountPage /> : - <DashboardPage />} - </Suspense> - {siteImportOpen && ( - <Suspense fallback={null}> - <SiteImportModal /> + <ConfirmDeleteProvider> + <SpotlightRoot> + {/* Suspense catches: + - First-visit cold-path of a prewarmedLazy page (it throws + the pending import promise the first time). On subsequent + visits the prewarmedLazy renders synchronously and this + boundary never fires. + - Downstream `React.lazy()` inside pages (e.g. content body + editor / LiveCanvas / CodeMirrorEditor). Those remain + legitimately lazy because the editor surfaces are large and + shouldn't ship until needed. */} + <Suspense fallback={<AppLoadingScreen />}> + {section === 'dashboard' ? <DashboardPage /> : + section === 'site' ? <SitePage key={branchKey} /> : + section === 'content' ? <ContentPage key={branchKey} /> : + section === 'data' ? <DataPage key={branchKey} /> : + section === 'media' ? <MediaPage /> : + section === 'plugins' ? <PluginsPage /> : + section === 'users' ? <UsersPage /> : + section === 'ai' ? <AiPage /> : + section === 'branchReview' ? <BranchReviewPage /> : + section === 'pluginPage' ? <PluginPage /> : + section === 'account' ? <AccountPage /> : + <DashboardPage />} </Suspense> - )} - {siteExportOpen && ( - <Suspense fallback={null}> - <SiteExportModal /> - </Suspense> - )} - </SpotlightRoot> + {siteImportOpen && ( + <Suspense fallback={null}> + <SiteImportModal /> + </Suspense> + )} + {siteExportOpen && ( + <Suspense fallback={null}> + <SiteExportModal /> + </Suspense> + )} + </SpotlightRoot> + </ConfirmDeleteProvider> </StepUpProvider> </AdminSessionProvider> ) diff --git a/src/admin/layouts/AdminCanvasLayout/AdminCanvasEditorBody.tsx b/src/admin/layouts/AdminCanvasLayout/AdminCanvasEditorBody.tsx index faa2f9642..82bcf1fa6 100644 --- a/src/admin/layouts/AdminCanvasLayout/AdminCanvasEditorBody.tsx +++ b/src/admin/layouts/AdminCanvasLayout/AdminCanvasEditorBody.tsx @@ -16,7 +16,6 @@ import { LeftSidebar } from '@admin/pages/site/sidebars/LeftSidebar' import { RightSidebar } from '@admin/pages/site/sidebars/RightSidebar' import { selectRightSidebarExpanded, useEditorStore } from '@admin/pages/site/store/store' import { useNarrowEditorChrome } from '@site/layout/responsiveChrome' -import { ConfirmDeleteProvider } from '@admin/shared/dialogs/ConfirmDeleteDialog' import { Dialog } from '@ui/components/Dialog' import { Button } from '@ui/components/Button' import { cn } from '@ui/cn' @@ -84,46 +83,38 @@ export function AdminCanvasEditorBody({ context is isolated; nested DndContexts are fully supported by dnd-kit. */} <DndContext sensors={canvasDndSensors} collisionDetection={pointerWithin}> - {/* `ConfirmDeleteProvider` wraps the editor body so the canvas - Delete-key handler, Layers panel context menu, and other - descendant destructive actions can call `useConfirmDelete()` - and gate on the `confirmBeforeDelete` editor preference. - Plugin uninstall is intentionally *not* gated on that preference - and uses its own dedicated `PluginRemoveDialog` instead. */} - <ConfirmDeleteProvider> - <div className={styles.editorBody}> - <LeftSidebar - workspace="site" - editable={canEditDraftSite} - canUseAiChat={canUseAiChat} - railOnly={hasRightSidebar && narrowChrome} - /> - <div - className={cn(styles.canvasStage, hasRightSidebar && styles.canvasStageRightSidebarOpen)} - data-right-sidebar-expanded={hasRightSidebar ? 'true' : 'false'} - > - <div className={styles.canvasContent} key="site"> - {/* Canvas — fills the remaining space between sidebars */} - {loadError ? ( - <SiteEditorLoadError message={loadError} /> - ) : ( - <CanvasRoot editable={canEditDraftSite} /> - )} - {/* Properties can be unpinned into the floating draggable overlay. */} - {canSaveSite && propertiesPanelMode === 'floating' && <PropertiesPanel variant="floating" />} - </div> + <div className={styles.editorBody}> + <LeftSidebar + workspace="site" + editable={canEditDraftSite} + canUseAiChat={canUseAiChat} + railOnly={hasRightSidebar && narrowChrome} + /> + <div + className={cn(styles.canvasStage, hasRightSidebar && styles.canvasStageRightSidebarOpen)} + data-right-sidebar-expanded={hasRightSidebar ? 'true' : 'false'} + > + <div className={styles.canvasContent} key="site"> + {/* Canvas — fills the remaining space between sidebars */} + {loadError ? ( + <SiteEditorLoadError message={loadError} /> + ) : ( + <CanvasRoot editable={canEditDraftSite} /> + )} + {/* Properties can be unpinned into the floating draggable overlay. */} + {canSaveSite && propertiesPanelMode === 'floating' && <PropertiesPanel variant="floating" />} </div> - {/* `mode` tells the RightSidebar which expansion model to use: - - `'site'`: Site editor — width follows the selection- - gated `sitePropertiesExpanded` selector. - - `'hidden'`: Site viewer with no `pages.draft.save` - capability. */} - <RightSidebar - key="site" - mode={canSaveSite ? 'site' : 'hidden'} - /> </div> - </ConfirmDeleteProvider> + {/* `mode` tells the RightSidebar which expansion model to use: + - `'site'`: Site editor — width follows the selection- + gated `sitePropertiesExpanded` selector. + - `'hidden'`: Site viewer with no `pages.draft.save` + capability. */} + <RightSidebar + key="site" + mode={canSaveSite ? 'site' : 'hidden'} + /> + </div> </DndContext> {/* Code editor/media preview: viewport overlay, not constrained by the diff --git a/src/admin/layouts/AdminWorkspaceCanvasLayout/AdminWorkspaceCanvasLayout.tsx b/src/admin/layouts/AdminWorkspaceCanvasLayout/AdminWorkspaceCanvasLayout.tsx index ece3b9ad7..e9c0035a1 100644 --- a/src/admin/layouts/AdminWorkspaceCanvasLayout/AdminWorkspaceCanvasLayout.tsx +++ b/src/admin/layouts/AdminWorkspaceCanvasLayout/AdminWorkspaceCanvasLayout.tsx @@ -11,7 +11,6 @@ import { lazy, Suspense, useRef, type CSSProperties, type ReactNode, type SyntheticEvent } from 'react' import { Toolbar } from '@site/toolbar/Toolbar' import { AdminSectionNavigation } from '@admin/shared/AdminSectionNavigation' -import { ConfirmDeleteProvider } from '@admin/shared/dialogs/ConfirmDeleteDialog' import { SidebarResizeHandle } from '@admin/shared/SidebarResizeHandle' import { useEditorAppearancePreferences } from '@site/preferences/editorPreferences' import { useInstalledEditorPlugins } from '@admin/pages/plugins/hooks/useInstalledEditorPlugins' @@ -90,29 +89,27 @@ export function AdminWorkspaceCanvasLayout({ rightSlot={toolbarRightSlot} /> - <ConfirmDeleteProvider> - <div className={styles.editorBody}> - {contentSidebar ?? null} - <div - className={cn(styles.canvasStage, hasRightSidebar && styles.canvasStageRightSidebarOpen)} - data-right-sidebar-expanded={hasRightSidebar ? 'true' : 'false'} - > - <div className={styles.canvasContent} key={workspace}> - {contentCanvas} - </div> - {hasReopenableRightPanel && ( - <WorkspaceRightPanelNotch - workspace={workspace} - onOpen={() => setRightPanel({ collapsed: false })} - /> - )} + <div className={styles.editorBody}> + {contentSidebar ?? null} + <div + className={cn(styles.canvasStage, hasRightSidebar && styles.canvasStageRightSidebarOpen)} + data-right-sidebar-expanded={hasRightSidebar ? 'true' : 'false'} + > + <div className={styles.canvasContent} key={workspace}> + {contentCanvas} </div> - <WorkspaceRightSidebar - hidden={!rightPanelAvailable} - contentPanel={contentRightPanel} - /> + {hasReopenableRightPanel && ( + <WorkspaceRightPanelNotch + workspace={workspace} + onOpen={() => setRightPanel({ collapsed: false })} + /> + )} </div> - </ConfirmDeleteProvider> + <WorkspaceRightSidebar + hidden={!rightPanelAvailable} + contentPanel={contentRightPanel} + /> + </div> {settingsOpen && ( <Suspense fallback={null}> diff --git a/src/admin/pages/branches/PageCompare.tsx b/src/admin/pages/branches/PageCompare.tsx index 876189853..7d61c7ffd 100644 --- a/src/admin/pages/branches/PageCompare.tsx +++ b/src/admin/pages/branches/PageCompare.tsx @@ -20,6 +20,7 @@ import { composedNodeSourceId } from '@core/templates' import { getErrorMessage } from '@core/utils/errorMessage' import { SegmentedControl } from '@ui/components/SegmentedControl' import { Switch } from '@ui/components/Switch' +import { changedNodeLine } from './reviewFormat' import styles from './BranchReviewPage.module.css' const PAGE_WIDTH = 1280 @@ -196,17 +197,14 @@ function markLabel(verb: string, nodeLabel: string | undefined): string { return `${verb} · ${nodeLabel}` } -/** "Changed text: “a” → “b”" when the plan knows what moved; "Changed text" when it does not. */ -function changedLine(tree: MergeTreeDiff, id: string): string { - const head = `Changed ${tree.labels[id] ?? id}` - const details = tree.details[id] ?? [] - return details.length > 0 ? `${head}: ${details.join('; ')}` : head -} - function clampPercent(value: number): number { return Math.min(100, Math.max(0, value)) } +function releaseSwipePointer(event: PointerEvent<HTMLDivElement>): void { + if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId) +} + interface PageCompareProps { branchId: string rowId: string @@ -237,7 +235,7 @@ export function PageCompare({ branchId, rowId, label, action, tree, fieldLines, const treeLines = tree ? [ ...tree.added.map((id) => `Added ${tree.labels[id] ?? id}`), - ...tree.changed.map((id) => changedLine(tree, id)), + ...tree.changed.map((id) => changedNodeLine(tree, id)), ...tree.removed.map((id) => `Removed ${tree.labels[id] ?? id}`), ] : [] @@ -260,9 +258,6 @@ export function PageCompare({ branchId, rowId, label, action, tree, fieldLines, if (!event.currentTarget.hasPointerCapture(event.pointerId)) return setSplit(splitFromPointer(event)) } - function onStackPointerUp(event: PointerEvent<HTMLDivElement>): void { - if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId) - } return ( <div className={styles.compare}> @@ -308,8 +303,8 @@ export function PageCompare({ branchId, rowId, label, action, tree, fieldLines, style={{ '--split': `${split}%` } as CSSProperties} onPointerDown={onStackPointerDown} onPointerMove={onStackPointerMove} - onPointerUp={onStackPointerUp} - onPointerCancel={onStackPointerUp} + onPointerUp={releaseSwipePointer} + onPointerCancel={releaseSwipePointer} data-testid="review-swipe-stack" > <ScaledFrame branchId={branchId} rowId={rowId} side="branch" title={`${label} on the branch`} marks={branchMarks} showHighlights={showHighlights} /> diff --git a/src/admin/pages/branches/ReviewChangeCard.tsx b/src/admin/pages/branches/ReviewChangeCard.tsx index b6865f9a3..2da5cf8b1 100644 --- a/src/admin/pages/branches/ReviewChangeCard.tsx +++ b/src/admin/pages/branches/ReviewChangeCard.tsx @@ -10,7 +10,7 @@ import { SegmentedControl } from '@ui/components/SegmentedControl' import { TagPill } from '@ui/components/TagPill' import { WarningDiamondSolidIcon } from 'pixel-art-icons/icons/warning-diamond-solid' import { PageCompare } from './PageCompare' -import { ACTION_TONE, ACTION_WORD, changeKindLabel, isPageChange } from './reviewFormat' +import { ACTION_TONE, ACTION_WORD, changeKindLabel, changedNodeLine, isPageChange } from './reviewFormat' import styles from './BranchReviewPage.module.css' interface ReviewChangeCardProps { @@ -130,15 +130,7 @@ export function ReviewChangeCard({ branchId, change, resolution, canResolve, onR {detail.tree && ( <ul className={styles.changeList}> {detail.tree.added.map((id) => <li key={`a-${id}`}>Added {detail.tree!.labels[id] ?? id}</li>)} - {detail.tree.changed.map((id) => { - const details = detail.tree!.details[id] ?? [] - return ( - <li key={`c-${id}`}> - Changed {detail.tree!.labels[id] ?? id} - {details.length > 0 && `: ${details.join('; ')}`} - </li> - ) - })} + {detail.tree.changed.map((id) => <li key={`c-${id}`}>{changedNodeLine(detail.tree!, id)}</li>)} {detail.tree.removed.map((id) => <li key={`r-${id}`}>Removed {detail.tree!.labels[id] ?? id}</li>)} </ul> )} diff --git a/src/admin/pages/branches/reviewFormat.ts b/src/admin/pages/branches/reviewFormat.ts index 055fbbf42..ec358d994 100644 --- a/src/admin/pages/branches/reviewFormat.ts +++ b/src/admin/pages/branches/reviewFormat.ts @@ -2,7 +2,7 @@ * Labels and grouping the merge review uses: how a change's kind and action * read, which filter a change belongs to, and short relative times. */ -import type { MergeChange, MergeRequestStatus } from '@core/branches' +import type { MergeChange, MergeRequestStatus, MergeTreeDiff } from '@core/branches' import { formatRelativeTime } from '@core/utils/relativeTime' import type { TagPillTone } from '@ui/components/TagPill' @@ -100,3 +100,17 @@ export function relativeIsoAgo(iso: string): string { /** Every comment on the request itself uses the empty key. */ export const REQUEST_ENTITY_KEY = '' + +/** + * "Changed text: “a” → “b”" for a node the tree diff lists as changed. The + * diff names props in full (`text: “a” → “b”`); when the node is labelled by + * the same word (a `text` node's `text` prop) the label already says it, so + * the prop name is not repeated. Without details it is "Changed text". + */ +export function changedNodeLine(tree: MergeTreeDiff, id: string): string { + const label = tree.labels[id] ?? id + const details = (tree.details[id] ?? []).map((detail) => + detail.startsWith(`${label}: `) ? detail.slice(label.length + 2) : detail, + ) + return details.length > 0 ? `Changed ${label}: ${details.join('; ')}` : `Changed ${label}` +} From 7691f0a305e7db739499a9c73f740639fdcde17c Mon Sep 17 00:00:00 2001 From: DavidBabinec <hello@davidbabinec.com> Date: Sat, 5 Sep 2026 17:41:07 +0200 Subject: [PATCH 12/16] fix(branches): ISO timestamps on touch, rename, and revoke; review copy and handle Three site_branches / site_branch_previews writes bound SQLite's current_timestamp, a space-separated local-time string that Date.parse reads as local time, so a branch merged a second ago read "updated 2h ago" in the strip and the palette. touchBranch, the rename update, and the preview revoke now bind new Date().toISOString(), like every other branch write (the same fix the merge requests got earlier). Also from the end-to-end pass on the showcase: - The merge confirmation read "1 change land"; it agrees in number now. - A page frame is far taller than the viewport, so a swipe handle at half the stack's height was usually off screen. It sits 180px from the top, in the first screen, with the divider line still full height. Verification: bun run build clean bun run lint clean bun test (branch server suites, dialogs, architecture) all pass headless Chromium on the showcase: merge with confirm and step-up, undo with step-up, main's draft restored, branch kept its edit --- server/repositories/branchPreviews.ts | 3 ++- server/repositories/branches.ts | 9 +++++++-- src/admin/pages/branches/BranchReviewPage.module.css | 2 +- src/admin/pages/branches/BranchReviewPage.tsx | 6 +++--- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/server/repositories/branchPreviews.ts b/server/repositories/branchPreviews.ts index 3d1efb91b..285db5e30 100644 --- a/server/repositories/branchPreviews.ts +++ b/server/repositories/branchPreviews.ts @@ -76,9 +76,10 @@ export async function resolveBranchPreviewToken(db: DbClient, tokenHash: string) /** Retire every active link of a branch; returns how many were active. */ export async function revokeBranchPreviews(db: DbClient, branchId: string): Promise<number> { + const now = new Date().toISOString() const { rows } = await db<{ id: string }>` update site_branch_previews - set revoked_at = current_timestamp + set revoked_at = ${now} where branch_id = ${branchId} and revoked_at is null returning id diff --git a/server/repositories/branches.ts b/server/repositories/branches.ts index 9f7b2b41b..de7463360 100644 --- a/server/repositories/branches.ts +++ b/server/repositories/branches.ts @@ -75,10 +75,11 @@ export async function renameBranch( id: string, name: string, ): Promise<SiteBranch | null> { + const now = new Date().toISOString() const { rows } = await db<SiteBranchRow>` update site_branches set name = ${name}, - updated_at = current_timestamp + updated_at = ${now} where id = ${id} and id <> ${MAIN_BRANCH_ID} returning id, name, base_branch_id, created_by_user_id, created_at, updated_at @@ -87,9 +88,13 @@ export async function renameBranch( } export async function touchBranch(db: DbClient, id: string): Promise<void> { + // Bound as ISO text: SQLite's `current_timestamp` is a space-separated + // local-time string that `Date.parse` reads as local time, which showed + // a just-merged branch as "updated 2h ago". + const now = new Date().toISOString() await db` update site_branches - set updated_at = current_timestamp + set updated_at = ${now} where id = ${id} ` } diff --git a/src/admin/pages/branches/BranchReviewPage.module.css b/src/admin/pages/branches/BranchReviewPage.module.css index 11ac9414d..8bcc1878c 100644 --- a/src/admin/pages/branches/BranchReviewPage.module.css +++ b/src/admin/pages/branches/BranchReviewPage.module.css @@ -322,7 +322,7 @@ .swipeStack { position: relative; cursor: ew-resize; touch-action: none; user-select: none; border-radius: var(--panel-radius); overflow: hidden; } .swipeTop { position: absolute; inset: 0; clip-path: inset(0 calc(100% - var(--split)) 0 0); pointer-events: none; } .swipeLine { position: absolute; top: 0; bottom: 0; left: var(--split); width: 3px; background: var(--warning); transform: translateX(-50%); pointer-events: none; } -.swipeHandle { position: absolute; top: 50%; left: var(--split); width: 32px; height: 32px; transform: translate(-50%, -50%); border-radius: 999px; background: var(--warning); display: grid; place-items: center; pointer-events: none; box-shadow: 0 2px 8px var(--scrim-40); } +.swipeHandle { position: absolute; top: 180px; left: var(--split); width: 32px; height: 32px; transform: translate(-50%, -50%); border-radius: 999px; background: var(--warning); display: grid; place-items: center; pointer-events: none; box-shadow: 0 2px 8px var(--scrim-40); } .swipeHandleGrip { width: 10px; height: 14px; border-left: 2px solid var(--bg-body); border-right: 2px solid var(--bg-body); } .swipeTagLeft, .swipeTagRight { position: absolute; top: 8px; padding: 2px 8px; border-radius: 999px; font-size: var(--text-3xs); font-weight: 700; color: var(--bg-body); background: var(--text); pointer-events: none; } .swipeTagLeft { left: 8px; } diff --git a/src/admin/pages/branches/BranchReviewPage.tsx b/src/admin/pages/branches/BranchReviewPage.tsx index 565572fbc..04f17a23e 100644 --- a/src/admin/pages/branches/BranchReviewPage.tsx +++ b/src/admin/pages/branches/BranchReviewPage.tsx @@ -191,12 +191,12 @@ function Review({ branchId, branchName }: ReviewProps) { function merge(): void { if (unresolved.length > 0 || loadedPlan.changes.length === 0) return const count = loadedPlan.changes.length - const changes = `${count} change${count === 1 ? '' : 's'}` + const lands = `${count} change${count === 1 ? ' lands' : 's land'}` confirmAction({ title: `Merge ${branchName} into main?`, description: deleteAfter - ? `${changes} land in main's draft and the branch is deleted, so this cannot be undone. Nothing is published.` - : `${changes} land in main's draft. Nothing is published, and the merge can be undone from this page while main stays as merged.`, + ? `${lands} in main's draft and the branch is deleted, so this cannot be undone. Nothing is published.` + : `${lands} in main's draft. Nothing is published, and the merge can be undone from this page while main stays as merged.`, confirmLabel: 'Merge into main', commit: () => { void runMerge() }, }) From a698a0726affacd9daac6dd57191b8cf5d1a986e Mon Sep 17 00:00:00 2001 From: DavidBabinec <hello@davidbabinec.com> Date: Sat, 5 Sep 2026 20:18:55 +0200 Subject: [PATCH 13/16] fix(branches): let a stale branch header through the account routes A tab remembers its branch in sessionStorage and sends it as X-Instatic-Branch on every admin request, the sign-in included. The CMS dispatcher resolved that header before any route group ran, so once the branch was gone (deleted from another tab, or the database reset under the tab) every request answered 404 branch_not_found, the login form showed "Branch "staging" does not exist", and nothing could recover: the fallback that drops a tab back to main lives in the authenticated branch store, which never loads before sign-in. The account groups (setup, session, login, preferences, users, roles, audit) now run before the branch header is looked at. They hold no branched data, and signing in is exactly how such a tab recovers: the first content request after it answers branch_not_found and the store's existing listener switches the tab to main with its notice. A branch's existence is also no longer revealed before authentication. Regression test in branchesHandler.test.ts: setup status, session, and a failed login all ignore a stale header, while the branches list still refuses it with the code the client falls back on. docs/features/ branches.md describes the order. Verification: bun run build clean bun run lint clean bun test (branch, auth, architecture suites) all pass headless Chromium on the showcase: a tab with a deleted branch stored signs in, lands on main with the "Branch no longer exists" notice --- docs/features/branches.md | 2 +- server/handlers/cms/index.ts | 28 +++++++++++++------- src/__tests__/server/branchesHandler.test.ts | 21 +++++++++++++++ 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/docs/features/branches.md b/docs/features/branches.md index 3d1b9dc7d..9d97638b2 100644 --- a/docs/features/branches.md +++ b/docs/features/branches.md @@ -84,7 +84,7 @@ Foreign keys stay physical, so main's rows, versions, redirects, and media refer ### Scope -`BranchScope { branchId }` is an explicit parameter on every repository function that touches a branched table (gated). The CMS dispatcher resolves it once: +`BranchScope { branchId }` is an explicit parameter on every repository function that touches a branched table (gated). The CMS dispatcher resolves it once, **after** the account route groups (setup, session, login, preferences, users, roles, audit) have had their turn: those hold no branched data, and a tab can carry a header naming a branch that no longer exists (deleted from another tab, or the database reset under it), which must never block signing in — signing in is how such a tab recovers, since the authenticated branch store drops back to main on the first `branch_not_found` it sees. It also keeps a branch's existence from being revealed before authentication. ```ts const scope = await resolveBranchScope(req, db) // MAIN_SCOPE, or 400 / 404 { code: 'branch_not_found' } diff --git a/server/handlers/cms/index.ts b/server/handlers/cms/index.ts index c91c57137..4e001d739 100644 --- a/server/handlers/cms/index.ts +++ b/server/handlers/cms/index.ts @@ -75,17 +75,17 @@ export async function handleCmsRequest( return jsonResponse({ error: 'Forbidden: invalid origin' }, { status: 403 }) } - // Branch scope — resolved ONCE per request from the `X-Instatic-Branch` - // header and handed to every content-shaped route group. Groups that only - // ever address the live site (dashboard, publish, plugins) pin MAIN_SCOPE - // themselves; user / media / auth groups have no branched data at all. - const scope = await resolveBranchScope(req, db) - if (scope instanceof Response) return scope - // Try each route group in order. The first to return a non-null // Response handled the request; null means "this group didn't match, // try the next one". - const response = + // + // The account groups run BEFORE the branch header is looked at. They have + // no branched data, and a tab can carry a header naming a branch that no + // longer exists (deleted from elsewhere, or the database reset under it): + // that must never stop anyone from finishing setup, checking their + // session, or signing in — signing in is how the tab recovers. It also + // means a branch's existence is not revealed before authentication. + const accountResponse = (await handleSetupRoutes(req, db)) ?? (await handleMeRoutes(req, db, options)) ?? (await handleAuthRoutes(req, db)) @@ -96,7 +96,17 @@ export async function handleCmsRequest( ?? (await handleUsersRoutes(req, db)) ?? (await handleRolesRoutes(req, db)) ?? (await handleAuditRoutes(req, db)) - ?? (await handleBranchesRoutes(req, db, scope, options)) + if (accountResponse) return accountResponse + + // Branch scope — resolved ONCE per request from the `X-Instatic-Branch` + // header and handed to every content-shaped route group. Groups that only + // ever address the live site (dashboard, publish, plugins) pin MAIN_SCOPE + // themselves; the media groups have no branched data at all. + const scope = await resolveBranchScope(req, db) + if (scope instanceof Response) return scope + + const response = + (await handleBranchesRoutes(req, db, scope, options)) ?? (await handleSiteRoutes(req, db, scope)) // The transactional whole-document save — must run before the pages/ // components/layouts GET handlers only for tidiness; paths are distinct. diff --git a/src/__tests__/server/branchesHandler.test.ts b/src/__tests__/server/branchesHandler.test.ts index d20ed1352..002d01018 100644 --- a/src/__tests__/server/branchesHandler.test.ts +++ b/src/__tests__/server/branchesHandler.test.ts @@ -78,6 +78,27 @@ describe('branches endpoints', () => { expect(await readJson<{ code: string }>(unknown)).toMatchObject({ code: 'branch_not_found' }) }) + it('lets a stale branch header through the account routes and still refuses it on content', async () => { + harness = await createCapabilityTestHarness() + const owner = await harness.setupOwner() + // A tab remembering a branch that no longer exists sends its header on + // every request, the sign-in included; the account routes must not care. + const stale = { 'X-Instatic-Branch': 'long-gone' } + expect((await harness.cms('/admin/api/cms/setup/status', { headers: stale })).status).toBe(200) + expect((await harness.cms('/admin/api/cms/me', { cookie: owner, headers: stale })).status).toBe(200) + // Wrong password answers as a login failure, not as a missing branch. + const login = await harness.cms('/admin/api/cms/login', { + method: 'POST', + headers: stale, + json: { email: 'nobody@example.test', password: 'not-the-password' }, + }) + expect(login.status).toBe(401) + // Content routes still refuse it, with the code the client falls back on. + const content = await harness.cms(BRANCHES, { cookie: owner, headers: stale }) + expect(content.status).toBe(404) + expect(await readJson<{ code: string }>(content)).toMatchObject({ code: 'branch_not_found' }) + }) + it('refuses duplicate ids, malformed ids, and any change to main', async () => { harness = await createCapabilityTestHarness() const owner = await harness.setupOwner() From 150ee61a441b4bb330a9864ed1a56b1da65e7f9e Mon Sep 17 00:00:00 2001 From: DavidBabinec <hello@davidbabinec.com> Date: Sat, 5 Sep 2026 20:29:25 +0200 Subject: [PATCH 14/16] feat(branches): drop changes from the review, and leave it when the branch is gone Deleting a branch from the strip while standing on its merge review left the page announcing "There is no branch" although the tab had already dropped back to main: the route still named the deleted branch. The review now leaves for the site editor whenever its branch disappears after having loaded, however it went (dropped from the footer, deleted from the strip or another tab, the database reset under it). A link to a branch that never loaded keeps the message and gains a Back to the site button instead of being a dead end. The footer gains Drop changes for whoever may delete the branch (a manager, or its creator): the same DeleteBranchDialog and step-up as the strip's Delete branch, so discarding a reviewed branch is one action from the place the decision is made. Verification: bun run build clean bun run lint clean bun test (architecture, dialogs) all pass headless Chromium on the showcase: Drop changes from the review lands on the site editor on main with the Deleted toast; a dead review link shows the message and the way back --- docs/features/branches.md | 2 +- src/admin/pages/branches/BranchReviewPage.tsx | 44 ++++++++++++++----- src/admin/pages/branches/ReviewFooter.tsx | 23 +++++++++- 3 files changed, 57 insertions(+), 12 deletions(-) diff --git a/docs/features/branches.md b/docs/features/branches.md index 9d97638b2..8ee0ecac1 100644 --- a/docs/features/branches.md +++ b/docs/features/branches.md @@ -162,7 +162,7 @@ Endpoints: `GET|POST /admin/api/cms/branches/:id/merge` and `…/update`. `GET` - **The request row** — the open or last merge request (who, note, state badge: *Awaiting review* / *Changes requested* / *Merged* / *Withdrawn*, a `TagPill` with a state `tone`), with the general conversation beside it and a row of fact tiles (changes by kind, conflicts left, freshness, what merging does). Without a request it offers *Request merge…*. - **One row per change**, its kind (*Page*, *Entry*, *Table*, *File*) and action (*new* / *changed* / *removed*) as badges in the card head, with the thread tile on the left (comments keyed by the change's `key`, an always-present composer) and the change on the right: a page (`row` in the `pages` table) as **before/after frames**, an entry as a field table, a table as its schema, the shell as a settings table, a file as a line diff (`@core/utils/lineDiff`). A change with conflicts carries a strip with *Keep main* / *Take branch* (managers only). - **The decision row** — the decline note, the merge outcome, or the wait. -- **The footer** — managers: *Delete branch after merging* (off by default; on, the merge cannot be undone and the label says so), *Decline…* (open request only; a note is required) and *Merge N changes*, disabled with the count while conflicts are undecided. Merging asks first (`useConfirmAction`, the confirm primitive in its non-destructive tone; *Update from main…* asks the same way), then runs the step-up-gated `POST …/merge`; the success toast carries an *Undo* action, and while the review state's `lastMerge` is set the footer shows *Undo merge* beside the merge button (`POST …/merge/undo`, step-up gated). The page stays on the review after a merge so the undo is at hand; it leaves for the site only when the branch was deleted. Requesters: *Withdraw request*; everyone else: *Request merge…*. +- **The footer** — managers: *Delete branch after merging* (off by default; on, the merge cannot be undone and the label says so), *Decline…* (open request only; a note is required) and *Merge N changes*, disabled with the count while conflicts are undecided. Merging asks first (`useConfirmAction`, the confirm primitive in its non-destructive tone; *Update from main…* asks the same way), then runs the step-up-gated `POST …/merge`; the success toast carries an *Undo* action, and while the review state's `lastMerge` is set the footer shows *Undo merge* beside the merge button (`POST …/merge/undo`, step-up gated). The page stays on the review after a merge so the undo is at hand; it leaves for the site only when the branch was deleted. Requesters: *Withdraw request*; everyone else: *Request merge…*. Whoever may delete the branch (`canActOnBranch`: a manager, or its creator) also gets *Drop changes…* at the left of the footer: the same `DeleteBranchDialog` and step-up as the strip's *Delete branch*. Once the reviewed branch is gone, however it went (dropped here, deleted from the strip or another tab, the database reset under it), the page leaves for the site editor, which is already on main; only a link to a branch that never loaded keeps the "There is no branch" message, with a *Back to the site* button. Page frames: `GET /admin/api/cms/branches/:id/review/render?row=<page row id>&side=main|branch` (`site.read`) returns the page's HTML composed like the branch preview (template chain, draft loops, inlined CSS, `dynamicNodes: 'inline'`) with `annotateNodeIds` on, so every node's root element carries `uid="<composed node id>"`; no runtime scripts are bundled. Both sides read DRAFT rows in their loops (`prefetchLoopData({ drafts: true })`, an explicit switch on `SourceFetchContext`): a merge compares main's draft with the branch's draft, and main's *published* versions are what visitors see, not what the merge writes over — without this a post-type loop rendered its cards on the branch and nothing on main. The page fetches the HTML through `apiTextRequest` and hands it to an `<iframe sandbox="allow-same-origin">` as `srcdoc` (scripts stay off). After load, the frame is measured and every `uid` is mapped back to its page node through `composedNodeSourceId` (`@core/templates`; template composition prefixes ids with `c0_` and `t<i>_`), then the nodes the plan's tree diff lists are outlined in place — one node inside a loop is outlined once per item. Highlights come from the diff, never from guesses. Modes: side by side, swipe (one frame clipped over the other; drag anywhere on the stack, or use the range below it with the keyboard), and the plain change list, where a changed node reads `Changed text: “old” → “new”` from the diff's per-node `details` (`server/branches/changeDetail.ts`: scalar props are quoted, structured ones and node fields are named). Field changes are labelled by field (`Title`, `Slug`, `SEO title`, `SEO description`, `Featured media`), and a cleared field keeps its old value in view. Row content is compared with empty cells normalized (`compactCells` in `contentHash.ts`: absent, `null`, and `""` are the same empty cell), so rows written by different paths never read as changed. diff --git a/src/admin/pages/branches/BranchReviewPage.tsx b/src/admin/pages/branches/BranchReviewPage.tsx index 04f17a23e..9db28887c 100644 --- a/src/admin/pages/branches/BranchReviewPage.tsx +++ b/src/admin/pages/branches/BranchReviewPage.tsx @@ -10,8 +10,8 @@ * stays disabled until every one has a side. Merging runs the same * step-up-gated apply the branch strip used to run from a dialog. */ -import { useEffect, useState } from 'react' -import { MAIN_BRANCH_ID, type MergeChange, type MergeResolution, type ReviewUserLabel } from '@core/branches' +import { useEffect, useRef, useState } from 'react' +import { MAIN_BRANCH_ID, canActOnBranch, type MergeChange, type MergeResolution, type ReviewUserLabel, type SiteBranch } from '@core/branches' import { getErrorMessage } from '@core/utils/errorMessage' import { AdminWorkspaceCanvasLayout } from '@admin/layouts/AdminWorkspaceCanvasLayout' import { useNavigate, useParams } from '@admin/lib/routing' @@ -19,6 +19,7 @@ import { hasCapability } from '@admin/access' import { useAuthenticatedAdminUser } from '@admin/sessionContext' import { mergeBranch, refreshBranches, switchBranch, useActiveBranchId, useBranchStore } from '@admin/state/branchStore' import { StepUpCancelledMessage, useStepUp } from '@admin/shared/StepUp' +import { DeleteBranchDialog } from '@admin/shared/BranchSwitcher/DeleteBranchDialog' import { useConfirmAction } from '@admin/shared/dialogs/ConfirmDeleteDialog' import { UserAvatar } from '@admin/shared/UserAvatar/UserAvatar' import { Button } from '@ui/components/Button' @@ -54,11 +55,23 @@ export function BranchReviewPage() { const branchesLoaded = useBranchStore((state) => state.branchesLoaded) const activeBranchId = useActiveBranchId() const branch = branches.find((candidate) => candidate.id === branchId) ?? null + const navigate = useNavigate() useEffect(() => { if (!branchesLoaded) void refreshBranches() }, [branchesLoaded]) + // The branch can disappear while this page is open (dropped from the + // footer, deleted from the strip or another tab, the database reset under + // it): the tab is already back on main by then, so the page goes back to + // the site instead of announcing a branch that existed a moment ago. A + // link to a branch that never loaded keeps the message and the way back. + const seenRef = useRef(false) + useEffect(() => { + if (branch) seenRef.current = true + else if (branchesLoaded && seenRef.current) navigate('/admin/site') + }, [branch, branchesLoaded, navigate]) + // Reviewing a branch means being on it: "back to the editor" lands there, // and the toolbar strip names what is being reviewed. useEffect(() => { @@ -73,9 +86,14 @@ export function BranchReviewPage() { <div className={styles.canvas}> {branchesLoaded ? ( <div className={styles.state} role="alert"> - {branch?.id === MAIN_BRANCH_ID - ? 'Main is the live site; it is what branches merge into.' - : `There is no branch “${branchId}”.`} + <span> + {branch?.id === MAIN_BRANCH_ID + ? 'Main is the live site; it is what branches merge into.' + : `There is no branch “${branchId}”.`} + </span> + <Button variant="secondary" size="sm" type="button" onClick={() => navigate('/admin/site')} data-testid="branch-review-back"> + Back to the site + </Button> </div> ) : ( <div className={styles.loading} aria-busy="true" aria-label="Loading the branch"> @@ -88,18 +106,21 @@ export function BranchReviewPage() { /> ) } - return <Review key={branch.id} branchId={branch.id} branchName={branch.name} /> + return <Review key={branch.id} branch={branch} /> } interface ReviewProps { - branchId: string - branchName: string + branch: SiteBranch } -function Review({ branchId, branchName }: ReviewProps) { +function Review({ branch }: ReviewProps) { + const branchId = branch.id + const branchName = branch.name const navigate = useNavigate() const user = useAuthenticatedAdminUser() const canManage = hasCapability(user, 'site.branches.manage') + // Dropping the branch is for whoever may delete it: a manager, or its creator. + const canDrop = canActOnBranch(user, branch) const { runStepUp } = useStepUp() const confirmAction = useConfirmAction() const data = useBranchReview(branchId) @@ -107,7 +128,7 @@ function Review({ branchId, branchName }: ReviewProps) { const [resolutions, setResolutions] = useState<Record<string, MergeResolution>>({}) // Off by default: a merge that deletes the branch cannot be undone. const [deleteAfter, setDeleteAfter] = useState(false) - const [dialog, setDialog] = useState<'request' | 'decline' | null>(null) + const [dialog, setDialog] = useState<'request' | 'decline' | 'drop' | null>(null) const [busy, setBusy] = useState(false) const me: ReviewUserLabel = { @@ -511,11 +532,14 @@ function Review({ branchId, branchName }: ReviewProps) { onMerge={merge} onUndo={() => { void undo() }} onDecline={() => setDialog('decline')} + canDrop={canDrop} + onDrop={() => setDialog('drop')} onWithdraw={() => { void withBusy('withdraw the request', () => data.withdraw()) }} onRequest={() => setDialog('request')} /> </div> + {dialog === 'drop' && <DeleteBranchDialog branch={branch} onClose={() => setDialog(null)} />} {dialog === 'request' && ( <NoteDialog eyebrow="Request a merge" diff --git a/src/admin/pages/branches/ReviewFooter.tsx b/src/admin/pages/branches/ReviewFooter.tsx index b9a0a3ed4..c6d0b7919 100644 --- a/src/admin/pages/branches/ReviewFooter.tsx +++ b/src/admin/pages/branches/ReviewFooter.tsx @@ -1,7 +1,8 @@ /** * ReviewFooter — the review's decision bar. A manager decides here (keep or * delete the branch after merging, undo the last merge, decline, merge); a - * requester waits or withdraws; everyone else asks for a merge. Presentation + * requester waits or withdraws; everyone else asks for a merge; whoever may + * delete the branch can drop its changes from here. Presentation * only: every action is a callback the page owns, and the page runs the * confirmations, step-ups, and toasts. */ @@ -9,6 +10,7 @@ import type { BranchMergeRecord, BranchMergeRequest, MergePlan } from '@core/bra import { Button } from '@ui/components/Button' import { Switch } from '@ui/components/Switch' import { GitMergeSolidIcon } from 'pixel-art-icons/icons/git-merge-solid' +import { TrashSolidIcon } from 'pixel-art-icons/icons/trash-solid' import { relativeIsoAgo } from './reviewFormat' import styles from './BranchReviewPage.module.css' @@ -28,6 +30,9 @@ interface ReviewFooterProps { onDecline: () => void onWithdraw: () => void onRequest: () => void + /** May delete the branch (a manager, or its creator): shows *Drop changes*. */ + canDrop: boolean + onDrop: () => void } export function ReviewFooter({ @@ -45,12 +50,28 @@ export function ReviewFooter({ onDecline, onWithdraw, onRequest, + canDrop, + onDrop, }: ReviewFooterProps) { const open = request?.status === 'open' const changeCountLabel = `${plan.changes.length} change${plan.changes.length === 1 ? '' : 's'}` return ( <footer className={styles.footer} data-testid="branch-review-footer"> + {canDrop && ( + <Button + variant="secondary" + size="sm" + type="button" + disabled={busy} + tooltip="Delete the branch and discard every unmerged change" + onClick={onDrop} + data-testid="review-drop" + > + <TrashSolidIcon size={12} aria-hidden="true" /> + <span>Drop changes…</span> + </Button> + )} {canManage ? ( <> <label className={styles.footerToggle}> From 45b92c44fe7e5222350fc3b20f499689b9f4108b Mon Sep 17 00:00:00 2001 From: DavidBabinec <hello@davidbabinec.com> Date: Sat, 5 Sep 2026 20:40:29 +0200 Subject: [PATCH 15/16] fix(branches): resolve viewport units in the review frames against a desktop screen The review shows a page in an iframe as tall as the document, so the page can be seen whole and scaled. That frame has no screen height of its own: a hero set to 62vh measured itself against the document, grew it, and was measured again, up to the frame's 2400px ceiling. Setomi's home page showed a hero three times taller than on the live site. The render endpoint now resolves every viewport unit (vh, vw, vmin, vmax, with the d/s/l prefixes) in the page's style blocks and style attributes against REVIEW_VIEWPORT (1280 x 800, shared from @core/branches with the frame's width), which is what a desktop screen does with the same rules; the frame shows that screen's rendering, captured full length. Names such as --gap-1vh and text that mentions a unit are left alone. Unit tests cover the block, the attribute, calc(), the prefixed units, names, and text. docs/features/branches.md describes it. Verification: bun run build clean bun run lint clean bun test (review, branches handler, architecture) all pass headless Chromium on the showcase: the Setomi hero measures as on a desktop screen in both frames --- docs/features/branches.md | 2 +- server/publish/branchReviewRender.ts | 5 +- server/publish/reviewViewportUnits.ts | 46 +++++++++++++++++++ .../server/reviewViewportUnits.test.ts | 30 ++++++++++++ src/admin/pages/branches/PageCompare.tsx | 7 ++- src/core/branches/index.ts | 1 + src/core/branches/schemas.ts | 5 ++ 7 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 server/publish/reviewViewportUnits.ts create mode 100644 src/__tests__/server/reviewViewportUnits.test.ts diff --git a/docs/features/branches.md b/docs/features/branches.md index 8ee0ecac1..f2c633f07 100644 --- a/docs/features/branches.md +++ b/docs/features/branches.md @@ -164,7 +164,7 @@ Endpoints: `GET|POST /admin/api/cms/branches/:id/merge` and `…/update`. `GET` - **The decision row** — the decline note, the merge outcome, or the wait. - **The footer** — managers: *Delete branch after merging* (off by default; on, the merge cannot be undone and the label says so), *Decline…* (open request only; a note is required) and *Merge N changes*, disabled with the count while conflicts are undecided. Merging asks first (`useConfirmAction`, the confirm primitive in its non-destructive tone; *Update from main…* asks the same way), then runs the step-up-gated `POST …/merge`; the success toast carries an *Undo* action, and while the review state's `lastMerge` is set the footer shows *Undo merge* beside the merge button (`POST …/merge/undo`, step-up gated). The page stays on the review after a merge so the undo is at hand; it leaves for the site only when the branch was deleted. Requesters: *Withdraw request*; everyone else: *Request merge…*. Whoever may delete the branch (`canActOnBranch`: a manager, or its creator) also gets *Drop changes…* at the left of the footer: the same `DeleteBranchDialog` and step-up as the strip's *Delete branch*. Once the reviewed branch is gone, however it went (dropped here, deleted from the strip or another tab, the database reset under it), the page leaves for the site editor, which is already on main; only a link to a branch that never loaded keeps the "There is no branch" message, with a *Back to the site* button. -Page frames: `GET /admin/api/cms/branches/:id/review/render?row=<page row id>&side=main|branch` (`site.read`) returns the page's HTML composed like the branch preview (template chain, draft loops, inlined CSS, `dynamicNodes: 'inline'`) with `annotateNodeIds` on, so every node's root element carries `uid="<composed node id>"`; no runtime scripts are bundled. Both sides read DRAFT rows in their loops (`prefetchLoopData({ drafts: true })`, an explicit switch on `SourceFetchContext`): a merge compares main's draft with the branch's draft, and main's *published* versions are what visitors see, not what the merge writes over — without this a post-type loop rendered its cards on the branch and nothing on main. The page fetches the HTML through `apiTextRequest` and hands it to an `<iframe sandbox="allow-same-origin">` as `srcdoc` (scripts stay off). After load, the frame is measured and every `uid` is mapped back to its page node through `composedNodeSourceId` (`@core/templates`; template composition prefixes ids with `c0_` and `t<i>_`), then the nodes the plan's tree diff lists are outlined in place — one node inside a loop is outlined once per item. Highlights come from the diff, never from guesses. Modes: side by side, swipe (one frame clipped over the other; drag anywhere on the stack, or use the range below it with the keyboard), and the plain change list, where a changed node reads `Changed text: “old” → “new”` from the diff's per-node `details` (`server/branches/changeDetail.ts`: scalar props are quoted, structured ones and node fields are named). Field changes are labelled by field (`Title`, `Slug`, `SEO title`, `SEO description`, `Featured media`), and a cleared field keeps its old value in view. Row content is compared with empty cells normalized (`compactCells` in `contentHash.ts`: absent, `null`, and `""` are the same empty cell), so rows written by different paths never read as changed. +Page frames: `GET /admin/api/cms/branches/:id/review/render?row=<page row id>&side=main|branch` (`site.read`) returns the page's HTML composed like the branch preview (template chain, draft loops, inlined CSS, `dynamicNodes: 'inline'`) with `annotateNodeIds` on, so every node's root element carries `uid="<composed node id>"`; no runtime scripts are bundled. Both sides read DRAFT rows in their loops (`prefetchLoopData({ drafts: true })`, an explicit switch on `SourceFetchContext`): a merge compares main's draft with the branch's draft, and main's *published* versions are what visitors see, not what the merge writes over — without this a post-type loop rendered its cards on the branch and nothing on main. Viewport units in the rendered HTML (`vh`, `vw`, `vmin`, `vmax`, with the `d`/`s`/`l` prefixes) are resolved server-side against `REVIEW_VIEWPORT` (1280×800, `@core/branches`; `server/publish/reviewViewportUnits.ts`), because the frame is an iframe as tall as the document and has no screen height of its own: a `62vh` hero would otherwise measure itself against the document, grow it, and be measured again up to the frame's ceiling. The page is shown the way a desktop screen renders it, captured full length. The page fetches the HTML through `apiTextRequest` and hands it to an `<iframe sandbox="allow-same-origin">` as `srcdoc` (scripts stay off). After load, the frame is measured and every `uid` is mapped back to its page node through `composedNodeSourceId` (`@core/templates`; template composition prefixes ids with `c0_` and `t<i>_`), then the nodes the plan's tree diff lists are outlined in place — one node inside a loop is outlined once per item. Highlights come from the diff, never from guesses. Modes: side by side, swipe (one frame clipped over the other; drag anywhere on the stack, or use the range below it with the keyboard), and the plain change list, where a changed node reads `Changed text: “old” → “new”` from the diff's per-node `details` (`server/branches/changeDetail.ts`: scalar props are quoted, structured ones and node fields are named). Field changes are labelled by field (`Title`, `Slug`, `SEO title`, `SEO description`, `Featured media`), and a cleared field keeps its old value in view. Row content is compared with empty cells normalized (`compactCells` in `contentHash.ts`: absent, `null`, and `""` are the same empty cell), so rows written by different paths never read as changed. Requests and comments (`server/branches/review.ts`, `server/repositories/branchReviews.ts`, migration `028_site_branch_reviews`): `site_branch_merge_requests` (one open per branch; `content_hash` of every branch entity at request time, so the page can say when the branch moved on) and `site_branch_review_comments` (keyed by branch and `entity_key`, `''` for the request itself; they outlive a declined request). Both cascade with the branch. diff --git a/server/publish/branchReviewRender.ts b/server/publish/branchReviewRender.ts index 4b945d162..10e31d191 100644 --- a/server/publish/branchReviewRender.ts +++ b/server/publish/branchReviewRender.ts @@ -8,6 +8,7 @@ * the plan says changed, and no runtime scripts are bundled — the frame * is sandboxed without scripts, so bundling would be wasted work. */ +import { REVIEW_VIEWPORT } from '@core/branches' import '../../src/modules/base' import '@core/loops/sources' import { registry } from '@core/module-engine' @@ -18,6 +19,7 @@ import type { SourceRequestContext } from '@core/loops/types' import type { DbClient } from '../db/client' import { MAIN_SCOPE, type BranchScope } from '../branches/scope' import { getDraftSiteDocument } from '../repositories/publish' +import { resolveViewportUnits } from './reviewViewportUnits' import { prefetchLoopData } from './loopPrefetch' import { prefetchMediaAssets } from './mediaPrefetch' import { getPublishVersion } from './publishState' @@ -63,5 +65,6 @@ export async function renderBranchReviewPage( annotateNodeIds: true, publishVersion: getPublishVersion(), }) - return rendered.html + // Viewport units against a desktop screen, not against the document-tall frame. + return resolveViewportUnits(rendered.html, REVIEW_VIEWPORT) } diff --git a/server/publish/reviewViewportUnits.ts b/server/publish/reviewViewportUnits.ts new file mode 100644 index 000000000..d5dc15e6c --- /dev/null +++ b/server/publish/reviewViewportUnits.ts @@ -0,0 +1,46 @@ +/** + * Resolve viewport units in a rendered page against a fixed desktop + * viewport. + * + * The merge review shows a page in an iframe as tall as the document, so + * the frame has no real screen height: `height: 70vh` would measure itself + * against the document, grow the document, and be measured again, until + * the frame's ceiling. A visitor's browser resolves the same rule against + * its screen. This does the same against the review's viewport, in every + * `<style>` block and `style` attribute, so the frame shows the page the + * way a desktop screen would, only captured full length. + */ + +/** `62vh`, `calc(100dvh - 80px)`, `5.8vw`, `10vmin`; never `--gap-1vh`. */ +const VIEWPORT_UNIT = /(-?(?:\d+\.?\d*|\.\d+))(?:d|s|l)?v(h|w|min|max)\b/gi + +export interface ReviewViewport { + width: number + height: number +} + +function toPx(value: number, axis: string, viewport: ReviewViewport): string { + const base = + axis === 'h' ? viewport.height + : axis === 'w' ? viewport.width + : axis === 'min' ? Math.min(viewport.width, viewport.height) + : Math.max(viewport.width, viewport.height) + const px = (value * base) / 100 + return `${Math.round(px * 100) / 100}px` +} + +function resolveCss(css: string, viewport: ReviewViewport): string { + return css.replace(VIEWPORT_UNIT, (match: string, value: string, axis: string, offset: number, source: string) => { + // Only a bare number precedes a unit; anything word-like before it makes + // this part of a name (`--gap-1vh`, `a1vh`). + const before = offset > 0 ? source[offset - 1] : '' + if (before !== '' && /[\w-]/.test(before)) return match + return toPx(Number(value), axis, viewport) + }) +} + +export function resolveViewportUnits(html: string, viewport: ReviewViewport): string { + return html + .replace(/(<style\b[^>]*>)([\s\S]*?)(<\/style>)/gi, (_match, open: string, css: string, close: string) => open + resolveCss(css, viewport) + close) + .replace(/(\sstyle=")([^"]*)(")/gi, (_match, open: string, css: string, close: string) => open + resolveCss(css, viewport) + close) +} diff --git a/src/__tests__/server/reviewViewportUnits.test.ts b/src/__tests__/server/reviewViewportUnits.test.ts new file mode 100644 index 000000000..7bce3d9d9 --- /dev/null +++ b/src/__tests__/server/reviewViewportUnits.test.ts @@ -0,0 +1,30 @@ +/** + * The merge review's frames resolve viewport units against a fixed desktop + * viewport, so a `vh`-sized hero is as tall as on a screen rather than as + * tall as the whole document. + */ +import { describe, expect, it } from 'bun:test' +import { resolveViewportUnits } from '../../../server/publish/reviewViewportUnits' + +const viewport = { width: 1280, height: 800 } + +describe('resolveViewportUnits', () => { + it('turns every viewport unit in a style block into pixels of the review viewport', () => { + const html = '<style>.hero{height:62vh;min-height:calc(100dvh - 80px);width:5.8vw;padding:10vmin 10vmax;margin:-0.14vw}</style>' + expect(resolveViewportUnits(html, viewport)).toBe( + '<style>.hero{height:496px;min-height:calc(800px - 80px);width:74.24px;padding:80px 128px;margin:-1.79px}</style>', + ) + }) + + it('resolves style attributes too, and leaves text and names alone', () => { + const html = '<p style="height: 50vh">Set it to 100vh, or use --gap-1vh and a1vh.</p><style>:root{--gap-1vh:2px;--x:1vh}</style>' + expect(resolveViewportUnits(html, viewport)).toBe( + '<p style="height: 400px">Set it to 100vh, or use --gap-1vh and a1vh.</p><style>:root{--gap-1vh:2px;--x:8px}</style>', + ) + }) + + it('is a no-op for a page without viewport units', () => { + const html = '<style>.a{height:100%;width:12px}</style><div style="color:red">x</div>' + expect(resolveViewportUnits(html, viewport)).toBe(html) + }) +}) diff --git a/src/admin/pages/branches/PageCompare.tsx b/src/admin/pages/branches/PageCompare.tsx index 7d61c7ffd..81fd8c9ba 100644 --- a/src/admin/pages/branches/PageCompare.tsx +++ b/src/admin/pages/branches/PageCompare.tsx @@ -13,7 +13,7 @@ * through `composedNodeSourceId` before it is matched against the plan. */ import { useCallback, useEffect, useRef, useState, type CSSProperties, type PointerEvent } from 'react' -import type { MergeTreeDiff, ReviewRenderSide } from '@core/branches' +import { REVIEW_VIEWPORT, type MergeTreeDiff, type ReviewRenderSide } from '@core/branches' import { apiTextRequest, isAbortError } from '@core/http' import { cmsBranchReviewRenderUrl } from '@core/persistence' import { composedNodeSourceId } from '@core/templates' @@ -23,7 +23,10 @@ import { Switch } from '@ui/components/Switch' import { changedNodeLine } from './reviewFormat' import styles from './BranchReviewPage.module.css' -const PAGE_WIDTH = 1280 +// The frame is laid out at the review viewport's width; the server resolved +// the page's viewport units against the same viewport, so a `vh` hero is as +// tall as on a screen although the frame is as tall as the document. +const PAGE_WIDTH = REVIEW_VIEWPORT.width const MIN_HEIGHT = 360 const MAX_HEIGHT = 2400 diff --git a/src/core/branches/index.ts b/src/core/branches/index.ts index 615cfeeab..9d8043773 100644 --- a/src/core/branches/index.ts +++ b/src/core/branches/index.ts @@ -48,6 +48,7 @@ export { ReviewUserLabelSchema, REVIEW_COMMENT_MAX_LENGTH, REVIEW_NOTE_MAX_LENGTH, + REVIEW_VIEWPORT, MergeDirectionSchema, MergePlanEnvelopeSchema, MergePlanSchema, diff --git a/src/core/branches/schemas.ts b/src/core/branches/schemas.ts index 4a25e29cd..b53a52e9e 100644 --- a/src/core/branches/schemas.ts +++ b/src/core/branches/schemas.ts @@ -277,6 +277,11 @@ export type BranchReviewState = Static<typeof BranchReviewStateSchema> export const REVIEW_NOTE_MAX_LENGTH = 2000 export const REVIEW_COMMENT_MAX_LENGTH = 4000 +/** + * The desktop viewport the review's page frames stand for: the frame is + * laid out this wide, and the server resolves viewport units against it. + */ +export const REVIEW_VIEWPORT = { width: 1280, height: 800 } as const export const CreateMergeRequestBodySchema = Type.Object({ note: Type.String({ maxLength: REVIEW_NOTE_MAX_LENGTH }), From dffe67bb2a22d2f1e73455451be528d318333a6b Mon Sep 17 00:00:00 2001 From: DavidBabinec <hello@davidbabinec.com> Date: Sat, 5 Sep 2026 21:26:58 +0200 Subject: [PATCH 16/16] fix(branches): show the whole page in the review frames The page frames were capped at 2400px of document, so a change at the bottom of a long page was out of the frame: Setomi's home page is 4288px tall once its viewport units resolve as on a screen. The frame is now as tall as the page; the remaining ceiling (16000px) only guards against a runaway layout, a document that keeps growing as it is measured. The two columns of the side-by-side mode share one scroll with the same top, so positions still line up. Verification: bun run build clean bun run lint clean bun test (architecture) all pass headless Chromium on the showcase: both frames of a changed home page measure the page's full height --- docs/features/branches.md | 2 +- src/admin/pages/branches/PageCompare.tsx | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/features/branches.md b/docs/features/branches.md index f2c633f07..5a7c4d920 100644 --- a/docs/features/branches.md +++ b/docs/features/branches.md @@ -164,7 +164,7 @@ Endpoints: `GET|POST /admin/api/cms/branches/:id/merge` and `…/update`. `GET` - **The decision row** — the decline note, the merge outcome, or the wait. - **The footer** — managers: *Delete branch after merging* (off by default; on, the merge cannot be undone and the label says so), *Decline…* (open request only; a note is required) and *Merge N changes*, disabled with the count while conflicts are undecided. Merging asks first (`useConfirmAction`, the confirm primitive in its non-destructive tone; *Update from main…* asks the same way), then runs the step-up-gated `POST …/merge`; the success toast carries an *Undo* action, and while the review state's `lastMerge` is set the footer shows *Undo merge* beside the merge button (`POST …/merge/undo`, step-up gated). The page stays on the review after a merge so the undo is at hand; it leaves for the site only when the branch was deleted. Requesters: *Withdraw request*; everyone else: *Request merge…*. Whoever may delete the branch (`canActOnBranch`: a manager, or its creator) also gets *Drop changes…* at the left of the footer: the same `DeleteBranchDialog` and step-up as the strip's *Delete branch*. Once the reviewed branch is gone, however it went (dropped here, deleted from the strip or another tab, the database reset under it), the page leaves for the site editor, which is already on main; only a link to a branch that never loaded keeps the "There is no branch" message, with a *Back to the site* button. -Page frames: `GET /admin/api/cms/branches/:id/review/render?row=<page row id>&side=main|branch` (`site.read`) returns the page's HTML composed like the branch preview (template chain, draft loops, inlined CSS, `dynamicNodes: 'inline'`) with `annotateNodeIds` on, so every node's root element carries `uid="<composed node id>"`; no runtime scripts are bundled. Both sides read DRAFT rows in their loops (`prefetchLoopData({ drafts: true })`, an explicit switch on `SourceFetchContext`): a merge compares main's draft with the branch's draft, and main's *published* versions are what visitors see, not what the merge writes over — without this a post-type loop rendered its cards on the branch and nothing on main. Viewport units in the rendered HTML (`vh`, `vw`, `vmin`, `vmax`, with the `d`/`s`/`l` prefixes) are resolved server-side against `REVIEW_VIEWPORT` (1280×800, `@core/branches`; `server/publish/reviewViewportUnits.ts`), because the frame is an iframe as tall as the document and has no screen height of its own: a `62vh` hero would otherwise measure itself against the document, grow it, and be measured again up to the frame's ceiling. The page is shown the way a desktop screen renders it, captured full length. The page fetches the HTML through `apiTextRequest` and hands it to an `<iframe sandbox="allow-same-origin">` as `srcdoc` (scripts stay off). After load, the frame is measured and every `uid` is mapped back to its page node through `composedNodeSourceId` (`@core/templates`; template composition prefixes ids with `c0_` and `t<i>_`), then the nodes the plan's tree diff lists are outlined in place — one node inside a loop is outlined once per item. Highlights come from the diff, never from guesses. Modes: side by side, swipe (one frame clipped over the other; drag anywhere on the stack, or use the range below it with the keyboard), and the plain change list, where a changed node reads `Changed text: “old” → “new”` from the diff's per-node `details` (`server/branches/changeDetail.ts`: scalar props are quoted, structured ones and node fields are named). Field changes are labelled by field (`Title`, `Slug`, `SEO title`, `SEO description`, `Featured media`), and a cleared field keeps its old value in view. Row content is compared with empty cells normalized (`compactCells` in `contentHash.ts`: absent, `null`, and `""` are the same empty cell), so rows written by different paths never read as changed. +Page frames: `GET /admin/api/cms/branches/:id/review/render?row=<page row id>&side=main|branch` (`site.read`) returns the page's HTML composed like the branch preview (template chain, draft loops, inlined CSS, `dynamicNodes: 'inline'`) with `annotateNodeIds` on, so every node's root element carries `uid="<composed node id>"`; no runtime scripts are bundled. Both sides read DRAFT rows in their loops (`prefetchLoopData({ drafts: true })`, an explicit switch on `SourceFetchContext`): a merge compares main's draft with the branch's draft, and main's *published* versions are what visitors see, not what the merge writes over — without this a post-type loop rendered its cards on the branch and nothing on main. Viewport units in the rendered HTML (`vh`, `vw`, `vmin`, `vmax`, with the `d`/`s`/`l` prefixes) are resolved server-side against `REVIEW_VIEWPORT` (1280×800, `@core/branches`; `server/publish/reviewViewportUnits.ts`), because the frame is an iframe as tall as the document and has no screen height of its own: a `62vh` hero would otherwise measure itself against the document, grow it, and be measured again without end. The page is shown the way a desktop screen renders it, captured full length: the frame is as tall as the page (`MAX_HEIGHT` in `PageCompare.tsx` is a 16000px sanity ceiling against a runaway layout, not a crop), so a change at the bottom of a long page is in view like any other, and the two columns of the side-by-side mode share one scroll with the same top. The page fetches the HTML through `apiTextRequest` and hands it to an `<iframe sandbox="allow-same-origin">` as `srcdoc` (scripts stay off). After load, the frame is measured and every `uid` is mapped back to its page node through `composedNodeSourceId` (`@core/templates`; template composition prefixes ids with `c0_` and `t<i>_`), then the nodes the plan's tree diff lists are outlined in place — one node inside a loop is outlined once per item. Highlights come from the diff, never from guesses. Modes: side by side, swipe (one frame clipped over the other; drag anywhere on the stack, or use the range below it with the keyboard), and the plain change list, where a changed node reads `Changed text: “old” → “new”` from the diff's per-node `details` (`server/branches/changeDetail.ts`: scalar props are quoted, structured ones and node fields are named). Field changes are labelled by field (`Title`, `Slug`, `SEO title`, `SEO description`, `Featured media`), and a cleared field keeps its old value in view. Row content is compared with empty cells normalized (`compactCells` in `contentHash.ts`: absent, `null`, and `""` are the same empty cell), so rows written by different paths never read as changed. Requests and comments (`server/branches/review.ts`, `server/repositories/branchReviews.ts`, migration `028_site_branch_reviews`): `site_branch_merge_requests` (one open per branch; `content_hash` of every branch entity at request time, so the page can say when the branch moved on) and `site_branch_review_comments` (keyed by branch and `entity_key`, `''` for the request itself; they outlive a declined request). Both cascade with the branch. diff --git a/src/admin/pages/branches/PageCompare.tsx b/src/admin/pages/branches/PageCompare.tsx index 81fd8c9ba..16ec45d49 100644 --- a/src/admin/pages/branches/PageCompare.tsx +++ b/src/admin/pages/branches/PageCompare.tsx @@ -28,7 +28,10 @@ import styles from './BranchReviewPage.module.css' // tall as on a screen although the frame is as tall as the document. const PAGE_WIDTH = REVIEW_VIEWPORT.width const MIN_HEIGHT = 360 -const MAX_HEIGHT = 2400 +// The frame shows the page whole, however long: a change at the bottom of a +// long page must be in view like any other. The ceiling only guards against +// a runaway layout (a document that keeps growing as it is measured). +const MAX_HEIGHT = 16000 interface HighlightBox { key: string