You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(cms): transactional site-document save with explicit deletes (CoreBunch#169)
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>
Copy file name to clipboardExpand all lines: docs/editor.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -630,7 +630,7 @@ The modal uses the tile-card pattern from `docs/design.md`: `--bg-surface` paren
630
630
631
631
Right-clicking a layer (DOM panel or canvas) offers **Save as layout…** — page mode only, disabled with an inline reason on the page root. The action opens `LayoutNameDialog` (`src/admin/pages/site/dialogs/`), then `saveNodeAsLayout` captures the node + its whole subtree **and every referenced style rule** into a `SavedLayout` (`@core/layouts`) on `site.layouts`. The snapshot shape deliberately mirrors the clipboard payload, and both flows share one engine (`@site/store/subtreeSnapshot`): collecting a subtree + its classes, and restoring a snapshot with fresh node ids, scoped classes cloned with remapped `scope.nodeId`, framework classes re-matched by name, and regular classes reused-or-reimported. Inserting a saved layout therefore reproduces the original selection exactly, the same way paste would.
632
632
633
-
Saved layouts persist as rows in the `layouts` system table (`savedLayoutFromRow` / `savedLayoutToCells` in `@core/data/layoutFromRow`) through the same incremental roster save as pages and components (`PUT /admin/api/cms/layouts`, dirty-tracked per layout id). Plugins can ship layouts too — `definePack({ layouts: [{ id, name, html, css? }] })` entries are authored as clean HTML (+ CSS) and compiled to snapshot form at plugin build time (`compilePackLayout`, using the same HTML-import pipeline as "Paste HTML here…"); they install into the same table with ids namespaced `<pluginId>/<id>` and are replaced on pack re-sync (see [`docs/features/plugin-system.md`](features/plugin-system.md)). The inserter's Layouts section groups them accordingly: the user's **Saved** layouts first, then one group per plugin labelled with the plugin's display name (`composeLayoutsSection` + `pluginRuntime.getPluginName`). In the inserter, snapshot-borne hazards disable items inline instead of failing on click: a snapshot carrying a `base.outlet` follows the outlet module's own placement rules, and in VC mode a snapshot whose component refs would create a dependency cycle is disabled. Refs to since-deleted Visual Components are stripped at insertion time. Right-clicking a saved layout in the inserter offers Rename… (closes the inserter and reopens `LayoutNameDialog`) and Delete (immediate — it's an undoable site mutation — confirmed via toast).
633
+
Saved layouts persist as rows in the `layouts` system table (`savedLayoutFromRow` / `savedLayoutToCells` in `@core/data/layoutFromRow`) through the same transactional site-document save as pages and components (`PUT /admin/api/cms/site-document`, dirty-tracked per layout id with explicit deleted-layout ids). Plugins can ship layouts too — `definePack({ layouts: [{ id, name, html, css? }] })` entries are authored as clean HTML (+ CSS) and compiled to snapshot form at plugin build time (`compilePackLayout`, using the same HTML-import pipeline as "Paste HTML here…"); they install into the same table with ids namespaced `<pluginId>/<id>` and are replaced on pack re-sync (see [`docs/features/plugin-system.md`](features/plugin-system.md)). The inserter's Layouts section groups them accordingly: the user's **Saved** layouts first, then one group per plugin labelled with the plugin's display name (`composeLayoutsSection` + `pluginRuntime.getPluginName`). In the inserter, snapshot-borne hazards disable items inline instead of failing on click: a snapshot carrying a `base.outlet` follows the outlet module's own placement rules, and in VC mode a snapshot whose component refs would create a dependency cycle is disabled. Refs to since-deleted Visual Components are stripped at insertion time. Right-clicking a saved layout in the inserter offers Rename… (closes the inserter and reopens `LayoutNameDialog`) and Delete (immediate — it's an undoable site mutation — confirmed via toast).
|`server/handlers/cms/pages.ts`|`pages`-specific endpoints (batch upsert of the page roster from the editor; uses an optimistic-concurrency `baselinePageIds` token so a saving client never deletes a page a sibling session created concurrently)|
179
+
|`server/handlers/cms/pages.ts`|`pages` read endpoint (raw DataRow list for the editor's loader). Writes go through the transactional site-document save (`server/handlers/cms/siteDocument.ts`) with explicit deleted-row ids — a saving client can never delete a page a sibling session created concurrently, because deletion-by-omission no longer exists|
come from a pre/post membership diff, robust to any recipe style), and
387
402
`usePersistence.ts` ships only those — a one-prop edit uploads one page, not
388
-
the site. The full id rosters always go along, so the server's
389
-
delete-what's-missing reconcile keeps full-replace semantics (including the
390
-
ISS-041 baseline). Anything the tracker can't attribute marks `all` and falls
391
-
back to a full save. Granular write gates (`SITE_WRITE_CAPABILITIES`)
392
-
enforce what each role can actually change inside the diff.
393
-
394
-
The page and component roster endpoints are fail-closed: because each reconcile soft-deletes stored rows missing from the incoming roster, malformed entries reject the whole save instead of being repaired by dropping entries. Page and VC trees must have a valid root, matching node-map keys, resolvable child IDs, and no reachable child cycles. Component saves also reject duplicate IDs/names, missing VC refs, and dependency cycles. Tolerant repair remains limited to reads of persisted data where dropping bad entries cannot be misread as an intentional delete request.
395
-
396
-
All three roster endpoints (`/pages`, `/components`, `/layouts`) write through one shared transaction, `reconcileDataRowRoster` (`server/repositories/data/rows/reconcile.ts`), whose ordering lets a single batch move a slug between rows: soft-deletes run **first** (a changed page may take the slug of a page deleted in the same save — the homepage swap), and slug-changing writes are **two-phase** (park on the placeholder slug `''`, which `data_rows_table_slug_active_idx` exempts, then take the final slug once every old slug is free) so within-batch swaps and rotations never transiently collide with the unique index. Slug-uniqueness validation for pages likewise ignores rows the same request reaps. A write whose id matches a **soft-deleted** row revives that row instead of inserting (undo of a delete re-submits the original id, which still owns the primary key). VC and layout name uniqueness is judged on the **derived slug** (`vcSlugFromName` / `layoutSlugFromName`) — names are stored as `data_rows.slug`, so "Button" and "button" are one identity and reject with a 400 instead of dying on the index.
403
+
the site. Delete-then-recreate within one save window nets to a plain write
404
+
at snapshot time; the server 400s any id in both the changed and deleted
405
+
sets as a backstop. Anything the tracker can't attribute marks `all` and
406
+
ships as a replace-mode full save. Granular write gates
407
+
(`SITE_WRITE_CAPABILITIES`) enforce what each role can actually change
408
+
inside the diff.
409
+
410
+
`usePersistence` runs saves through a **single-flight queue**: at most one
411
+
save on the wire and one queued follow-up that reads the latest store state
412
+
— autosave, Cmd+S, save-request events, the MCP bridge, and the unmount
413
+
flush can never interleave requests.
414
+
415
+
The save is **atomic and fail-closed**: shell + components + layouts + pages
416
+
commit in one transaction (`server/handlers/cms/siteDocument.ts`), so one
417
+
malformed entry rejects the whole save and nothing is persisted — no torn
418
+
"shell saved, pages failed" states. Page and VC trees must have a valid
419
+
root, matching node-map keys, resolvable child IDs, and no reachable child
420
+
cycles. Component saves also reject duplicate IDs/names, missing VC refs,
421
+
and dependency cycles — validated against the **merged post-save roster**,
422
+
so a page may reference a component created in the same save (the old
423
+
components-before-pages request-ordering contract is gone). Tolerant repair
424
+
remains limited to reads of persisted data where dropping bad entries
425
+
cannot be misread as an intentional delete request.
426
+
427
+
Row writes go through `applyDataRowChanges`
428
+
(`server/repositories/data/rows/apply.ts`), whose ordering lets a single
429
+
batch move a slug between rows: explicit soft-deletes run **first** (a
430
+
changed page may take the slug of a page deleted in the same save — the
431
+
homepage swap), and slug-changing writes are **two-phase** (park on the
432
+
placeholder slug `''`, which `data_rows_table_slug_active_idx` exempts,
433
+
then take the final slug once every old slug is free) so within-batch swaps
434
+
and rotations never transiently collide with the unique index.
435
+
Slug-uniqueness validation for pages likewise ignores rows the same request
436
+
deletes. A write whose id matches a **soft-deleted** row revives that row
437
+
instead of inserting (undo of a delete re-submits the original id, which
438
+
still owns the primary key). VC and layout name uniqueness is judged on the
439
+
**derived slug** (`vcSlugFromName` / `layoutSlugFromName`) — names are
440
+
stored as `data_rows.slug`, so "Button" and "button" are one identity and
441
+
reject with a 400 instead of dying on the index.
442
+
443
+
Every save allocates a **site-global sync sequence number**
444
+
(`server/repositories/syncSequence.ts`) inside the transaction and stamps it
445
+
on the shell and every written or deleted row (`data_rows.seq`); the
446
+
response returns it (`{ ok:true, seq }`). The seq is the substrate for
447
+
multi-admin conflict detection and delta reconciliation (live-sync plan) —
448
+
informational to the client until that lands.
397
449
398
450
### Atomic diff validation
399
451
400
-
The shell save handler validates the diff before applying — e.g. a user with only `site.content.edit` can't change a class definition (style-edit) or rename a breakpoint (structure-edit). The diff validator is in `src/core/persistence/validate.ts` → `validateSite`.
452
+
The save handler validates the shell diff before applying — e.g. a user with only `site.content.edit` can't change a class definition (style-edit) or rename a breakpoint (structure-edit). The shell diff validator is `validateSiteWriteDiff` (`server/handlers/cms/siteDiff.ts`); per-page category diffs run through `validatePageWriteDiff` (`server/handlers/cms/pageDiff.ts`).
401
453
402
454
---
403
455
@@ -410,16 +462,15 @@ The editor's store works with the in-memory `SiteDocument`:
`SITE_WRITE_CAPABILITIES` is the convenience set `['site.structure.edit', 'site.content.edit', 'site.style.edit']` — defined locally in `server/handlers/cms/site.ts` and `src/admin/access.ts` at each point of use, not in a shared capabilities module. Used by the site shell save handler and the page-row save path. `/pages` accepts any site writer, then diff-validates each changed page by category: page roster, metadata, topology, module identity, non-content props, and dynamic bindings require `site.structure.edit`; content-category props require `site.content.edit`; inline styles/classes/breakpoint overrides require `site.style.edit`. `/components` and `/layouts` accept no-op saves from any site writer so the client can save one dirty family without tripping over empty batches, but actual changed components/layouts or roster removals still require `site.structure.edit`.
36
+
`SITE_WRITE_CAPABILITIES` is the convenience set `['site.structure.edit', 'site.content.edit', 'site.style.edit']` — defined locally in `server/handlers/cms/siteDocument.ts` and `src/admin/access.ts` at each point of use, not in a shared capabilities module. The transactional site-document save (`PUT /admin/api/cms/site-document`) accepts any site writer, then diff-validates the batch by category: page deletions, page metadata, topology, module identity, non-content props, and dynamic bindings require `site.structure.edit`; content-category props (and site-wide SEO copy on the shell) require `site.content.edit`; inline styles/classes/breakpoint overrides and style rules require `site.style.edit`. Empty change sets are no-op saves any site writer may perform, but changed/deleted components and layouts remain structural work (`site.structure.edit`).
0 commit comments