Skip to content

fix(core): let an entry shed a deleted field's value instead of stranding it - #3142

Merged
ascorbic merged 2 commits into
emdash-cms:mainfrom
eisenbruch:fix/deleted-field-stranded-drafts
Sep 21, 2026
Merged

ascorbic merged 2 commits into
emdash-cms:mainfrom
eisenbruch:fix/deleted-field-stranded-drafts

Conversation

@eisenbruch

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes #3141: an entry with a pending draft cannot be saved again once one of its fields is deleted.

A draft revision stores the whole data as JSON, so a deleted field's value stays in it and every read hands the key back. On the way in, validateContentData refuses any key that is not a current field, and there is no request body that gets the entry out:

  • send the data you just read (what the admin editor does): hits: unknown field on collection 'posts';
  • send the key as null or "": refused the same way;
  • omit the key: accepted, but the update merges into the draft's data, so the key survives and the next full save fails again.

This takes the validation side of the two fixes offered in the issue, because it also rescues entries that a delete has already stranded. Two changes in EmDashRuntime.handleContentUpdate:

  • dropUnknownKeysAlreadyStored() removes a key the collection has no field for from the incoming data when the entry already stores it (checking the draft revision when there is one, otherwise the entry). A key the entry does not already store is left alone, so an unknown field is still reported as one and a typo still fails.
  • The merged data is written through keepKnownFields(), so a saved entry sheds keys the collection has no field for instead of carrying them through every revision that follows. Reserved _-prefixed keys are kept, and a collection whose fields cannot be read is left alone rather than emptied.

I have not touched handleContentCreate: a new entry has nothing stored to shed, so an unknown key there is exactly what the existing error is for.

The alternative in the issue, stripping the field from stored data inside schema_delete_field, is worth doing as well, and it is not in this PR. It prevents the next delete from stranding anything, but it cannot help an entry stranded by a delete that has already happened.

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 (packages/core)
  • pnpm lint passes (pnpm lint:json: 0 diagnostics)
  • pnpm test passes (or targeted tests for my change): tests/integration/content and tests/unit/fields, 26 files, 220 tests
  • 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). Not applicable, no admin strings.
  • I have added and reviewed the user-facing changeset (emdash: patch)
  • New features link to an approved Discussion. Not applicable.
  • I have included screenshots below if this PR changes the UI. Not applicable.

AI-generated code disclosure

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

Screenshots / test output

Not applicable for UI.

packages/core/tests/integration/content/deleted-field-stranded-entry.test.ts sets up a published entry with a pending draft, deletes the field, and then covers the four cases. Against main the first three fail:

 × saves the data it just read, and sheds the deleted field
 × accepts an explicit null for the deleted field, and refuses it once the entry is clean
 × drops the value from the merged data even when the key is omitted
 ✓ still refuses a key the entry does not already store

With the change:

 Test Files  1 passed (1)
      Tests  4 passed (4)

And the suites around it:

 Test Files  26 passed (26)
      Tests  220 passed (220)

Opened as a draft: I have six PRs waiting for review, which I understand to be the cap. Mark it ready whenever suits you, or say if you would rather I close it and reopen it later.

@changeset-bot

changeset-bot Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 49d6710

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

@pkg-pr-new

pkg-pr-new Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/auth-atproto

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

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

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

emdash

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

create-emdash

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

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

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

@emdash-cms/plugin-cli

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

@emdash-cms/plugin-test

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

@emdash-cms/plugin-types

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

@emdash-cms/registry-client

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

@emdash-cms/registry-lexicons

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

@emdash-cms/registry-loader

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

@emdash-cms/registry-moderation

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

@emdash-cms/registry-verification

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

@emdash-cms/sandbox-workerd

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-field-kit

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: 49d6710

@eisenbruch
eisenbruch marked this pull request as ready for review September 16, 2026 06:22
@github-actions github-actions Bot added the review/needs-review No maintainer or bot review yet label Sep 16, 2026
@ascorbic ascorbic mentioned this pull request Sep 16, 2026
8 of 19 tasks
@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Sep 16, 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.

This PR fixes a real bug: once a collection field is deleted, the draft revision still carries the old value, so EmDashRuntime.handleContentUpdate rejects it as an unknown field and the editor cannot save its way out. The chosen approach — drop unknown keys the entry already stores before validation, and write merged draft data through keepKnownFields() so future revisions also shed deleted keys — is sound for the runtime path it touches, and the tests cover the primary cases well.

However, the same merge/validation logic still exists in ContentRepository.updateDraftAware, which is used by plugin context and both sandbox bridges (workerd and Cloudflare). That path still throws for a key stranded in a draft revision, so the bug is only partially fixed until the helper is shared or updateDraftAware is updated too.

I also have a few smaller items: the new comments are useful but read like a PR-description/narrative in places; the test header references the issue number; and handleContentUpdate can now load the collection field list three or four times in a single save, which is worth consolidating even though it is an authenticated route.

None of these are security or data-loss blockers, but the留下updateDraftAware gap is a real regression left standing.


Findings

  • [needs fixing] packages/core/src/database/repositories/content.ts:956-970

    updateDraftAware still validates every incoming key against the live field list and merges { ...baseData, ...data } without shedding keys whose fields have been deleted. This leaves the same stranding bug alive for every caller that is not EmDashRuntime.handleContentUpdate — specifically packages/core/src/plugins/context.ts, packages/workerd/src/sandbox/bridge-handler.ts, and packages/cloudflare/src/sandbox/bridge.ts. The new keepKnownFields filter should be applied to the merged data here (and already-stored unknown keys should be allowed through the field check), either by sharing the helper or by duplicating the same behavior, so the fix covers every content-write surface.

  • [suggestion] packages/core/src/emdash-runtime.ts:2961-2969

    normalizeMediaFields already loads the collection field list and discards it, then dropUnknownKeysAlreadyStored loads it again, then validateContentData loads it a third time, and the merge further down loads it a fourth time. On an update-with-data this can issue four field-list queries. Consider loading collectionInfo once before normalization and reusing it through the rest of the function, or having normalizeMediaFields return the field list it already fetched.

  • [suggestion] packages/core/src/emdash-runtime.ts:2963-2968

    The comment explains a non-obvious invariant, but it is written as a bug-report narrative ("would be refused... no body that works"). AGENTS.md asks comments to state the invariant for future readers without restating the PR description. Trim it:

    			// Drop unknown field keys the entry already stores (e.g. a deleted field
    			// stranded in a draft revision) before validation, while still rejecting
    			// unknown keys the entry has never stored.
    
  • [suggestion] packages/core/src/emdash-runtime.ts:3971-3979

    The docstring is useful but longer than necessary and reads like the PR description. Keep the reason someone reading the code needs:

    	/**
    	 * Drop incoming keys that have no matching collection field when the entry
    	 * already stores them in its live data or current draft revision.
    	 *
    	 * Draft revisions keep the full `data` JSON, so deleting a field can strand
    	 * the old value; this lets a read-then-write save succeed without allowing
    	 * genuinely unknown keys.
    	 */
    
  • [suggestion] packages/core/tests/integration/content/deleted-field-stranded-entry.test.ts:1-6

    AGENTS.md says comments should never reference issues, PRs, or review threads — that context belongs in the commit message and PR description, not in code. Drop the issue number from the test header:

    /**
     * Deleting a field can strand a pending draft: the revision stores the whole `data`,
     * so the deleted field's value stays there and every read hands it back. Writing that
     * data back was refused as an unknown field, an explicit null was refused too, and
     * omitting the key was accepted but left the merge carrying it, so no request body
     * got the entry out of the state.
     */
    

@emdashbot emdashbot Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed bot:review Trigger an emdashbot code review on this PR review/needs-review No maintainer or bot review yet labels Sep 16, 2026
@eisenbruch

Copy link
Copy Markdown
Contributor Author

Thanks, the updateDraftAware gap is real and I had missed it. Pushed in 5129a8a.

ContentRepository.updateDraftAware (needs fixing). It now applies the same two rules as the runtime handler: a key with no matching field is dropped rather than rejected when the entry already stores it, reading the draft revision when there is one, and the merged data is written through keepKnownFields. The unknown-field check runs after that drop, so a key the entry has never stored still throws Unknown field. That covers plugins/context.ts and both sandbox bridges.

Rather than duplicate the behavior I moved it into packages/core/src/content/known-fields.ts, exporting keepKnownFields and staleStoredKeys, both taking a plain ReadonlySet<string> of field slugs. The runtime and the repository read their field lists differently (schema registry versus the joined _emdash_fields query), so a set keeps the helpers free of either shape.

Two new cases in the integration test cover the repository path: a read-then-write save that sheds the key, and a typo key that is still refused. The first fails against this branch without the content.ts change.

Field-list loads (suggestion). You were right about the count, and SchemaRegistry.getCollectionWithFields does not cache, so each one was two queries. handleContentUpdate now loads it once up front and threads it through: normalizeMediaFields takes it as an optional preloaded argument, dropUnknownKeysAlreadyStored takes the slug set, and the draft merge reuses it. That removes three of the four loads, six queries per save. The fourth is inside validateContentData, which builds its own registry; leaving that alone kept the change contained.

Comments and test header (suggestions). Taken as written, all three.

pnpm typecheck and oxlint --type-aware --deny-warnings are clean, oxfmt has been run, and tests/integration/content plus tests/unit/fields pass at 26 files and 222 tests.

@github-actions github-actions Bot added review/needs-rereview New commits since the last review needs-rebase size/L cla: signed and removed review/awaiting-author Reviewed; waiting on the author to respond size/M labels Sep 16, 2026
…ding it

Deleting a field leaves its value in the entry's draft revision, which stores
the whole data. Every read handed the key back, writing it back was refused as
an unknown field, an explicit null was refused too, and omitting the key was
accepted but left the merge carrying it, so no request body got the entry out.

An update now drops a key the collection has no field for when the entry
already stores it, and the saved data is written without those keys, so the
entry sheds them. A key the entry does not already store is still an unknown
field, so a typo still fails.
The first pass only covered EmDashRuntime.handleContentUpdate, so an entry
with a stranded key stayed unsaveable through ContentRepository.updateDraftAware,
which the plugin context and both sandbox bridges write through. That path now
applies the same two rules, and the shared helpers live in one module rather
than being duplicated per caller.

handleContentUpdate loaded the collection's field list up to four times in a
single save; it is now read once and threaded through normalization, the
stale-key drop and the draft merge.
@eisenbruch
eisenbruch force-pushed the fix/deleted-field-stranded-drafts branch from 5129a8a to 49d6710 Compare September 17, 2026 15:55
@ascorbic
ascorbic merged commit 9c61f93 into emdash-cms:main Sep 21, 2026
86 of 87 checks passed
MA2153 added a commit to MA2153/emdash that referenced this pull request Sep 21, 2026
One conflict, in EmDashRuntime.handleContentUpdate. emdash-cms#3142 hoisted the
collection load out of the draft-revision block so a single save reads the
field list once, and narrowed the block's guard back to `processedData`.
This branch had widened that guard to `processedData || references` so a
save carrying only a picker change still reaches draft staging.

Keep both: the guard stays widened and the local re-load is gone. The hoist
condition has to widen to match, since it only loaded the collection when
the body carried `data` — a references-only save would otherwise reach the
draft merge with a null collection and silently skip revision handling.

`keepKnownFields` now also applies to a references-only save's merged data,
which is the intended shedding behaviour and leaves reserved `_`-prefixed
keys such as `_slug` untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

Deleting a field strands every entry that has a pending draft: the draft keeps the value, and no save is accepted afterwards

2 participants