feat(branches): site branches with preview links, three-way merge, and version restore - #484
Merged
Conversation
DavidBabinec
force-pushed
the
feat/site-branches
branch
from
September 5, 2026 09:40
5c3f9e3 to
36f825e
Compare
…d 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.
…ads 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.
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.
- 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).
- 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.
`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.
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.
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.
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
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)
…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)
…py 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
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
…ranch 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
…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
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
CodeQL flagged getDataRow as imported and never used in branchReview.test.ts. Neither tsc nor eslint checks unused imports in test files, so the bot was the only gate that saw it.
A branch preview rendered its page and injected plugin frontend assets by hand, skipping the rest of what a published page gets: form page tokens, the module-JS script tags, and the publish hooks. A form on a preview link was a dead HTML form and a module with client JS was inert. The preview now feeds applyPublishedHtmlPipeline like every other visitor-facing HTML path, and the architecture gate covers it. The banner is spliced in with a function replacement: a branch named "Q4 $` promo" used to paste the whole document into the banner, since String.replace reads $ patterns in a string replacement. The pipeline's input type no longer carries the CSS bundle, which only the bake needs. The three template helpers the preview and the review render used come through the @core/templates barrel.
An unauthenticated request naming a branch in X-Instatic-Branch got a 404 for an unknown id and a 401 for a real one, which let anyone probe branch ids. resolveBranchScope now checks for a session before it answers 404, so a guessed id reads like a real one; the 404 with its code is still what a signed-in tab falls back on. Undoing a merge that created a table with rows replayed the recorded entries in apply order, so the table's before-image (absent) was restored while its rows were still there and the repository refused with 409 merge_apply. Undo replays in reverse apply order. Deleting a page, moving a row, or retracting a row on a branch bumped main's render cache version; only main's routes are served, so only main's writes evict it now. The branches route group took a scope it never read.
The review page kept the tab on the branch it shows. When a request from the page found the branch deleted, the store dropped the tab to main and started a re-read, and the page switched straight back onto the deleted branch while the registry was stale. fallBackToMain now drops the branch from the registry in the same tick. The page is keyed on the branch id so what it learned about one branch does not decide a link to the next, and a merge that deletes the branch leaves through that one path instead of pushing /admin/site twice. The merge plan fetch takes the abort signal too, and a failed refresh of a review already on screen no longer replaces it with the error state. The server-side viewport-unit rewriter for the review frames was a weaker copy of the canvas scanner: it matched inside url() and quoted strings, treated an uppercase unit as vmax, and missed vi/vb. One resolver now lives in @core/utils/viewportUnits and serves both frames; the review render applies it to every style block and attribute. Field changes name built-in fields from the field definitions (the featured media cell was keyed by the wrong id and showed raw), the node change details omit a prop that is the node's own label instead of the client stripping the prefix back off, and every "n ago" label shares one helper, so an age past a week reads as a date rather than "9/1/2026 ago". The frame width follows REVIEW_VIEWPORT, the swipe handle stays inside a short stack, and the page's round shapes use a --radius-pill token.
nowIso() in @core/utils/isoDate is the one place that says why a displayed timestamp is bound as ISO text rather than SQL current_timestamp; the branch repositories use it and the dialect doc states the rule. The publish snapshot hash and the branch content hash were the same function twice; the site import's stableStringify was canonicalJson again. canonicalJson now treats undefined the way JSON does, so an in-memory value hashes like its stored form. A page node written outside the editor with no children list was dropped by the parser and the pruned tree written back by the relay; a missing list is an empty one now. @core/branches exported jsonEquals, which nothing called, and two dozen schemas and types nothing outside the module imported.
Browser-bridged content tools act in whatever branch the connected workspace has open, but the per-row ownership pre-check read the row from main: a document that exists only on the branch was "not found", and one whose author differs between main and the branch was judged by main. The bridge records the workspace's branch from the request header when the stream opens, and the pre-check reads the row there.
The review polish hid the strip's merge button on the review page and moved the request into the footer; the editor step still expected the strip button there. The step now follows the real path: the strip reads "Request merge" for an editor without merge rights, lands on the review, and the footer carries the request. The feature matrix gains the REVIEW-001 note and the validation table names the spec; the branch rows record today's run.
DavidBabinec
force-pushed
the
feat/site-branches
branch
from
September 11, 2026 18:26
dffe67b to
d123109
Compare
…book A branch preview of an entry ignored the row's SEO title and description: the public renderer passes them as documentMeta and the preview did not, so the head showed the plain title. The preview resolver now carries the override and the preview head matches a published page. restoreDataTable, the merge engine's path for a table the target side had deleted, normalized the incoming field list without the hold every other write applies: a post type revived with only its custom fields lost title and slug and its entries lost their routes. It reads the stored table first and holds the mandatory built-ins the way a PATCH does. The "add a new branched table" cookbook pointed at a snapshotScope that no longer exists; it names collectBranchEntities now.
…h E2E The merge plan named a row by its primary field or title and then fell back to its slug and finally its id, so a row in a custom table without a primary field showed up in the review as "row create uq5VO9VG...". The Content explorer, the AI document list, and the Data delete prompt each had their own version of the same rule. readDisplayTitle in @core/data/cells is now the one rule: the table's primary field, else the title, else the first text field with a value, else "Untitled"; never the id or the slug. The merge entities, the Content explorer, the AI bridge's document list, the Data delete prompt, and the duplicate-row title all read it. The undo-with-rows test pins the label of a title-less row. BRANCH-006 drives the Content workspace on a branch: a post saved there is absent on main, the review names it by its title, and merging with delete lands it on main's Content list. startNewPost, saveSelectedDraft, and entryRow moved from the Content spec into tests/e2e/helpers/content so both specs share them. Registered in the feature matrix and the validation table.
DavidBabinec
marked this pull request as ready for review
September 11, 2026 19:51
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #440 (staging environments: branches are the in-app form of it; no separate database or subdomain)
Refs #430 (a public preview URL for a draft: branch preview links are revocable but do not expire)
Refs #361 (versioning: a page's published versions can be restored into the draft; no draft snapshots yet)
What
Site branches: a private fork of the whole site (shell, tables, rows, files) that is edited in the same editor, reviewed, and merged back into main.
branch_id; main is the live site. Admin requests name the branch in theX-Instatic-Branchheader, collab documents in their doc ids./admin/branches/:id/review): every planned change with its detail, pages rendered before and after with the changed, added, and removed nodes outlined from the plan's tree diff, field and schema diffs, file line diffs, a comment thread per change, and a request → review → merge flow. Editors without merge rights request a merge with a note; branch managers decline with a note or merge with a step-up, deciding each conflict inline. Merging deletes the branch when asked and writes only main's draft; nothing is published.Feature doc:
docs/features/branches.md(including the "Merge review" section).Two capabilities, not one
Forking is additive and private; merging rewrites main's drafts. They are gated separately:
site.branches.createforks a branch and covers the branches you forked: rename, delete, update from main, share or revoke a preview link.site.branches.managecovers every branch, plus the two 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 the server gates and the admin UI both use it, so a control is never offered and then refused: the chip's Create, the context strip's actions, the manage dialog's per-row rename and delete (disabled with the reason when out of reach), and the Spotlight commands. A contributor can fork, work, and ask for review without being able to land anything, which is the persona the review page was built for.The unshipped
027_site_branchesmigration seeds the new capability next to the old one in both dialects, andcreateTestDbnow runssyncSystemRolesafter migrations the way boot does, so system roles in tests come from code rather than the seed snapshot.Why
Editors could not stage a redesign or a content campaign without it going live piecemeal, and had no way to hand a draft to someone outside the admin for review. Without the review page a merge was a blind dialog, so the feature ships with the review as its merge path.
Impact
site,data_tables, anddata_rowstake aBranchScope; site files are merge entities with path-collision conflicts; the collab relay refuses documents of deleted branches and reseeds a revived branch from its rows; rows written outside the editor parse throughparsePageNodeon load. Migrations 026 and 027 are additive (027 addssite_branch_merge_requestsandsite_branch_review_comments).text/plainwith a sandbox CSP and load into sandboxedsrcdocframes; plan reads redact rows the reader cannot see.Verification
The branch-review spec runs the whole flow across two accounts (owner and an editor persona without merge rights) on a fresh database.
Review polish (2026-09-05)
Fixes found testing the merge review on a real templated site:
c0_,t<i>_); the review now maps every rendereduidback to its page node throughcomposedNodeSourceId(@core/templates). A node inside a loop is outlined once per item.SourceFetchContext.drafts), so a post-type loop no longer renders on the branch side only (main's imported rows have no published versions).text: “old” → “new”), proper field labels (SEO title, SEO description, featured media), cleared fields keep the old value, and empty cells (absent /null/"") compare equal. The SEO loss itself was a relay bug: fix(collab): keep the cells a doc does not own when the relay persists a row #504.site_branch_merges(migration029) with before-images;POST …/merge/undoand…/update/undoreverse the latest apply behind the same gates + step-up, refused with409 merge_undowhen the target moved since. The footer shows Undo merge, the success toast carries Undo, the page stays on the review after a merge, and Delete branch after merging defaults to off (a merge that deleted the branch cannot be undone).useConfirmAction; the confirm primitive gains aprimarytone).BranchReviewPagefooter extracted toReviewFooter.tsx(700-line budget).Verification:
bun run build,bun run lint,bun test(full suite) clean; new server tests cover the undo (both sides + base restored, refused after main moved, HTTP gates + 409) and the relay cell-merge regression.Follow-ups from the end-to-end pass on a staged showcase:
ConfirmDeleteProvideris mounted once at the admin root (the review rendered its layout from above the provider and silently skipped the confirmation);touchBranch, rename, and preview revoke bind ISO timestamps instead of SQLite's localcurrent_timestamp(a just-merged branch read "updated 2h ago"); the change list no longer repeats a prop name the node label already says; the swipe handle stays in the first screen.A tab that remembered a since-deleted branch could not sign in again (the dispatcher resolved the branch header before the account routes; the login answered
404 branch_not_found). Account routes now run before the header is looked at; the store's existing fallback drops the tab to main after sign-in.The review footer gains Drop changes… for whoever may delete the branch (same dialog and step-up as the strip), and the page leaves for the site editor whenever its branch disappears after loading; a dead review link keeps the message with a Back to the site button.
Review frames resolve viewport units against a fixed desktop viewport (
REVIEW_VIEWPORT, 1280×800) server-side, so a62vhhero is as tall as on a screen instead of growing with the document-tall frame.Rebase and review pass (2026-09-11)
Rebased onto main (through #526); three conflicts, all where main had landed the relay cell fix (#504) and the stale plugin-asset sweep (#359) that this branch already carried. Review findings fixed on the branch:
applyPublishedHtmlPipeline(form tokens, module scripts, plugin hooks) like every visitor-facing page; a$in a branch name no longer corrupts the banner. Architecture gate covers the preview.409 merge_apply.fallBackToMaindrops the branch from the registry in the same tick so the page cannot switch back onto it.@core/utils/viewportUnits) for the canvas and the review frames; the server copy matched insideurl()and strings and treated uppercase units asvmax.nowIso()replaces nine inline binds and the dialect doc states the rule; one content hash and one canonical JSON serializer (undefinednow follows JSON).childrenlist is parsed as a leaf instead of being dropped and persisted away.@core/branchesbarrel exports only what is used;jsonEqualsremoved.restoreDataTable(the merge's revival of a deleted table) holds a post type'stitleandsluglike every other write; the cookbook namescollectBranchEntitiesinstead of asnapshotScopethat no longer exists. The per-kind entity adapter is queued as the follow-up PR.readDisplayTitle: primary field, title, first text field, else Untitled; never the id), so a custom-table row no longer shows its id in the merge plan. New E2E BRANCH-006: a post written in the Content workspace on a branch stays off main until the merge lands it.Verification on the rebased branch:
bun run build,bun test,bun run lint, andbunx playwright test tests/e2e/branches.e2e.ts tests/e2e/version-history.e2e.ts tests/e2e/branch-review.e2e.ts tests/e2e/content.e2e.ts. Postgres: migrations 027 to 029 applied on a fresh PostgreSQL 16 and the whole flow (fork, post and custom table on the branch, isolation from main, preview link, merge plan labels, merge with delete, undo on a kept branch, delete) ran clean against the Postgres-backed server with no server errors.