Skip to content

feat(cloudflare/workers): declare Durable Object class transfers between workers#803

Open
sam-goodwin wants to merge 5 commits into
mainfrom
claude/issue-799-transferred-classes-11ff1e
Open

feat(cloudflare/workers): declare Durable Object class transfers between workers#803
sam-goodwin wants to merge 5 commits into
mainfrom
claude/issue-799-transferred-classes-11ff1e

Conversation

@sam-goodwin

@sam-goodwin sam-goodwin commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Moving a Durable Object class to another Worker is now supported via Cloudflare's data-preserving transferred_classes migration. Moves are always declared — a class deleted on one worker and created on another is ambiguous between "move the data" and "delete it, start fresh" (the same reason Terraform requires moved blocks), so Alchemy never guesses. The new host names where the class came from:

// BEFORE: worker-b hosts the class
const b = yield* Cloudflare.Worker("WorkerB", {
  main: "./src/worker-b.ts", // exports MyDOClass
  bindings: { MyDO: Cloudflare.DurableObject("MyDO", { className: "MyDOClass" }) },
});

// AFTER: worker-a hosts it and declares where it came from;
// worker-b keeps a cross-script reference.
const a = yield* Cloudflare.Worker("WorkerA", {
  main: "./src/worker-a.ts", // now exports MyDOClass
  bindings: {
    MyDO: Cloudflare.DurableObject("MyDO", {
      className: "MyDOClass",
      transferredFrom: "WorkerB", // logical id (same stack) or script name
    }),
  },
});
const b = yield* Cloudflare.Worker("WorkerB", {
  main: "./src/worker-b.ts",
  bindings: {
    MyDO: Cloudflare.DurableObject("MyDO", {
      className: "MyDOClass",
      scriptName: a.workerName,
    }),
  },
});

transferredFrom semantics

  • Each entry names a former host by its Worker logical id in the same stack + stage (resolved via the alchemy:id/alchemy:stack/alchemy:stage ownership tags among scripts observed to currently host the class) or by its physical script name (required for cross-stack moves).

  • Besides strings, entries accept the Worker class or resource itself, or a thunk for forward references and import cycles:

    Cloudflare.DurableObject("Counter", { transferredFrom: b })            // resource
    Cloudflare.DurableObject<C>()("Counter", { transferredFrom: () => WorkerA }) // class via thunk

    References are normalized at plan time to the worker's logical id — never its Output — since consuming the former host's Output would add a new-host→former-host dependency edge and invert the deploy order the transfer requires. A same-stack workerName Output is likewise reduced to its source resource's logical id via provenance.

  • It's a host history: transferredFrom: ["WorkerB", "WorkerA"] transfers from whichever listed host currently holds the namespace, so stages that lag behind or skipped an intermediate release still converge. When none does (fresh stage, transfer already completed) the declaration is inert — safe to keep in code indefinitely.

  • Multiple matching sources (e.g. an orphaned script left by a name change) fail with the typed AmbiguousDurableObjectTransfer rather than guessing whose data to move.

Safety interlocks

  • An undeclared move — the former host re-binds a class it still hosts as a cross-script reference — fails before any upload with the typed DurableObjectTransferRequired, naming the exact declaration to add. This replaces Cloudflare's opaque deleted_classes-vs-binding rejection from the issue; data is never silently destroyed or forked.
  • The former host's deploy validates deleted_classes against observed namespace ownership (with a short bounded re-observation for listing lag): a class that transferred away is skipped, so the former host converges on its own.
  • Transfer-destination classes are excluded from the precreate placeholder (Cloudflare forbids pre-creating a transfer's destination class).
  • Pure moves (former host drops the DO entirely, no cross-script reference) are two deploys — add to the new host with transferredFrom and deploy, then remove from the former host — since nothing orders a single deploy's transfer before the former host's delete. Documented on the resource.

Verified live in DurableObjectNamespace.test.ts: a move declared by logical id preserves data across the transfer and is inert on redeploy; a move declared by physical script name via a thunk (the cross-stack shape) transfers as well; the documented two-deploy pure move driven by a resource reference transfers and the former host's DO removal converges without a delete; an undeclared move fails with DurableObjectTransferRequired.

Closes #799

🤖 Generated with Claude Code

…erredFrom

Moving a Durable Object class from one Worker to another previously failed:
the former host emitted a deleted_classes migration in the same upload as
the new cross-script binding, which Cloudflare rejects — and applying the
delete would have destroyed the namespace's data anyway.

- add `transferredFrom` to DurableObject declarations (props form + class
  form); the new host's deploy drives Cloudflare's data-preserving
  transferred_classes migration
- the former host observes namespace ownership before emitting
  deleted_classes: a class transferred away is skipped, a class still
  hosted locally but re-bound cross-script fails before any upload with
  the typed DurableObjectTransferRequired error
- exclude transfer-marked classes from the precreate placeholder (Cloudflare
  forbids pre-creating the destination class of a transfer)

Closes #799

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alchemy-version-bot

alchemy-version-bot Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Install the packages built from this commit:

alchemy

bun add alchemy@https://pkg.ing/alchemy/21b9a8c

@alchemy.run/better-auth

bun add @alchemy.run/better-auth@https://pkg.ing/@alchemy.run/better-auth/21b9a8c

@alchemy.run/pr-package

bun add @alchemy.run/pr-package@https://pkg.ing/@alchemy.run/pr-package/21b9a8c

Host moves are now inferred like class renames — no configuration needed
when both Workers deploy from the same stack:

- a cross-script DO declaration whose scriptName carries resource
  provenance (worker.workerName, Counter.from(Worker)) binds a literal
  transfer hint {logicalId, className, fromWorkerId} onto the target at
  plan time (no Outputs, so no dependency edges)
- the target's deploy resolves the hint against observed cloud state —
  the source must currently host the namespace and carry the matching
  alchemy:stack/stage/id/do tags — and ships transferred_classes; the
  tag match is what distinguishes a host move from the documented
  two-hosts-two-namespaces pattern
- hint-confirmed transfer classes are excluded from the precreate
  placeholder (Cloudflare forbids pre-creating a transfer destination)
- transferredFrom remains only as the explicit escape hatch for
  cross-stack moves, which the plan fundamentally cannot see; ambiguous
  inference (multiple qualifying sources) fails with the typed
  AmbiguousDurableObjectTransfer error instead of guessing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sam-goodwin sam-goodwin changed the title feat(cloudflare/workers): move Durable Object classes between workers with transferredFrom feat(cloudflare/workers): infer Durable Object class transfers between workers Jul 11, 2026
…t history

Remove hint-based inference — a class deleted on one worker and created
on another is ambiguous between "move the data" and "delete + fresh
namespace", so moves are always declared, never guessed:

- transferredFrom now accepts a host *history* (string | string[]);
  entries name the former host by Worker logical id (same stack+stage,
  resolved via alchemy:id/stack/stage ownership tags among scripts
  observed to host the class) or by physical script name (cross-stack)
- the namespace transfers from whichever listed host currently holds it,
  so lagging stages and skipped intermediate releases converge; when none
  does (fresh stage, transfer complete) the declaration is inert
- an undeclared move still fails before any upload with
  DurableObjectTransferRequired naming the exact declaration to add;
  multiple matching sources fail with AmbiguousDurableObjectTransfer

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sam-goodwin sam-goodwin changed the title feat(cloudflare/workers): infer Durable Object class transfers between workers feat(cloudflare/workers): declare Durable Object class transfers between workers Jul 11, 2026
sam-goodwin and others added 2 commits July 10, 2026 22:36
…ker guide

Adds a 'Move the Durable Object to a new host' walkthrough to the
cross-worker Durable Object guide: declaring transferredFrom on the class,
swapping host/consumer roles, what the deploy does (transferred_classes,
data intact, inert afterwards), the DurableObjectTransferRequired
interlock, host history for chained moves, and the pure-move/cross-stack
rules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
transferredFrom entries can now be the Worker class or resource itself, or
a thunk for forward references and import cycles:

  Cloudflare.DurableObject("Counter", { transferredFrom: b })        // resource
  DurableObject<C>()("Counter", { transferredFrom: () => WorkerA })  // class via thunk

References are normalized at plan time to the worker's logical id — never
its Output — because consuming the former host's Output would add a
new-host->former-host dependency edge and invert the deploy order the
transfer requires. A same-stack workerName Output is likewise reduced to
its source resource's logical id via provenance; only opaque Outputs
(cross-stack stackRefs) pass through as values. Platform classes now
expose a static LogicalId to make the class form extractable.

Adds a live test for the documented two-deploy pure move, driven by a
resource reference, and covers the thunk form in the physical-name test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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

1 participant