Skip to content

fix(core): keep an entry's starting content in Revisions - #4389

Open
danielmlr wants to merge 11 commits into
mainfrom
daniel/duplicate-first-revision
Open

danielmlr wants to merge 11 commits into
mainfrom
daniel/duplicate-first-revision

Conversation

@danielmlr

@danielmlr danielmlr commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes an entry's starting content going missing from Revisions. Creating or duplicating an entry writes no revision. The first autosave then writes one that already holds the edit, and each later autosave replaces it. A post created as published, like one from the WordPress import, loses its starting content for good once an edit is published. This affects new and duplicated entries, translations, and entries created through the API, MCP, plugins or an import.

In a collection with revisions, the first save of an entry that has no revision yet now writes the stored content, including its reference links, as a revision before it writes its draft. A plugin's first save writes it without the links, and the draft it writes has none either. The revision's id is made from the entry, so first saves that overlap write it once. A save whose draft is refused leaves it in place. It's never the draft, so autosave leaves it in place too. Publishing an entry before its first edit writes that same revision with its links and publishes it, so the entry still gets a single revision, and a publish that fails doesn't delete it. A seed publishes it without the links. Every first save and that publish write it before any other revision, so a save that finds a revision already there can skip writing it. If the entry has other revisions, or its stored content changed since, publishing writes a new revision as before. Like every revision, the starting one is dated to when it was saved. Creating, duplicating and importing entries cost nothing extra; the first save or a publish before it pays for the revision, once per entry. Autosave still replaces only the draft, and a save in a collection without revisions still writes no revision. Keeping manual saves when a later autosave runs, the issue's second ask, isn't part of this PR.

Closes #4195

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 code 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

AI assistance disclosure

  • This PR includes AI-generated code or text — model/tool: Claude Opus 5.5

Screenshots / test output

A post created in demos/simple and autosaved twice, with its oldest revision opened. On main, the panel lists one revision, and it already holds the autosaved text.

Before After
Revisions panel of a new post on main after two autosaves: one revision, marked as the current version Revisions panel of the same new post with the fix: the current version and below it an older revision
The oldest revision opened on main: the title already shows the autosaved text Duis aute irure dolor in reprehenderit. The oldest revision opened with the fix: the title shows the created text Lorem ipsum dolor sit amet.

New tests check the oldest revision of a new and of a duplicated post after autosaves and a save, of a new post after plugin edits, and of a post created as published after an edit is published. Others overlap first saves of one entry, from the admin with _rev, from the API without it and from a plugin, including a first save that waits while two more saves finish, and check that the starting content is kept once. One publishes an entry without a transaction, the way it runs on D1, while its first save goes in, and checks that the starting content survives. Others give entries the same custom id in two collections, or the same creation time, and check that each keeps its own starting content. Others change the links, restore that revision, and get back the links the entry started with, or none if it had none, also after a refused first save or a publish refused without a transaction, as on D1. These fail on SQLite and PostgreSQL without the fix. Two more publish an entry restored from Trash and one whose stored content changed after a refused first save, and check that the stored content goes live. Two existing tests counted revisions after a first save, refused or not; they now count the starting revision too. The starting revision now holds the created content, so the revisions end-to-end test no longer saves it again as its oldest version.

Duplicating an entry created the copy without any revision. The first
autosave then wrote a revision that already held the edit, and each
later autosave replaces the revision the draft points to. The copy's
starting content never reached Revisions, so editors could not compare
against it or restore it.

Duplicating now writes a first revision of the copied content in
collections with revisions. It is not staged as the draft, so autosave
leaves it in place.
@changeset-bot

changeset-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c235e26

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

@github-actions github-actions Bot added review/needs-review No maintainer or bot review yet area/core size/M labels Oct 24, 2026
@cloudflare-workers-and-pages

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

Copy link
Copy Markdown

🚀 Deploying Preview to Cloudflare 🚀

Preview URL: https://daniel-duplicate-first-revision.try.emdashcms.com, https://daniel-duplicate-first-revision-emdash-playground.emdash-cms.workers.dev (commit c235e26)

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://89b18367.try.emdashcms.com, https://89b18367-emdash-playground.emdash-cms.workers.dev c235e26 2026-10-26T01:23:16.523Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://5aaad439.try.emdashcms.com, https://5aaad439-emdash-playground.emdash-cms.workers.dev 6423174 2026-10-25T19:47:28.958Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://3f7776e2.try.emdashcms.com, https://3f7776e2-emdash-playground.emdash-cms.workers.dev d6372cd 2026-10-25T02:12:21.829Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://004194a1.try.emdashcms.com, https://004194a1-emdash-playground.emdash-cms.workers.dev b970739 2026-10-24T21:31:16.821Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://532d4189.try.emdashcms.com, https://532d4189-emdash-playground.emdash-cms.workers.dev 73744ed 2026-10-24T20:06:59.965Z 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 approach is sound: writing an extra, non-draft revision of the copied data inside the duplicate transaction preserves the starting content without changing autosave semantics or needing a migration. The reviewers agree on this and on the scope. Explicit-save preservation across later autosaves is out of scope. They disagree on whether the change is complete. One reviewer found nothing to fix. The other found that the baseline revision omits reference-field selections, so it cannot fully restore the copy's starting content. That finding is kept as needs_fixing. The first reviewer did not mention reference fields, so the two reviews don't conflict on this point. The first reviewer also noted, as a non-blocking side point, that newly created (non-duplicated) entries in revision-enabled collections have the same first-autosave gap, which could be a follow-up.


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

Comment thread packages/core/src/api/handlers/content.ts Outdated
@pkg-pr-new

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

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/auth-atproto

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

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

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

emdash

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

create-emdash

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

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

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

@emdash-cms/plugin-cli

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

@emdash-cms/plugin-test

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

@emdash-cms/plugin-types

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

@emdash-cms/registry-client

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

@emdash-cms/registry-lexicons

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

@emdash-cms/registry-loader

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

@emdash-cms/registry-moderation

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

@emdash-cms/registry-verification

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

upgrade-emdash

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

@emdash-cms/sandbox-workerd

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-ai-search

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-field-kit

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: c235e26

The first revision of a duplicated entry held only the copied fields.
Reference selections live in the link table, and restoring a revision
brings back only the selection it carries, so restoring that first
revision left whatever links the copy had since been published with.

The first revision is now written after the links are copied and
carries the copy's full selection, the way a published revision does.
@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 24, 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 approach remains sound: preserving a separate initial revision fits the existing revision lifecycle without changing autosave behavior.

The follow-up addresses the earlier reference-restoration finding. The snapshot is now written after reference links are copied and includes the complete reference selection, including empty field selections. The new regression test changes and publishes the copy’s links, restores its first revision, and verifies the copied links return.

I checked the follow-up diff against the selection and restore helpers and the database-backed test setup. No new issues found. Review was static; I did not run tests or builds. Preserving explicit-save checkpoints across later autosaves remains outside this PR’s stated scope.


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

Writing a revision when an entry is duplicated covered only duplicates.
New entries, translations, imported posts and existing entries that were
never saved since they were created lost their starting content the
same way, and a duplicate published before its first edit listed the
copied content twice.

The first save of an entry with no revision yet now writes the stored
content as a revision before staging the draft, from the runtime's save
path (with the entry's reference selection) and from the repository's
draft-aware update that plugin writes use. Duplicating no longer writes
one. The snapshot is never the draft, so autosave leaves it in place.
Creating or importing an entry costs nothing extra, and publishing an
unedited entry still records a single revision.
…t save

The doc comment said nothing holds the starting content once a draft
takes over. The columns still hold it then, until publishing a later
draft overwrites them. The changeset's last sentence also gets the
right tense.
@danielmlr danielmlr changed the title fix(core): show a duplicated entry's starting content in Revisions fix(core): keep an entry's starting content in Revisions Oct 25, 2026
@github-actions github-actions Bot removed the size/M label Oct 25, 2026
@cloudflare-workers-and-pages

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

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
docs d6372cd Oct 25 2026, 02:11 AM

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

Preserving the starting content on the first edit is a sound approach: it covers more creation paths without adding work to creation or import. The earlier reference-restoration finding remains addressed, including empty selections.

I reviewed the follow-up’s lazy snapshot creation, draft staging and cleanup, reference restoration, regression tests, E2E setup, and replacement changeset. One new concurrency issue remains: a losing first save can delete the starting revision that a successful concurrent save relied on.

Review was static; I did not run tests or builds. Preserving explicit-save checkpoints across later autosaves remains outside the stated scope.


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

Comment thread packages/core/src/emdash-runtime.ts Outdated
A save wrote the starting revision before its draft and deleted it again
when its draft was refused. An overlapping first save that had already
seen it then skipped its own, so the starting content was lost.

A save now reserves the starting revision's id before writing its draft
and writes the revision only once the draft is staged. A save that loses
the draft to an overlapping first save keeps the reservation into its
retry and writes it there unless a revision older than the winning draft
exists.
…v loses

A save that sends _rev, as the admin and MCP do, ends with a conflict
instead of retrying when its draft is refused. Its reserved starting
revision was dropped with it. When the save that won had skipped the
starting revision because it saw the refused save's draft, nobody wrote
it, and publishing later overwrote the starting content.

Before reporting the conflict, the save now re-reads the entry and runs
the same check as a retry: it writes the reserved starting revision unless
a revision older than the draft that went in already exists.

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

Preserving the starting content lazily remains the right approach, and moving baseline writes out of request-local cleanup addresses the previously reported two-save deletion race. The reference-restoration finding remains addressed.

I reviewed the follow-up’s reservation logic, draft staging and cleanup, revision ordering, and new concurrency/reference tests. One concurrency gap remains: an older edited revision can be mistaken for the starting snapshot, leaving the baseline missing after three overlapping saves.

Review was static; I did not run tests or builds. Preserving explicit-save checkpoints across later autosaves remains outside the stated scope.


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

Comment on lines +1468 to +1476
const older = await this.db
.selectFrom("revisions")
.select("id")
.where("collection", "=", type)
.where("entry_id", "=", existing.id)
.where("id", "<", existing.draftRevisionId)
.limit(1)
.executeTakeFirst();
return older ? null : reserved;

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.

[needs fixing] An older revision is not necessarily the starting snapshot. This check can still discard the only baseline reservation:

  1. First save A reserves a starting ID and inserts its edited draft, then pauses before staging it.
  2. Save B sees A’s revision in findLatest(), skips reserving a baseline, and successfully stages its own edited draft.
  3. Explicit save C stages another draft before A resumes. B’s edited revision remains in history because C is not an autosave.
  4. A loses its CAS and deletes its unstaged draft. On its retry—or the fresh check before returning an _rev conflict—this query finds B’s revision older than C’s draft and returns null.

None of these saves writes the starting content. Publishing then overwrites the original columns, reproducing the loss this PR fixes. The new tests overlap only one other save, so they do not cover this sequence. Use evidence that identifies an actual baseline rather than any older revision, and add a regression test that interleaves B and C before A resumes, then publishes and checks the original content survives.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in d17afcd. The starting revision now has an id made from the entry, so overlapping first saves write the same row once. Every first save writes it before its own draft, unless the entry already has a revision. A save whose draft is refused deletes only that draft. So when a save finds any revision, the starting one is already written, and the save can skip writing it. This replaces the reserved id from 6f0700d and 6423174, which a save wrote only after its draft. Since 4c71508, publishing an entry that has no revision yet publishes the starting revision itself, and a failed publish doesn't delete it. So a first save that finds it while a publish runs can skip writing it too.

A new test in starting-revision.test.ts runs the sequence above: the first save writes its draft and waits, two more saves finish, then it resumes and the entry is published. It runs for the admin with _rev, the API without it and a plugin, on SQLite and PostgreSQL, and checks that the starting content is kept once, as the oldest revision.

~ 🤖 Claude Code (Claude Opus 5.5)

@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 25, 2026
A first save whose draft was refused decided from "an older revision
exists" whether the starting revision was already written. With a third
save in between, that older revision could be another save's draft, so
no save wrote the starting revision.

Every first save now writes the starting revision before its own draft,
under an id derived from the entry, and only if it isn't there yet. A
save never removes it, even when its draft is refused, so any revision
another save can see comes after the starting one. The reserved id, the
retry carry-over and the write before a _rev conflict are gone.
… save

Publishing an entry with no revision wrote a copy of its stored content and
deleted it again when the publish lost. Without a transaction, as on D1, a
first save running at the same time could see that copy, take it for the
starting revision and skip writing one. Once the publish deleted its copy,
nothing kept the starting content.

Publishing an entry with no live or draft revision now publishes the
starting revision under its fixed id, written if it isn't there yet, and
keeps it when the publish fails. It reuses that revision only while it's the
entry's newest one and still holds the stored content, so the live revision
never differs from the columns. Otherwise publish writes a new revision as
before.
Publishing an entry that was never saved wrote its starting revision
without the reference links, since the repository can't read them. On
D1, where a publish runs without a transaction, a refused publish left
that revision behind, and the first save then found it and wrote none
of its own. Restoring it later kept the current links instead of the
ones the entry started with.

The publish handler now writes the starting revision with the live
links before the repository publishes, as a first save does. The
runtime and the handler read the links through one helper.
The comment on keepStartingRevision said a save that finds any revision
can rely on the starting one being among them. Pruning keeps an entry's
newest 50 revisions, so an entry with more can lose it. What the save
relies on is only that the starting revision was written, so it can skip
writing it.
@github-actions github-actions Bot added size/XL and removed size/L labels Oct 26, 2026
@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 26, 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.

Preserving the starting content lazily remains the right approach. Writing an entry-derived baseline before staging any draft addresses the earlier overlapping-save gap without making baseline retention depend on request-local cleanup.

I reviewed the follow-up’s baseline creation and ordering, publish reuse and failure cleanup, reference snapshots, and new regression tests. The earlier findings are addressed, including the three-save interleaving; reference restoration remains covered. I found no new issues to fix in the changes since the last review.

Review was static; I did not run tests or builds. Preserving explicit-save checkpoints across later autosaves remains outside the stated scope.


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.

Revision history loses the initial state of duplicated entries after autosave

1 participant