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..8673dcfaf 100644 --- a/docs/e2e/feature-matrix.md +++ b/docs/e2e/feature-matrix.md @@ -134,6 +134,22 @@ 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 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. + +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..29c1cab4b 100644 --- a/docs/e2e/feature-validation.tsv +++ b/docs/e2e/feature-validation.tsv @@ -150,3 +150,10 @@ 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 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/editor.md b/docs/editor.md index 2de45ee41..b41fde6c7 100644 --- a/docs/editor.md +++ b/docs/editor.md @@ -180,10 +180,12 @@ 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. +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..05d4caf27 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`, `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 new file mode 100644 index 000000000..5a7c4d920 --- /dev/null +++ b/docs/features/branches.md @@ -0,0 +1,228 @@ +# 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. +- **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`). +- 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`. + +--- + +## 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 +├── 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) + +server/branches/ +├── scope.ts BranchScope, MAIN_SCOPE, resolveBranchScope(req, db), BRANCH_HEADER +├── 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 / 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 +└── 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 +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 / update dialogs +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 " 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, **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' } +``` + +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*, *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. +- **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` | `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` | `canActOnBranch` | 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, timestamps, **or files**; every site file is an entity of its own (`file:`, 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 — 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. + +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, 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 + +`/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 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…*. 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=&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=""`; 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 `