Skip to content

fix(cloudflare/workers): guard Durable Object class move across scripts#800

Closed
danielgangl wants to merge 2 commits into
alchemy-run:mainfrom
danielgangl:fix/do-cross-script-move-delete-class
Closed

fix(cloudflare/workers): guard Durable Object class move across scripts#800
danielgangl wants to merge 2 commits into
alchemy-run:mainfrom
danielgangl:fix/do-cross-script-move-delete-class

Conversation

@danielgangl

@danielgangl danielgangl commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Fixes #799.

Problem

When a Durable Object class moves from one Worker to another and the former host keeps a cross-script reference (Cloudflare.DurableObject(..., { scriptName })), the former host's next deploy fails. The class disappears from the worker's locally-owned DO bindings (getDurableObjectBindings excludes cross-script bindings from migration ownership), so it lands in deletedClasses — while the same script upload still carries a durable_object_namespace binding referencing that class_name. Cloudflare rejects the combination:

DurableObjectClassNotFound: Cannot apply --delete-class migration to class 'MyDOClass' without also removing the binding that references it.

Cloudflare requires binding removal and class deletion in two separate uploads, and matches bindings by class_name alone — a script_name pointing at the new host does not exempt the binding. There is no single-upload sequence it accepts.

Why this isn't silently auto-fixed

That rejection is effectively a safety interlock. Durable Object storage does not move with the class — the new host gets a fresh, empty namespace, and the old namespace on the former host still holds all its data. A delete-class migration permanently destroys it. Silently splitting the upload would turn an innocent-looking topology refactor into irreversible data loss.

Change

  • Default — fail loudly, before any upload. When the computed deletedClasses intersects the class names of current cross-script DO bindings, the deploy now fails with a new DurableObjectClassMoved error that states (a) the class moved to another script, (b) the data does not transfer and the delete is irreversible, and (c) how to proceed. This is strictly better than the opaque raw Cloudflare error users hit today.

  • Opt-in — two-phase upload. Setting the new deleteMovedDurableObjectClasses: true prop on the Worker confirms the deletion. The provider then splits the deploy:

    1. Upload feat: remove most HKTs and simplify types #1 — the same code/modules with the conflicting cross-script DO binding(s) omitted, carrying the deleted_classes migration. Cloudflare accepts it (the binding is gone) and drops the old namespace and its data.
    2. Upload chore: add README #2 — the full binding set (cross-script bindings included), no migration. Upload feat: remove most HKTs and simplify types #1 already advanced Cloudflare's migration tag to the bumped value and the metadata tags carry the same alchemy:migration-tag: value, so the recorded tag stays consistent.

    Non-DO bindings and local DO bindings are present in both uploads; the two-phase path only triggers when the conflict set is non-empty — every other deploy stays single-upload. A session.note records when the split activates.

    Note: between the two uploads there is a brief window where the moved cross-script binding is absent — in-flight requests that use it will fail during that window. This is inherent to Cloudflare's two-upload requirement; a manual two-phase deploy has the same window. It is documented on the deleteMovedDurableObjectClasses prop.

Test coverage

The conflict detection is extracted into getConflictingCrossScriptDoBindings (exported for unit testing, matching the existing normalizeStateDomains seam) and covered by unit tests, alongside tests asserting the DurableObjectClassMoved guidance. The live local→cross-script move is only exercisable by the integration suite against real Cloudflare, so no new live test was invented here; bun tsgo typecheck, oxlint, oxfmt, and packages/alchemy WorkerProvider.test.ts (14 passing) all run offline and are green.

🤖 Generated with Claude Code

danielgangl and others added 2 commits July 10, 2026 17:09
…run#799)

When a Durable Object class moves from one Worker to another and the former
host keeps a cross-script `DurableObject(..., { scriptName })` reference, the
class lands in `deletedClasses` while its `class_name` is still bound. A single
script upload then ships the delete-class migration alongside the binding that
references the class, which Cloudflare rejects ("Cannot apply --delete-class
migration to class 'X' without also removing the binding that references it") —
its validator matches DO bindings by `class_name` alone, ignoring `script_name`.

That rejection is a safety interlock: Durable Object storage does not move with
the class, so applying the delete migration irreversibly destroys the old
namespace and its data on the former host. So:

- By default the deploy now fails before any upload with `DurableObjectClassMoved`,
  an actionable error explaining the move, the irreversible data loss, and how to
  proceed — strictly better than the opaque Cloudflare error.
- Opt in via the new `deleteMovedDurableObjectClasses` Worker prop to confirm the
  deletion. The provider then performs the move as two uploads (Cloudflare rejects
  doing both at once): upload alchemy-run#1 applies the delete-class migration with the
  conflicting cross-script binding(s) omitted, upload alchemy-run#2 re-attaches the full
  binding set with no migration, keeping the migration/`alchemy:migration-tag`
  bookkeeping consistent.

Extracts `getConflictingCrossScriptDoBindings` as a unit-testable seam and adds
unit coverage for the conflict detection and the error guidance. The live move
scenario is covered only by integration tests against real Cloudflare.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Name the destination script(s) in DurableObjectClassMoved: payload is now
  `movedClasses: { className, scriptName }[]` and the message says which
  worker hosts each moved class.
- Document the brief window between the two uploads where the moved binding
  is absent (in-flight requests using it fail) in the
  `deleteMovedDurableObjectClasses` JSDoc.
- Drop the `unknown`-typed Set in favor of a plain `.includes` over the
  (1-2 element) conflict list.
- Trim the conflict-detection block comment to the Cloudflare-rejection
  facts and safety-interlock rationale.
- Remove the zero-signal `_tag` assertion test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sam-goodwin

Copy link
Copy Markdown
Contributor

transfer would be better, right? Put together that here #803

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cloudflare: moving a DO class to another Worker fails — deleted_classes migration ships in the same upload as the new cross-script binding

2 participants