Skip to content

fix(seed): skip content whose slug collides with a trashed entry - #1814

Open
masonjames wants to merge 4 commits into
emdash-cms:mainfrom
masonjames:fix/seed-skip-trashed-slug-collision
Open

masonjames wants to merge 4 commits into
emdash-cms:mainfrom
masonjames:fix/seed-skip-trashed-slug-collision

Conversation

@masonjames

Copy link
Copy Markdown
Contributor

What does this PR do?

Re-applying a seed against a database where a seeded entry has been moved to the trash crashes with a raw SQLite error instead of honoring onConflict:

SqliteError: UNIQUE constraint failed: ec_projects.slug, ec_projects.locale

applySeed's content existence check uses ContentRepository.findBySlug, which filters deleted_at IS NULL — but the content tables' UNIQUE(slug, locale) constraint covers trashed rows too. So a trashed entry is invisible to the conflict check, the insert fires, and the constraint throws. Hit in the wild via the dev setup bypass (/_emdash/api/setup/dev-bypass), which re-applies the seed on every call: on our site, a dozen seeded projects had been moved to the trash, and the bypass 500'd every time.

Fix: when the live lookup misses, check findBySlugIncludingTrashed. A trashed collision now:

  • skips in skip mode (counted in result.content.skipped, with a console warning naming the entry),
  • skips in update mode too — a trashed row is a deliberate deletion, and a seed re-apply must not resurrect or overwrite it,
  • throws a clear Conflict: ... already exists (in trash) in error mode instead of the raw constraint error.

The live-row path is untouched (the extra lookup only runs when the live check misses). Three new tests pin the semantics per mode; the skip/update tests fail on main with the exact production error.

Issue: none filed — happy to open one retroactively if you prefer tracking it.

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

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). N/A: no admin UI changes.
  • I have added a changeset (emdash, patch)
  • New features link to an approved Discussion: N/A, bug fix.

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Fable 5 (Claude Code)

Screenshots / test output

  • Failing on main before the fix: SqliteError: UNIQUE constraint failed: ec_posts.slug, ec_posts.locale (skip/update modes) and the raw constraint error surfacing in error mode
  • vitest run tests/unit/seed/ → 129 passed (8 files)
  • Full core suite → 314 files, 4280 tests, all passed
  • pnpm lint:json | jq '.diagnostics | length'0
  • pnpm format → ran
  • Changeset added (emdash, patch)

applySeed's existence check uses the live-rows-only findBySlug, but the
content tables' UNIQUE(slug, locale) constraint also covers trashed
rows — so re-applying a seed against a database where a seeded entry
had been moved to the trash crashed with a raw SQLite constraint error
instead of honoring onConflict. Hit in the wild via the dev setup
bypass, which re-applies the seed on every call.

Trashed collisions now skip in every non-error mode — deliberately
deleted content is never resurrected or overwritten — and error mode
reports a clear conflict message.
@changeset-bot

changeset-bot Bot commented Jul 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 15e6270

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

This PR includes changesets to release 17 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/plugin-mcp-smoke Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash 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

@github-actions github-actions Bot added area/core size/M review/needs-review No maintainer or bot review yet labels Jul 3, 2026
@pkg-pr-new

pkg-pr-new Bot commented Jul 3, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/auth-atproto

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

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

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

emdash

npm i https://pkg.pr.new/emdash@1814

create-emdash

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

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

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

@emdash-cms/plugin-cli

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

@emdash-cms/plugin-types

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

@emdash-cms/registry-client

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

@emdash-cms/registry-lexicons

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

@emdash-cms/registry-verification

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

@emdash-cms/sandbox-workerd

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-field-kit

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: 6e016cb

@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.

This PR fixes a real, well-explained bug: applySeed only checks live rows for slug conflicts, so re-applying a seed after an entry was moved to trash crashes on the UNIQUE(slug, locale) constraint. The approach is correct for EmDash — it keeps the unique index intact, respects the deliberate-deletion semantics of the trash, and preserves the existing live-row behavior unchanged.

The implementation is clean on the safety front: the lookup uses the existing parameterized findBySlugIncludingTrashed, filters by locale as required, and the changeset is present. Test coverage is good for the three onConflict modes.

I did not run the test suite or linters (no shell), so I’m treating the author’s tooling results as unverified.

One concrete issue: when a trashed collision is skipped, the code adds the trashed row’s id to seedIdMap. That id later flows into translationOf resolution and menu/$ref resolution. ContentRepository.create resolves translationOf via findById, which filters deleted_at IS NULL, so a trashed id causes EmDashValidationError("Translation source content not found") and aborts the whole seed apply. Even for plain $ref or menu refs, pointing at a deleted row is a broken reference. Since the PR’s goal is to stop seed re-applies from crashing on trashed content, this mapping undermines that goal for seeds with references/translations. The fix is to not map the skipped trashed entry to seed IDs at all, which makes downstream references behave like any other unresolved seed reference.

I’d also like to see a regression test covering a translationOf (or $ref/menu ref) pointing at a trashed-collision entry, so this edge case stays fixed.

Comment thread packages/core/src/seed/apply.ts Outdated
Comment thread packages/core/tests/unit/seed/apply.test.ts
Review follow-up: recording the trashed row's id in seedIdMap made the
skipped entry a resolution target for translationOf, $ref, and menu
refs. translationOf resolves through the live-only findById, so a
sibling entry translating the skipped one crashed the whole apply with
'Translation source content not found' — the same failure class this
fix exists to prevent. Skipped trashed entries now behave like any
other unresolved seed reference; regression test covers the
translationOf path.
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/needs-review No maintainer or bot review yet labels Jul 3, 2026
@ascorbic

ascorbic commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

I'm kind've in two minds here. I think this is a valid fix, but I also think we need to think a bit more about what trashing a post means for the slug. I'm not sure it should collide with a live post. That would mean either changing the slug when something is trashed (simple, but hacky) or changing the unique key to be a composite of the slug and trashed status. I'd be interested in people's thoughts on that.

@masonjames

Copy link
Copy Markdown
Contributor Author

FWIW, WordPress hit exactly this (#11863 — open for six years) and shipped the hacky option in 4.5: trash appends __trashed (ref), untrash reclaims the stored slug, and pre-existing trashed posts get renamed lazily when a new post wants their slug (ref). Stable for a decade since.

The follow-up bugs (#40588, #44805) were all restore semantics, so we could design for that up front.

Rename-on-trash is structurally cheaper: UNIQUE(slug, locale) is a table constraint in every ec_* table's DDL, so a composite key means rebuilding every content table on D1 — and restore needs live-collision handling either way. A deterministic suffix needs no stored state: strip it and re-run slug generation.

Either way existing databases have unsuffixed trashed rows regardless, which is why WP kept the lazy path. Happy to open a Discussion for more eyeballs.

@ascorbic

ascorbic commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Yeah, a discussion would be good.

@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Jul 20, 2026
@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 20, 2026
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

This PR has been inactive for 14 days. It will be closed automatically in 7 days if there is no further activity.

If you're still working on this, please push an update or leave a comment.

@github-actions github-actions Bot added stale and removed stale labels Aug 3, 2026
…-slug-collision

# Conflicts:
#	packages/core/src/seed/apply.ts
@masonjames

Copy link
Copy Markdown
Contributor Author

Discussion post created here: #2412

@github-actions github-actions Bot added stale and removed stale labels Aug 24, 2026
@github-actions github-actions Bot added stale and removed stale labels Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core review/needs-rereview Author pushed changes since the last review size/M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants