Skip to content

Support data-preserving cross-script Durable Object moves via transferred_classes (follow-up to #799) #802

Description

@danielgangl

Follow-up to #799 / #800 (those cover the data-destroying variant of a cross-script Durable Object move). This issue asks: can alchemy support the data-preserving move via Cloudflare's transferred_classes Transfer migration — and sketches a concrete design.

Line references below are against 2.0.0-beta.61.

Verdict up front: Yes, with caveats — cleanly buildable, small surface. The wire plumbing already exists end to end; what's missing is (a) an authoring prop, (b) ~20 lines of migration-computation in WorkerProvider.ts, and (c) it is coupled to the #799 source-side fix, which it depends on. Estimated scope: 2 files, 1 new prop, ~30–50 LOC.


1. Cloudflare semantics (authoritative)

From the Durable Objects migrations docs and its mdx source:

  • Shape: transferred_classes: [{ from, from_script, to }]from = source class name, from_script = source Worker script name (no account field → same-account only, inferred), to = destination class name exported by the deploying Worker.
  • Which upload carries it: the destination (receiving) Worker's upload. The source script is only named; it deploys nothing for the transfer.
  • Destination class must be exported but must NOT pre-exist as a namespace: "The destination class … must be exported by the deployed Worker. You should not create the destination Durable Object class before running a Rename or Transfer migration." → In alchemy terms: the transfer must ride the first deploy that hosts the class on the receiving worker; you cannot transfer into a class whose namespace alchemy already created via new_sqlite_classes.
  • Source at transfer time: the source class/namespace must still exist and be deployed (the migration reads from on from_script). Docs don't spell out the source's post-state, but the mechanic is a move: the namespace leaves the source and lands on the destination.
  • Bindings auto-forward: "After a migration, any existing bindings to the original Durable Object class (for example, from other Workers) will automatically forward to the updated destination class." → The source's binding keeps working even before the source's own cleanup deploy. This is what lets the source later hold a cross-script binding to the transferred class.
  • Source needs a follow-up deploy anyway: to drop the now-orphaned local class export and convert its binding to cross-script (script_name → new host). Critically that deploy must NOT emit deleted_classes for the moved class (the namespace is gone/moved, and — per Cloudflare: moving a DO class to another Worker fails — deleted_classes migration ships in the same upload as the new cross-script binding #799 — a delete + cross-script-binding-to-same-class in one upload is rejected by Cloudflare's class_name-only validator).
  • Deploy order: destination (transfer) first, source cleanup second. Transfer needs the source still hosting the class; after transfer the namespace is on the destination, then the source redeploys reference-only.
  • Tags: "Migration tags are treated like unique names… used to determine which migrations have already been applied. Each migration can only be applied once per environment." → natural idempotency: a tag that already advanced past the transfer won't re-run it.
  • SQLite / KV: no documented restriction on transferring either backend; transfer moves the namespace as-is (it does not change the storage class — and note "you cannot enable SQLite on an existing deployed class," so a transfer of a KV-backed namespace stays KV-backed). Rename+transfer combined: not supported as one op — they're distinct migration kinds. You can transfer to a different to name (that's a move+rename in one, which the from/to fields explicitly allow), but you cannot also renamed_classes the same class in the same step.
  • Community evidence it works: workers-sdk #2305 — a user reported wrangler warned "Unexpected fields found in migrations field: 'transferred_classes'" yet the transfer itself succeeded and data was preserved. So the API path is real and reliable; the historical bug was cosmetic wrangler validation, not the transfer. No open workers-sdk issue indicates transferred_classes data-loss.

2. Fit with alchemy's model

The wire path already exists. node_modules/@distilled.cloud/cloudflare/src/services/workers.ts defines TransferredClass ({ from, fromScript, to } → encoded { from, from_script, to }, line 1557–1570) and SingleStepMigrationParam.transferredClasses (line 2322–2325, 2344–2356) — the exact flat migration shape WorkerProvider.ts already sends as metadata.migrations. And WorkerProvider.ts:1488 already carries a placeholder transferredClasses: [] as { from: string; to: string }[] in its migrations object (it just needs a fromScript field and to be populated). So nothing new is needed in the SDK or the upload metadata.

Proposed authoring surface

Add an optional prop to DurableObjectProps (src/Cloudflare/Workers/DurableObject.ts:111), on the receiving/new-host side, matching the existing scriptName idiom:

export interface DurableObjectProps {
  className?: string;
  scriptName?: Input<string> | undefined;
  /** Preserve data when this class was previously hosted by another Worker.
   *  Emits a Cloudflare Transfer migration on the FIRST deploy that hosts
   *  the class here, instead of creating a fresh (empty) namespace. */
  transferFrom?: {
    scriptName: Input<string>;   // from_script — the former host
    className?: Input<string>;   // from — defaults to this binding's className
  } | undefined;
}

The receiving worker already has the source script name available via Output.stackRef / worker.workerName (the same edge #799 uses), so transferFrom.scriptName is just that Output. This prop flows through the DO binding's data into getDurableObjectBindings reach.

Provider changes (WorkerProvider.ts, ~line 1411–1449, the "Compute new and renamed classes" loop)

Today: a currentDoBinding whose logicalId has no prior alchemy:do: tag → pushed to newSqliteClasses (fresh empty namespace = data loss). Change:

if (!previousClassName) {
  if (binding.transferFrom) {                       // NEW branch
    transferredClasses.push({
      from: binding.transferFrom.className ?? binding.className,
      fromScript: binding.transferFrom.scriptName,
      to: binding.className,
    });
  } else {
    newSqliteClasses.push(binding.className);
  }
} else if (previousClassName !== binding.className) {
  renamedClasses.push({ from: previousClassName, to: binding.className });
}

and hoist transferredClasses next to newSqliteClasses, wiring it into the migrations object at line 1482–1490 (replacing the empty placeholder) and widening its type to { from, fromScript, to }[].

First-vs-subsequent deploy detection (idempotency)

The absence of the alchemy:do:{logicalId}:{className} tag is the "first deploy" signal — the transfer branch is inside if (!previousClassName). On the transfer deploy, alchemy writes alchemy:do:{logicalId}:{className} and bumps alchemy:migration-tag. On every later deploy, previousClassName is set → neither transferred_classes nor new_sqlite_classes re-emitted. This aligns with Cloudflare's "applied once per environment" tag rule: even a stray re-emit would be a no-op past the tag. Retry-safe.

One guard worth adding: only take the transfer branch when the class is genuinely new here (no matching observed cloud binding by name on this script), so an adopt-in-place path (the existing "match by binding name" fallback, line 1427–1437) still wins over transfer.

What the SOURCE worker must see (the #799 coupling)

After the transfer, the source worker (former host) redeploys with the class removed and its binding converted to cross-script. At that moment oldDoClassNameByLogicalId still contains the tag, but getDurableObjectBindings excludes the now-cross-script binding (line 2567) → the class lands in deletedClasses (line 1371–1379). That is exactly the #799 bug, and for the preserving path emitting deleted_classes would be doubly wrong: it would try to delete a namespace that has already moved. So the data-preserving move requires #799's source-side fix — specifically the "skip the delete when the disappeared class is still referenced by a current cross-script binding" behavior (#799 option 2, or the binding-preserving half of option 1). The transfer PR should build on / land after #799.


3. Deploy-order & bookkeeping walkthrough (concrete two-stack case)

Topology: class MyDOClass moves from worker-b (former host) to worker-a (new host); worker-b keeps a reference-only cross-script binding. worker-b refs worker-a via Output.stackRef → engine deploys A before B (edge already exists).

  1. worker-a deploy (transfer)MyDOClass is new here (no alchemy:do tag for its logicalId), transferFrom: { scriptName: "worker-b" } set. Provider emits transferred_classes: [{ from: "MyDOClass", from_script: "worker-b", to: "MyDOClass" }], not new_sqlite_classes. worker-b is still, at this instant, hosting/exporting the class (it hasn't redeployed yet) → source valid. Cloudflare moves the namespace + data onto worker-a. Alchemy writes alchemy:do:{lid}:MyDOClass and bumps worker-a's migration tag. worker-b's existing binding auto-forwards to the new host immediately (docs).
  2. worker-b deploy (cleanup) — class export removed; binding now { scriptName: "worker-a" } (cross-script, excluded from migration ownership). With the Cloudflare: moving a DO class to another Worker fails — deleted_classes migration ships in the same upload as the new cross-script binding #799 fix in place, the provider sees the class is still referenced by a current cross-script binding → does not emit deleted_classes. worker-b ends reference-only; namespace lives on worker-a; data intact.

Failure / rollback:

  • worker-a transfer upload fails → transfer is atomic per upload; namespace stays on worker-b; worker-b untouched. Fix and retry worker-a.
  • worker-a succeeds, worker-b fails → namespace already on worker-a (tag written); retrying worker-a is a no-op (tag past the transfer); retry worker-b independently. Idempotent both directions.
  • Danger window between step 1 and 2: worker-b still exports the (now-empty-on-b) class locally while auto-forwarding to a — benign per docs, cleaned up by step 2. Worth a test.

Risks / unknowns

  • Hard-couples to Cloudflare: moving a DO class to another Worker fails — deleted_classes migration ships in the same upload as the new cross-script binding #799: the preserving move is only safe once the source stops emitting deleted_classes for a transferred-away class. Sequence the PRs.
  • Cross-stack ordering is load-bearing: transfer requires the source still hosting the class at transfer time, which is the reverse of the usual "host-first" cross-script intuition — but the stackRef edge (B→A) already produces the correct A-then-B order. Just document that transferFrom must reference a script that is still live and hosting the class on this deploy.
  • No pre-created destination: transferFrom is only valid on the class's first deploy on the receiving worker. If alchemy already created the namespace here (fresh new_sqlite_classes on a prior deploy), transfer is impossible — need a clear error rather than silently falling through.
  • from vs to mismatch (move+rename in one step) is allowed by the fields but untested here; safest to default from = to = className and treat rename-on-transfer as out of scope v1.
  • Same-account only (inferred from from_script being an unqualified name) — fine for alchemy's single-account stacks, but state it.
  • Backend preserved, not convertible: a KV-backed source stays KV-backed after transfer; you cannot use this to sneak a namespace onto SQLite.
  • Observability: transfer success is only verifiable by the namespace appearing under worker-a's settings (the provider already fetches durableObjectNamespaces post-put) — add an assertion there.

Scope estimate

Feasible: yes, with caveats. The genuinely hard parts are not the code (it's tiny) but the sequencing contract and the #799 dependency; both are already latent in alchemy's topology (the stackRef edge) and the sibling PR.


Key source references (all absolute):

  • src/Cloudflare/Workers/WorkerProvider.ts — migration computation lines 1353–1490 (transfer branch goes in 1411–1449), cross-script exclusion getDurableObjectBindings 2545–2582, tag map 2584–2596.
  • src/Cloudflare/Workers/DurableObject.tsDurableObjectProps line 111–126 (new transferFrom prop), binding emission 871–885.
  • @distilled.cloud/cloudflare src/services/workers.tsTransferredClass 1557–1570, SingleStepMigrationParam 2309–2358 (the shape the provider already ships).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions