feat(cms): transactional site-document save with explicit deletes#169
Merged
Conversation
Replace the four-request save protocol (PUT /site, /components, /layouts,
/pages — each committing independently, reap-by-omission roster semantics,
no client-side save serialization) with ONE endpoint committing the whole
document in ONE transaction:
PUT /admin/api/cms/site-document
{ mode, site, changedPages, deletedPageIds, changedComponents,
deletedComponentIds, changedLayouts, deletedLayoutIds }
Why: the old protocol could tear a save in half (shell committed, pages
failed), interleave concurrent saves (autosave vs Cmd+S vs unmount flush vs
MCP bridge — no single-flight guard), and silently delete sibling sessions'
components/layouts (their rosters had no ISS-041 baseline token). Deletion
was inferred from roster absence rather than stated.
Server:
- New handler server/handlers/cms/siteDocument.ts: three-phase flow —
validate everything outside the transaction (per-category capability
diff gates identical to the four retired endpoints; pages validate VC
refs against the MERGED post-save component roster, so a page may
reference a component created in the same save), then one writes-only
transaction (shell + components + layouts + pages), then post-commit
publish-version bump. Per-collection work is gated on need: a shell-only
autosave loads no rows; slug checks use the cheap (id, slug) projection;
full-caps callers skip page-roster hydration entirely.
- applyDataRowChanges(InTx) replaces reconcileDataRowRoster: explicit
deletes scoped to the target table's own rows, delete-first + two-phase
slug writes preserved, soft-delete resurrection on write. The …InTx split
avoids nesting db.transaction (the SQLite serialized-chain wedge).
- Migration 020 (both dialects, additive): site-global sync sequence —
data_rows.seq (+ index), site.seq, single-row site_sync_state counter.
Every save allocates one seq inside the transaction and stamps the shell
and every written/deleted row; the response returns it. Substrate for
multi-admin conflict detection and delta reconciliation.
Client:
- CmsAdapter.saveSite is one request; incremental mode ships only changed
rows + explicit deleted ids (no rosters, no baselines), replace mode
(imports, dirty.all) ships full collections.
- Dirty tracking records deletions via a pre/post membership diff (robust
to any recipe style); delete-then-recreate nets to a write at snapshot
time (server 400s changed∩deleted as a backstop).
- usePersistence gains a single-flight save queue: at most one save on the
wire and one queued follow-up reading the latest store state — no
interleaving across autosave/Cmd+S/save-events/MCP-bridge/unmount flush.
Also fixes a latent bug: a save completing after mid-flight edits no
longer stomps hasUnsavedChanges (site reference-equality guard).
- ISS-041 baseline machinery (baselinePageIds, syncedPageIdsRef,
rowsToReap) deleted — reap-by-omission no longer exists.
Tests: all 19 scenarios of the retired endpoint suites ported to
siteDocumentSave.test.ts plus new atomicity (invalid page → nothing
persists), same-save VC-ref, replace-mode, seq-stamping, cross-table
delete-scoping, netting, and single-flight queue coverage. Capability /
security-boundary / route-authorization matrices updated to the new route.
Docs: site-shell.md save section rewritten; capabilities/content-storage/
editor/server docs updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Jul 2, 2026
DavidBabinec
marked this pull request as ready for review
July 3, 2026 14:08
lauer3912
pushed a commit
to ClawCopilot/Instatic
that referenced
this pull request
Jul 20, 2026
… deletes (CoreBunch#169) Cherry-pick upstream 43abba0 — the core transactional save commit. Migration conflict resolution: - Upstream 020_site_sync_sequence kept as-is (data_rows.seq, site.seq, site_sync_state table) — required by the transactional save logic. - Workspace 020_plugin_migrations_registry renumbered to 021 to avoid id collision. Key upstream changes: - New siteDocument handler + transactional apply() replacing reconcile() - syncSequence repository for multi-admin conflict detection - Client-side persistence queue, dirty tracking, and undo/redo updates - Reconcile tests replaced with apply tests
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.
What changed
Saving the site was four independent HTTP requests (
PUT /site,/components,/layouts,/pages), each committing its own transaction, with reap-by-omission roster semantics and no client-side save serialization. This PR replaces the whole protocol with ONE endpoint committing the whole document in ONE transaction:incremental(every editor save): only changed rows + explicitly deleted ids ship. Unmentioned rows are untouchable — deletion is stated intent, never inferred from a missing roster entry.replace(imports, bootstrap): full collections ship; the server derives deletions as stored − shipped.Why (reachable failure modes today)
Server
server/handlers/cms/siteDocument.ts— three-phase flow: validate everything outside the transaction (capability diff-gates ported 1:1 from the four retired endpoints; pages validate VC refs against the merged post-save roster, so a page may reference a component created in the same save), one writes-only transaction, post-commit publish-version bump. Per-collection work gated on need: a shell-only autosave loads zero rows; slug checks use the cheap(id, slug)projection; full-caps callers skip page-roster hydration.applyDataRowChanges(InTx)replacesreconcileDataRowRoster: explicit deletes scoped to the target table's own rows, delete-first + two-phase slug writes preserved (homepage swap + delete, slug swaps/rotations), soft-delete resurrection on write (undo-after-saved-delete).…InTxsplit avoids nesteddb.transaction(the SQLite serialized-chain wedge, PR refactor(publish): one-way layering — repositories never import the publish layer #38 class).data_rows.seq(+ index),site.seq, single-rowsite_sync_statecounter. Every save allocates one seq in-transaction and stamps the shell + every written/deleted row; response returns{ ok, seq }. This is the substrate for the multi-admin live-sync plan (conflict detection + O(delta) reconnect).Client
CmsAdapter.saveSite= one request (was 3 sequential legs); ordering contract gone.usePersistencesingle-flight queue: at most one save on the wire + one queued follow-up reading the latest store state — autosave/Cmd+S/save-events/MCP-bridge/unmount-flush can never interleave. Also fixes a latent bug: a save landing after mid-flight edits no longer stompshasUnsavedChanges(site reference-equality guard).baselinePageIds,syncedPageIdsRef,rowsToReap) deleted — the failure class no longer exists to patch.Behavior change (intentional)
One invalid item now blocks the whole save (previously shell/style edits could persist while pages failed). Consistency over partial progress; the
{ error }envelope names the offending item viaSiteValidationError.path.Review
Self-reviewed at high effort; three findings found and fixed in this PR:
deleted*Idscould soft-delete rows from other tables (old reap ids were server-derived); deletes now filter against the target table's live rows (+ test).removepatches no longer trigger the O(collection) membership diff on every node deletion.Tests
All 19 scenarios of the retired endpoint suites ported to
siteDocumentSave.test.ts(24 tests), plus new coverage: atomicity (invalid page → shell + valid components NOT persisted), same-save VC-ref, replace-mode derivation + guards, seq stamping (written AND deleted rows), cross-table delete scoping, snapshot netting (both directions), and 4 single-flight queue tests with a gated adapter. Capability-matrix / route-authorization / security-boundary suites updated to the new route; architecture gate (SH-6) updated in the same change per CLAUDE.md.Verification
Migration verified additive + parity-gated (
migration-parity.test.ts,db-postgres-isms.test.ts).🤖 Generated with Claude Code