Skip to content

fix(core): delete a menu item's children together with the item on SQLite and D1 - #4316

Open
danielmlr wants to merge 3 commits into
mainfrom
daniel/menu-item-delete-children
Open

danielmlr wants to merge 3 commits into
mainfrom
daniel/menu-item-delete-children

Conversation

@danielmlr

@danielmlr danielmlr commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

Deleting a menu item on SQLite and D1 now deletes the items nested under it too. Before, the children stayed in the database: the admin menu editor no longer showed them, but the public menu still listed them at the top level.

Migration 036 rebuilt the menu items table without the cascade on parent_id that migration 005 had created, and MenuRepository.deleteItem deleted only the one row. PostgreSQL kept the cascade, so it was not affected. deleteItem now deletes the item and everything below it in one statement, with a recursive query that finds the branch. Because it is one statement, it removes either the whole branch or nothing, on D1 too, which has no transactions. Items that earlier deletes already left behind are not touched.

I found this while working on #4285. Before opening it, I confirmed the regression tests fail without the fix and pass with it.

Type of change

  • Bug fix
  • Feature (requires an accepted feature plan or RFC)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Your connection to this change

  • I use EmDash and ran into the problem this PR addresses
  • I maintain a tool, dependency, or service that EmDash uses, and this change concerns that integration
  • A Maintainer approved this specific change before I opened the PR

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main. (n/a: no admin UI change)
  • I have added and reviewed the user-facing changeset (if this PR changes a published package)
  • New features link to their merged design PR: https://github.com/emdash-cms/emdash/pull/... (n/a: bug fix)
  • I have included screenshots below if this PR changes the UI (n/a: no UI change)

AI assistance disclosure

  • This PR includes AI-generated code or text — model/tool: Claude Sonnet 5.5 and Claude Opus 5.5 (Claude Code)

Screenshots / test output

Not applicable. pnpm build, pnpm typecheck and pnpm lint pass. The new tests in menu-item-parent-validation.test.ts fail on SQLite without the change and pass with it, and also pass on PostgreSQL. tests/workerd/menu-item-delete-d1.test.ts fails on D1 without the change and passes with it. Its second test makes the delete fail partway and checks that nothing was removed; it fails on the earlier batched version of this PR, which had already deleted 50 of 60 children.

@changeset-bot

changeset-bot Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f3b18f3

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/plugin-test Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
create-emdash Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving descendant cleanup into MenuRepository.deleteItem is the right fix for the lost parent_id cascade on SQLite and D1, and it makes every dialect behave the same. Both reviewers found the traversal cycle-safe and the deletes child-first and chunked to SQL_BATCH_SIZE. They also found the tests meaningful, and no other delete path needs changes. The reviewers disagreed on the verdict. One approved with no findings. The other raised two issues: cache invalidation after a partial D1 delete, and missing locale guidance in the changeset cleanup steps. Both are kept below. The approving reviewer did not raise them, and the first one contradicts their reading of D1 failure behavior. They noted that a mid-delete failure on D1 leaves the remaining subtree attached, but did not consider the stale cache. The review was static and nothing was run.


Reviewed by anthropic/claude-opus-5-5 and openai/gpt-6.1-sol.


Findings

  • [needs fixing] packages/core/src/database/repositories/menu.ts:652

    Cache invalidation is reached only when every delete batch succeeds. On D1, withTransaction runs without a transaction, so for a subtree larger than SQL_BATCH_SIZE, an earlier batch can commit before a later batch throws. The handler then returns an error without invalidating the menu object cache, and the route also skips route-cache invalidation. Public navigation can keep showing descendants that were actually deleted until the cache expires.

    Handle the partial-write error path as well as success, including the route-cache invalidation needed when committed writes precede an error. Add a regression test for a later-batch failure with an already populated menu cache.

    The other reviewer did not raise this. They noted only that a partial failure on D1 leaves the remaining subtree still attached to its parent, and that this is the best the approach can do without a transaction.

  • [needs fixing] .changeset/menu-item-delete-children.md:9

    The cleanup procedure omits locale selection. When a menu name has multiple translations, GET /_emdash/api/menus/{name} selects one locale, while handleMenuItemDelete rejects the unqualified DELETE with AMBIGUOUS_LOCALE. Readers following these instructions on a multilingual site cannot remove the orphan, and may not find it in the GET response.

    Document ?locale={locale} on both requests for translated menus, using the affected menu's locale. Also link to the REST authentication instructions so the procedure is usable outside the PR context.

    The other reviewer did not raise this. They praised the changeset for naming the audience and giving the API calls.

@pkg-pr-new

pkg-pr-new Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/admin@4316

@emdash-cms/auth

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/auth@4316

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/auth-atproto@4316

@emdash-cms/blocks

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/blocks@4316

@emdash-cms/cloudflare

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/cloudflare@4316

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/contentful-to-portable-text@4316

emdash

npm i https://pkg.pr.new/emdash-cms/emdash@4316

create-emdash

npm i https://pkg.pr.new/emdash-cms/emdash/create-emdash@4316

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/gutenberg-to-portable-text@4316

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-cli@4316

@emdash-cms/plugin-test

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-test@4316

@emdash-cms/plugin-types

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-types@4316

@emdash-cms/registry-client

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-client@4316

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-lexicons@4316

@emdash-cms/registry-loader

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-loader@4316

@emdash-cms/registry-moderation

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-moderation@4316

@emdash-cms/registry-verification

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-verification@4316

upgrade-emdash

npm i https://pkg.pr.new/emdash-cms/emdash/upgrade-emdash@4316

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/sandbox-workerd@4316

@emdash-cms/x402

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/x402@4316

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-ai-moderation@4316

@emdash-cms/plugin-ai-search

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-ai-search@4316

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-atproto@4316

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-audit-log@4316

@emdash-cms/plugin-color

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-color@4316

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-embeds@4316

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-field-kit@4316

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-forms@4316

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-webhook-notifier@4316

commit: f3b18f3

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

🚀 Deploying Preview to Cloudflare 🚀

Preview URL: https://daniel-menu-item-delete-children.try.emdashcms.com, https://daniel-menu-item-delete-children-emdash-playground.emdash-cms.workers.dev (commit f3b18f3)

This URL reflects your latest Preview deployment

Preview Deployments by commit

Status Deployment URL Commit Updated (UTC) See this deployment's details
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://bf696203.try.emdashcms.com, https://bf696203-emdash-playground.emdash-cms.workers.dev f3b18f3 2026-10-27T21:53:25.833Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://cf0506a1.try.emdashcms.com, https://cf0506a1-emdash-playground.emdash-cms.workers.dev 7ad8d06 2026-10-23T23:13:31.924Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://fba7a7c8.try.emdashcms.com, https://fba7a7c8-emdash-playground.emdash-cms.workers.dev 4af4f1f 2026-10-22T18:11:39.543Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://d7a802da.try.emdashcms.com, https://d7a802da-emdash-playground.emdash-cms.workers.dev 167e31d 2026-10-21T01:37:59.812Z Visit the dashboard ↗

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The repository-level descendant deletion remains the right approach, and the follow-up correctly moves object-cache invalidation into finally. The changeset now addresses the earlier locale-selection problem.

One part of the previous cache finding remains open: a partial D1 deletion still skips route-cache invalidation, so cached public pages can continue displaying deleted descendants. I checked the follow-up diff, transaction behavior, handler error conversion, and both cache invalidation paths. No additional issues found in the changed code. Review was static; no checks were run.


Follow-up review by openai/gpt-6.1-sol, scoped to the changes since the last review.

Comment thread packages/core/src/database/repositories/menu.ts Outdated
@emdashbot emdashbot Bot added review/needs-review No maintainer or bot review yet and removed review/needs-review No maintainer or bot review yet labels Oct 23, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The single-statement recursive deletion is the right approach: it removes the partial-commit state behind the earlier cache finding rather than trying to repair caches afterward.

I reviewed the follow-up SQL, menu scoping, cycle termination, cache invalidation paths, and the new D1 failure regression test. The author’s response addresses the previous finding: a failed statement leaves the subtree unchanged, while successful deletion follows the existing cache invalidation paths. No new issues found in the changed code.

Review was static; no tests, builds, or lint checks were run.


Follow-up review by openai/gpt-6.1-sol, scoped to the changes since the last review.

…Lite and D1

Migration 036 rebuilt `_emdash_menu_items` without the `parent_id`
ON DELETE CASCADE from migration 005, and `deleteItem` removed only the one
row. Deleting a parent item on SQLite and D1 therefore left its children in
the database: the admin menu editor no longer reaches them, while the public
menu shows them at the top level. PostgreSQL kept the cascade and was not
affected.

`deleteItem` now collects the item and everything nested under it and deletes
them, deepest first, in a transaction where the database supports one (D1 does
not). Child rows left behind by earlier deletes are not touched.
On D1 the delete batches run without a transaction, so batches before a
failed one stay committed. The menu object cache is now cleared once a
delete has started, whether it finishes or throws.

The changeset's cleanup steps now add ?locale= for translated menus:
without it GET returns one translation and DELETE fails with
AMBIGUOUS_LOCALE.
On D1 the delete batches ran without a transaction, so a failure partway
left some children deleted and the caches not refreshed. One DELETE with
a recursive query for the subtree removes the whole branch or nothing on
every dialect, and needs no read first.
@danielmlr
danielmlr force-pushed the daniel/menu-item-delete-children branch from 7ad8d06 to f3b18f3 Compare October 27, 2026 21:51

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The single-statement recursive deletion remains the right approach: it fixes descendant deletion on SQLite/D1 and avoids partial commits rather than repairing caches afterward.

I checked the PR-relevant follow-up changes, menu scoping, cycle termination, handler and cache-invalidation paths, regression tests, and the changeset’s cleanup instructions. The earlier cache finding is addressed by atomic deletion; no findings remain, and I found no new defects in the reviewed changes.

Review was static; no tests, builds, or lint checks were run.


Follow-up review by openai/gpt-6.1-sol, scoped to the changes since the last review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant