Skip to content

feat(cms): transactional site-document save with explicit deletes#169

Merged
DavidBabinec merged 1 commit into
mainfrom
feat/transactional-site-save
Jul 3, 2026
Merged

feat(cms): transactional site-document save with explicit deletes#169
DavidBabinec merged 1 commit into
mainfrom
feat/transactional-site-save

Conversation

@DavidBabinec

Copy link
Copy Markdown
Contributor

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:

PUT /admin/api/cms/site-document
{ mode, site, changedPages, deletedPageIds,
  changedComponents, deletedComponentIds,
  changedLayouts, deletedLayoutIds }
  • 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)

  1. Torn writes — shell PUT commits, pages PUT fails → storage holds a state that never existed in the editor; toolbar says "Save failed" but half landed.
  2. Interleaved saves — nothing serialized autosave / Cmd+S / unmount flush / MCP-bridge saves; requests from two saves could interleave (newer shell + older trees, both "successful").
  3. Cross-session reaping — components/layouts rosters had no ISS-041 baseline: tab 2's autosave silently soft-deleted the component tab 1 just created.
  4. Client-owned ordering — "components before pages" lived in an adapter comment; any future caller could reintroduce silent ref-stripping.

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) replaces reconcileDataRowRoster: 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). …InTx split avoids nested db.transaction (the SQLite serialized-chain wedge, PR refactor(publish): one-way layering — repositories never import the publish layer #38 class).
  • Migration 020 (both dialects, additive, no backfill): site-global sync seqdata_rows.seq (+ index), site.seq, single-row site_sync_state counter. 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.
  • Dirty tracking records deletions via a pre/post membership diff (robust to splice/filter/wholesale recipes); delete-then-recreate nets to a write at snapshot time; server 400s changed∩deleted as backstop.
  • usePersistence single-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 stomps hasUnsavedChanges (site reference-equality guard).
  • ISS-041 baseline machinery (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 via SiteValidationError.path.

Review

Self-reviewed at high effort; three findings found and fixed in this PR:

  1. Table scoping — client-supplied deleted*Ids could soft-delete rows from other tables (old reap ids were server-derived); deletes now filter against the target table's live rows (+ test).
  2. Hot-path regression — every save loaded all three hydrated collections; now gated per collection (shell-only save = zero row queries).
  3. Deep remove patches 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

bun test          # 5959 pass, 0 fail
bun run build     # tsc -b && vite build — clean
bun run lint      # clean

Migration verified additive + parity-gated (migration-parity.test.ts, db-postgres-isms.test.ts).

🤖 Generated with Claude Code

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>
@DavidBabinec
DavidBabinec marked this pull request as ready for review July 3, 2026 14:08
@DavidBabinec
DavidBabinec merged commit 43abba0 into main Jul 3, 2026
6 checks passed
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant