Skip to content

feat(core): lock a content entry while someone is editing it - #2919

Draft
danielmlr wants to merge 3 commits into
emdash-cms:mainfrom
danielmlr:feat/entry-edit-lock
Draft

feat(core): lock a content entry while someone is editing it#2919
danielmlr wants to merge 3 commits into
emdash-cms:mainfrom
danielmlr:feat/entry-edit-lock

Conversation

@danielmlr

Copy link
Copy Markdown
Contributor

What does this PR do?

Prevents two editors from colliding on one entry, rather than reporting the collision once both have done the work. #2126 turns a stale save into a 409, but that arrives after the typing is done, and the only resolution left is to discard one side.

Opening an entry in the admin now takes a lease on it, one row per (collection, entry_id), claimed in a single insert-or-steal statement so two editors racing on one entry cannot both win. The second editor is told who holds it and chooses read-only or take-over; the holder learns of a take-over on their next heartbeat or save, and their editor turns read-only with a banner naming who has it now. PUT, DELETE, /publish, /unpublish, /schedule and /discard-draft refuse a write against someone else's live lease with 409 ENTRY_LOCKED and name the holder; overrideLock opts out, and the CLI exposes --override-lock. The lease runs seven minutes; the admin re-asserts it every two minutes and on every save, and hands it back when the editor is left or the tab closes. Nothing is added to the logged-out path. Locking is on for every collection and switches off per collection.

Design and defaults are discussion #2819; ascorbic approved the split it proposes, _rev on the MCP write tools first and locking on top, in Discord.

Four things carry over from that thread:

Type of change

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

Checklist

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 5 (review pass: Claude Fable 5.1)

Screenshots / test output

Content editor with a modal dialog reading "This entry is open somewhere else", "Ada Lovelace is editing this entry. Open it read-only, or take over; they will be told the entry moved on.", above buttons Open read-only and Take over

The same dialog in the Arabic admin, with the sidebar, header and dialog buttons mirrored to right-to-left

Content editor in read-only mode with a banner reading "Read-only", "Ada Lovelace is editing this entry. Nothing you change here will be saved.", above a Take over button; the fields below are disabled

The same read-only banner in the Arabic admin, mirrored to right-to-left with the banner text still reading left-to-right

The refusal path is covered at the repository, at the handlers and at every refusing route, against real databases; the repository suite runs on Postgres too, in CI, where the integration job has one. The two-editor journey has no end-to-end test: the e2e fixture has a single identity, dev-bypass is hard-wired to dev@emdash.local, and a second live session needs the invite and virtual-authenticator flow. So the end-to-end tests cover the lease mechanics with that one identity, and the 409-to-banner seam is covered at the hook level.

Two people can open the same entry today and neither is told the other is
there. emdash-cms#2126 made a stale save return 409 instead of silently replacing a
newer draft, but that reports the collision after both people have done the
work, and the only resolution left is to discard one side.

An entry now takes a lease when it is opened, keyed on (collection, entry_id)
in a new system table. The claim is a single INSERT ... ON CONFLICT DO UPDATE
... WHERE statement rather than a read-then-write, so two editors racing on
one entry cannot both win. Timestamps come from the database clock in the
format the media-usage lease already uses, so leases stay comparable across
isolates whose wall clocks drift.

The lease survives a save. Released on save it would be gone two seconds after
the next pause, because autosave debounces at AUTOSAVE_DELAY, so the refresh
rides on the autosave PUT the admin already sends: one statement, no extra
request, and nothing added to the logged-out path.

A write against someone else's live lease is refused with 409 ENTRY_LOCKED
unless the caller opts out. The refusal carries the holder, which is how the
previous holder learns their entry was taken: their next save is refused and
the editor says so rather than failing quietly.

ENTRY_LOCKED is a new code rather than the existing WORK_LEASE_ACTIVE because
mediaUsageWorkRetryConflictSchema pins that code's details to
{ leaseExpiresAt } and is wired into the OpenAPI document, so reusing it would
make the published spec claim one code carries two incompatible shapes.

Locking is on for every collection and switches off with edit_locking on
_emdash_collections.

The MCP content tools do not honour the lock yet; that lands separately.
The lock upsert failed on Postgres: inside ON CONFLICT DO UPDATE the stored
row and `excluded` both carry `user_id`, so a bare column name is ambiguous
there. Every acquire returned ENTRY_LOCK_ERROR and the admin fell back to
editing unlocked. The conflict branch now qualifies its references.

Nothing renewed an open tab's lease, so a holder who paused for seven
minutes lost the entry without knowing, and the next colleague acquired it
without a take-over prompt. The admin now re-asserts the lease every two
minutes, re-checks it when the tab becomes visible again, and reports a
lost lease itself; a reader is moved to editing once the holder leaves. A
take-over therefore reaches the previous holder within one heartbeat.

The lease was keyed on the account, so closing one of two tabs of the same
user dropped the lease the other still relied on. Each mounted editor now
sends a per-tab token; the row keeps the latest one and a release matches
on it. Closing the tab releases through a keepalive request, and a grant
that lands after the editor moved on is handed back.

`/schedule` on POST and DELETE now refuses a write against someone else's
lease like the other write routes, with the same opt-out on body and query
string, carried through the client and the CLI.

The refusal message names the holder, the OpenAPI document describes the
409 body on every refusing route, and the CLI flag is declared as
`override-lock` so `--help` prints what the docs say. A refused autosave is
resent once the entry is taken back. `editLocking` only enters the seed
capture fingerprint when it is off, so existing collections keep theirs.

Tests cover two collections sharing an entry id, the token, the heartbeat,
the visibility re-check, the keepalive release and the in-flight hand-back.
The note that the edit lock does not refuse MCP writes sat under
content_update alone, so the sections for content_delete, content_publish,
content_unpublish, content_schedule, content_unschedule and
content_discard_draft read as if they honoured it. None does: no content
write tool passes through claimEntryLockForWrite. The sentence now opens
the Content Tools section and links the lock's reference.

Also drops the comment on entryLockConflictSchema that explained why it is
not registered as a component; the reason it gave does not hold, and the
sibling media-usage conflict schema is inlined without one.
@changeset-bot

changeset-bot Bot commented Sep 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ec78032

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

This PR includes changesets to release 17 packages
Name Type
emdash Minor
@emdash-cms/admin Minor
@emdash-cms/cloudflare Minor
@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/auth Minor
@emdash-cms/blocks Minor
create-emdash Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/release-service 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 commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 3,518 lines across 58 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@pkg-pr-new

pkg-pr-new Bot commented Sep 5, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/auth-atproto

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

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

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

emdash

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

create-emdash

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

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

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

@emdash-cms/plugin-cli

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

@emdash-cms/plugin-types

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

@emdash-cms/registry-client

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

@emdash-cms/registry-lexicons

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

@emdash-cms/registry-moderation

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

@emdash-cms/registry-verification

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

@emdash-cms/sandbox-workerd

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-field-kit

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: ec78032

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

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