Skip to content

Commit 43abba0

Browse files
DavidBabinecclaude
andauthored
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>
1 parent ae7ed0d commit 43abba0

43 files changed

Lines changed: 2182 additions & 1396 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/editor.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -630,7 +630,7 @@ The modal uses the tile-card pattern from `docs/design.md`: `--bg-surface` paren
630630

631631
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.
632632

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).
634634

635635
---
636636

docs/features/content-storage.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ All repository functions are dialect-naive ANSI SQL. JSON columns end in `_json`
176176
| File | Owns |
177177
|-----------------------------------------------|-------------------------------------------------------------------------|
178178
| `server/handlers/cms/data/` | Generic `/admin/api/cms/data/tables[/:id]` + `/admin/api/cms/data/rows[/:id]` endpoints |
179-
| `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 |
180180
| `server/handlers/cms/components.ts` | `components`-specific endpoints |
181181
| `server/handlers/cms/publish.ts` | Publish a row, write a version, emit `publish.before/.html/.after` hooks |
182182

docs/features/site-shell.md

Lines changed: 77 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -373,31 +373,83 @@ Pages and VCs follow the same principle: `validateVisualComponents` silently dro
373373
374374
## Saving the site
375375
376-
The shell is saved independently of pages / VCs. Three save paths:
376+
The whole document saves through ONE endpoint, in ONE server transaction:
377377
378-
| Endpoint | Saves |
379-
|---------------------------------------------|------------------------------------------------|
380-
| `PUT /admin/api/cms/site` | The shell — settings, breakpoints, classes, files (always written) |
381-
| `PUT /admin/api/cms/pages` | `{ changedPages, pageIds, baselinePageIds? }` — only changed pages; full id roster drives reaping |
382-
| `PUT /admin/api/cms/components` | `{ changedComponents, componentIds }` — only changed VCs; cross-VC rules run on the merged roster |
378+
```
379+
PUT /admin/api/cms/site-document
380+
{ mode, site, // shell — always written
381+
changedPages, deletedPageIds,
382+
changedComponents, deletedComponentIds,
383+
changedLayouts, deletedLayoutIds }
384+
```
383385
384-
Saves are **incremental**: the editor store derives which pages/VCs changed
385-
from the same Mutative patches that power undo
386-
(`src/admin/pages/site/store/slices/site/dirtyTracking.ts`), and
386+
Two modes:
387+
388+
- **`incremental`** (every editor save): only the changed rows plus
389+
**explicitly deleted** row ids ship. Rows the save doesn't mention are
390+
untouched server-side — deletion is stated intent, never inferred from a
391+
missing roster entry, so a stale client can't delete a sibling session's
392+
rows by omission (the failure class the retired reap-by-omission protocol
393+
needed `baselinePageIds` to patch).
394+
- **`replace`** (imports, fresh-site bootstrap): the full collections ship;
395+
the server derives deletions as stored − shipped. `deleted*Ids` must be
396+
empty.
397+
398+
Saves are **incremental**: the editor store derives which pages/VCs/layouts
399+
changed — and which were deleted — from the same Mutative patches that power
400+
undo (`src/admin/pages/site/store/slices/site/dirtyTracking.ts`; deletions
401+
come from a pre/post membership diff, robust to any recipe style), and
387402
`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.
397449
398450
### Atomic diff validation
399451
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`).
401453
402454
---
403455
@@ -410,16 +462,15 @@ The editor's store works with the in-memory `SiteDocument`:
410462
...siteShell, // id, name, breakpoints, settings, classes, files, runtime, packageJson
411463
pages: Page[],
412464
visualComponents: VisualComponent[],
465+
layouts: SavedLayout[],
413466
}
414467
```
415468
416-
When the editor saves, the persistence layer **splits** the in-memory document back into:
417-
418-
- A `SiteShell` (everything except `pages` and `visualComponents`) → `PUT /site`
419-
- A `Page[]``PUT /pages`
420-
- A `VisualComponent[]``PUT /components`
421-
422-
The split prevents an "all-or-nothing" save: editing the page roster doesn't risk overwriting a concurrent settings change.
469+
When the editor saves, the persistence layer **splits** the in-memory
470+
document into the shell plus per-collection changed/deleted sets — all
471+
inside the one `PUT /site-document` body. Loading stays four parallel GETs
472+
(`/site`, `/pages`, `/components`, `/layouts`) — reads have no atomicity
473+
problem.
423474
424475
---
425476

docs/reference/capabilities.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ For the broader auth flow (sessions, MFA, step-up), see [docs/features/auth-and-
3333
| `site.content.edit` | Modify content props (text, image src/alt, link href) on existing nodes — no structure or style edits | Owner, Admin, Client |
3434
| `site.style.edit` | Modify CSS classes, style overrides, breakpoints, framework tokens | Owner, Admin |
3535

36-
`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`).
3737

3838
### Page publishing
3939

docs/server.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,7 @@ All SQL lives in `server/repositories/`. Each file owns one resource:
362362
| `sessions.ts` | User sessions |
363363
| `setup.ts` | Setup wizard state (`isSetup`, first-run owner) |
364364
| `site.ts` | The single site shell row |
365+
| `syncSequence.ts` | Site-global sync sequence counter (multi-admin sync substrate — stamped on every row the site-document save writes or deletes) |
365366
| `userPreferences.ts` | Per-user editor preferences |
366367
| `users.ts` | Users + auth fields |
367368

server/db/migrations-pg.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1045,4 +1045,30 @@ export const pgMigrations: Migration[] = [
10451045
alter table ai_mcp_connectors add column expires_at timestamptz;
10461046
`,
10471047
},
1048+
{
1049+
// Multi-admin sync substrate: every row written or soft-deleted by the
1050+
// transactional site-document save is stamped with a site-global,
1051+
// monotonically increasing sequence number. One column serves conflict
1052+
// detection (stored seq > client base seq), O(delta) reconnect
1053+
// reconciliation (rows where seq > cursor), and event ordering.
1054+
// `site_sync_state` is the single-row counter (dialect-neutral: a plain
1055+
// row bumped with `set seq = seq + 1 returning seq` inside the save
1056+
// transaction — kept as a row, not a PG sequence, for SQLite parity).
1057+
id: '020_site_sync_sequence',
1058+
sql: `
1059+
alter table data_rows add column seq bigint not null default 0;
1060+
1061+
create index if not exists data_rows_table_seq_idx
1062+
on data_rows (table_id, seq);
1063+
1064+
alter table site add column seq bigint not null default 0;
1065+
1066+
create table if not exists site_sync_state (
1067+
id integer primary key check (id = 1),
1068+
seq bigint not null default 0
1069+
);
1070+
1071+
insert into site_sync_state (id, seq) values (1, 0);
1072+
`,
1073+
},
10481074
]

server/db/migrations-sqlite.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1109,4 +1109,30 @@ export const sqliteMigrations: Migration[] = [
11091109
alter table ai_mcp_connectors add column expires_at text;
11101110
`,
11111111
},
1112+
{
1113+
// Multi-admin sync substrate: every row written or soft-deleted by the
1114+
// transactional site-document save is stamped with a site-global,
1115+
// monotonically increasing sequence number. One column serves conflict
1116+
// detection (stored seq > client base seq), O(delta) reconnect
1117+
// reconciliation (rows where seq > cursor), and event ordering.
1118+
// `site_sync_state` is the single-row counter (dialect-neutral: a plain
1119+
// row bumped with `set seq = seq + 1 returning seq` inside the save
1120+
// transaction — SQLite has no sequence objects).
1121+
id: '020_site_sync_sequence',
1122+
sql: `
1123+
alter table data_rows add column seq integer not null default 0;
1124+
1125+
create index if not exists data_rows_table_seq_idx
1126+
on data_rows (table_id, seq);
1127+
1128+
alter table site add column seq integer not null default 0;
1129+
1130+
create table if not exists site_sync_state (
1131+
id integer primary key check (id = 1),
1132+
seq integer not null default 0
1133+
);
1134+
1135+
insert into site_sync_state (id, seq) values (1, 0);
1136+
`,
1137+
},
11121138
]

0 commit comments

Comments
 (0)