Skip to content

fix(cms): homepage swap + delete in one save no longer fails publish#37

Merged
DavidBabinec merged 1 commit into
mainfrom
fix/homepage-swap-publish
Jun 11, 2026
Merged

fix(cms): homepage swap + delete in one save no longer fails publish#37
DavidBabinec merged 1 commit into
mainfrom
fix/homepage-swap-publish

Conversation

@DavidBabinec

Copy link
Copy Markdown
Contributor

The bug

Set a new page as the homepage, delete the old homepage, hit Publish → the draft save fails with duplicate slug: Duplicate page slug "/index" and the editor wedges in a Draft save failed / Retry publish loop. (Reported with screenshots; reproduced live in the e2e environment.)

The homepage is the page with slug index. When the swap and the delete land in one save batch, PUT /admin/api/cms/pages rejected it because slug-uniqueness validation counted the old homepage row — which this very request was about to soft-delete — as the owner of index.

Two more failures hid behind that one:

  • Write-before-reap: the reconcile transactions wrote changed rows before soft-deleting dropped ones, so even with validation fixed, the new homepage's UPDATE … slug='index' would hit data_rows_table_slug_active_idx mid-transaction. Within-batch slug swaps/rotations (A↔B) could never succeed under per-statement unique enforcement, in any write order.
  • Adversarial review of the fix confirmed two adjacent defects in the same write path (both empirically reproduced): same-derived-slug names (Button vs button, Hero! vs Hero?) passed name-only validation and died on the DB index as an opaque 500, and re-submitting a reaped row id (delete → save → undo → save) hit the soft-deleted row's primary key — a permanent 500 save loop.

The fix

All three roster endpoints (/pages, /components, /layouts) now write through one shared transaction, reconcileDataRowRoster (server/repositories/data/rows/reconcile.ts):

  1. Reap first — soft-deletes free the slugs of dropped rows before any write needs them.
  2. Two-phase slug writes — slug-changing rows park on the placeholder slug '' (exempt from the partial unique index on both dialects), then take their final slug once every old slug is free. Swaps and three-way rotations work in one batch.
  3. Revive on id match — a write whose id matches a soft-deleted row resurrects it (clears deleted_at) instead of inserting into its own primary key.

Pages slug validation now ignores rows the same request reaps (rowsToReap, moved from the pages handler into the shared module). VC and layout name uniqueness is judged on the derived storage slug (vcSlugFromName / layoutSlugFromName, moved into @core/visualComponents / @core/layouts), so colliding names reject with a readable 400 — in the editor dialogs and on the server alike.

Verification

  • TDD: every fix landed against a failing test first (pagesPartialSave.test.ts handler-level via the real capability harness, reconcile.test.ts against the real SQLite unique index).
  • Live e2e: reproduced the exact reported flow pre-fix, re-ran post-fix — save succeeds, publish succeeds, / serves the new homepage.
  • Multi-agent adversarial review of the diff: 8 findings raised, 2 confirmed, both fixed in this PR.
  • bun test (5354 pass / 0 fail), bun run build, bun run lint all green.

🤖 Generated with Claude Code

…aps, revivals

Swapping the homepage and deleting the old one in a single save failed
('duplicate slug: Duplicate page slug "/index"'), wedging the editor in a
Draft-save-failed / Retry-publish loop. Two layers were broken:

- validation counted rows the SAME request reaps as slug owners, and
- the reconcile transactions wrote changed rows BEFORE soft-deleting
  dropped ones, so even valid batches could hit
  data_rows_table_slug_active_idx mid-transaction.

All three roster endpoints (/pages, /components, /layouts) now write through
one shared transaction, reconcileDataRowRoster:

1. soft-deletes run FIRST, freeing the slugs of dropped rows;
2. slug-changing writes are two-phase — park on the placeholder slug ''
   (exempt from the unique index), then take the final slug — so
   within-batch swaps and rotations never transiently collide;
3. a write whose id matches a soft-deleted row REVIVES it instead of
   inserting into its own primary key (undo of a delete re-submits the
   original id; previously a permanent 500 save loop).

Pages slug validation now ignores rows the same request reaps. VC and
layout name uniqueness is judged on the derived storage slug
(vcSlugFromName / layoutSlugFromName moved into their domain modules), so
"Button" vs "button" rejects with a readable 400 instead of dying on the
DB index as an opaque 500 — in the editor dialogs and on the server alike.

Reproduced and verified end-to-end in the live editor (swap homepage,
delete old page, save + publish in one batch; / serves the new homepage).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@DavidBabinec DavidBabinec merged commit ee81a93 into main Jun 11, 2026
6 checks passed
@DavidBabinec DavidBabinec deleted the fix/homepage-swap-publish branch June 13, 2026 09:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant