Skip to content

fix(core): CAS-guard the draft-revision pointer flip on concurrent saves#2130

Draft
marcusbellamyshaw-cell wants to merge 2 commits into
emdash-cms:mainfrom
Emdash-Bug-Testing:fix/1158-draft-revision-save-race
Draft

fix(core): CAS-guard the draft-revision pointer flip on concurrent saves#2130
marcusbellamyshaw-cell wants to merge 2 commits into
emdash-cms:mainfrom
Emdash-Bug-Testing:fix/1158-draft-revision-save-race

Conversation

@marcusbellamyshaw-cell

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes a save race reported as a follow-up comment on closed issue #1158. EmDashRuntime.handleContentUpdate's draft-revision path does read-existing → merge → create-revision → flip-pointer with no wrapping transaction (D1 has no multi-statement transactions) and an unconditional pointer-flip UPDATE. Two concurrent saves (autosave racing an explicit save, or two explicit saves) that both read draft_revision_id: null before either writes both create a new revision and race to flip the pointer — whichever UPDATE lands last wins regardless of which read the fresher data, so the other save's edit is silently discarded even though the API reports success. This matches the DCathal repro in the #1158 thread (two revisions created in the same second, the stale one becomes draft_revision_id).

Note: the original #1158 report (TipTap editor.view.dispatch bypassing onUpdate) was already fixed by #1119 and is unaffected here — this addresses the separate, still-reproducing save race described in the newest comment on that thread (0.28.1/0.29.0).

Fix: the pointer-flip UPDATE is now a compare-and-set keyed on the draft_revision_id read at the start of the attempt (COALESCE(draft_revision_id, '') = <value read>, NULL-safe across SQLite/D1 and Postgres). If the CAS fails, the losing save retries — re-reading the now-current draft, re-merging its own edit on top, and retrying the create+CAS — instead of silently overwriting. Bounded to 3 attempts. The orphaned revision row from a lost race is harmless; pruneOldRevisions sweeps it up on a later successful save since it's keyed by entry_id, not by pointer.

Known residual, out of scope for this PR: the autosave-only "update existing draft revision in place" path (skipRevision when a draft already exists) still isn't CAS-guarded — two concurrent autosaves (no explicit save involved) can still last-write-wins on that single row's data column. Closing that fully would need a version column on revisions, which felt like more surface than this fix needs; flagging in case a maintainer disagrees.

Closes (comment thread on) #1158

Type of change

  • Bug fix

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes (pre-existing unrelated errors in oauth-state-store.ts / plugins/context.ts confirmed present without this change)
  • pnpm lint passes (oxlint, scoped to changed files)
  • pnpm test passes (targeted: new race test + adjacent content/revision/media-usage integration suites, both sqlite and postgres dialects)
  • pnpm format has been run (skipped locally — CRLF checkout makes format:check unreliable on Windows; will check CI)
  • I have added/updated tests for my changes
  • I have added a changeset (patch, emdash)

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Sonnet 5

Test plan

Added packages/core/tests/integration/database/content-update-save-race.test.ts: creates a published entry, then fires two concurrent handleContentUpdate calls editing different fields, with a RevisionRepository.create spy that gates the first call until the second also arrives — forcing both to read draft_revision_id: null before either writes, regardless of driver timing. Verified the test fails against the pre-fix code (save B's edit silently dropped, matching the report) and passes with the fix (both edits survive).

Two saves to a collection with no draft yet (autosave + explicit save, or
two concurrent explicit saves) both read draft_revision_id: null, then race
an unconditional UPDATE to flip the pointer. Whichever lands last wins
regardless of which read the fresher base data, so the other save's edit is
silently discarded even though the API reports success.

D1 has no multi-statement transactions, so the pointer-flip UPDATE is now a
CAS keyed on the draft_revision_id read at the start of the attempt; a
losing save retries against the fresh pointer (re-merging its edit on top)
instead of clobbering it. Reported as a comment on emdash-cms#1158
(the original TipTap-dispatch cause of emdash-cms#1158 itself was already fixed by
emdash-cms#1119; this addresses the separate save-race still reproducing on
0.28.1/0.29.0).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jul 19, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f501b03

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

This PR includes changesets to release 16 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/sandbox-workerd 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

@emdashbot

emdashbot Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Could not push formatting changes to this fork. The contributor may have "Allow edits by maintainers" disabled.

Please run the formatter locally:

pnpm format

@pkg-pr-new

pkg-pr-new Bot commented Jul 19, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/auth-atproto

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

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

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

emdash

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

create-emdash

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

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

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

@emdash-cms/plugin-cli

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

@emdash-cms/plugin-types

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

@emdash-cms/registry-client

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

@emdash-cms/registry-lexicons

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

@emdash-cms/registry-verification

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

@emdash-cms/sandbox-workerd

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-field-kit

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: f501b03

CI format:check flagged this file after the PR emdash-cms#2130 diff shifted
line wrapping. Reformatted with oxfmt, no logic change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant