Repository navigation
Conversation
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 detectedLatest commit: c235e26 The changes in this PR will be included in the next version bump. This PR includes changesets to release 12 packages
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 |
There was a problem hiding this comment.
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.
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-test
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/registry-loader
@emdash-cms/registry-moderation
@emdash-cms/registry-verification
upgrade-emdash
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-ai-search
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
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.
There was a problem hiding this comment.
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.
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
docs | d6372cd | Oct 25 2026, 02:11 AM |
There was a problem hiding this comment.
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.
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.
There was a problem hiding this comment.
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.
| 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; |
There was a problem hiding this comment.
[needs fixing] An older revision is not necessarily the starting snapshot. This check can still discard the only baseline reservation:
- First save A reserves a starting ID and inserts its edited draft, then pauses before staging it.
- Save B sees A’s revision in
findLatest(), skips reserving a baseline, and successfully stages its own edited draft. - Explicit save C stages another draft before A resumes. B’s edited revision remains in history because C is not an autosave.
- A loses its CAS and deletes its unstaged draft. On its retry—or the fresh check before returning an
_revconflict—this query finds B’s revision older than C’s draft and returnsnull.
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.
There was a problem hiding this comment.
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)
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.
There was a problem hiding this comment.
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.
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
Your connection to this change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runmessages.pochanges except in translation PRs — a workflow extracts catalogs on merge tomain. (n/a: no admin UI code change)AI assistance disclosure
Screenshots / test output
A post created in
demos/simpleand autosaved twice, with its oldest revision opened. On main, the panel lists one revision, and it already holds the autosaved text.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.