You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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.
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:
exportinterfaceDurableObjectProps{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 hostclassName?: 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 branchtransferredClasses.push({from: binding.transferFrom.className??binding.className,fromScript: binding.transferFrom.scriptName,to: binding.className,});}else{newSqliteClasses.push(binding.className);}}elseif(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 }[].
The absence of the alchemy:do:{logicalId}:{className} tagis 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.
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).
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" }], notnew_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).
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.
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
DurableObject.ts: +1 prop (transferFrom), thread it into the durable_object_namespace binding data. (~10 LOC)
WorkerProvider.ts: hoist transferredClasses, add the if (binding.transferFrom) branch in the new/renamed loop, widen the migrations.transferredClasses type, plus a guard against transferring into an already-created namespace. (~25 LOC)
No SDK/wire changes — transferred_classes is already fully modeled and encoded.
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.ts — DurableObjectProps line 111–126 (new transferFrom prop), binding emission 871–885.
@distilled.cloud/cloudflare src/services/workers.ts — TransferredClass 1557–1570, SingleStepMigrationParam 2309–2358 (the shape the provider already ships).
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_classesTransfer 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:
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.new_sqlite_classes.fromonfrom_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.script_name→ new host). Critically that deploy must NOT emitdeleted_classesfor 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'sclass_name-only validator).toname (that's a move+rename in one, which thefrom/tofields explicitly allow), but you cannot alsorenamed_classesthe same class in the same step.transferred_classesdata-loss.2. Fit with alchemy's model
The wire path already exists.
node_modules/@distilled.cloud/cloudflare/src/services/workers.tsdefinesTransferredClass({ from, fromScript, to }→ encoded{ from, from_script, to }, line 1557–1570) andSingleStepMigrationParam.transferredClasses(line 2322–2325, 2344–2356) — the exact flat migration shapeWorkerProvider.tsalready sends asmetadata.migrations. AndWorkerProvider.ts:1488already carries a placeholdertransferredClasses: [] as { from: string; to: string }[]in itsmigrationsobject (it just needs afromScriptfield 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 existingscriptNameidiom:The receiving worker already has the source script name available via
Output.stackRef/worker.workerName(the same edge #799 uses), sotransferFrom.scriptNameis just that Output. This prop flows through the DO binding'sdataintogetDurableObjectBindingsreach.Provider changes (
WorkerProvider.ts, ~line 1411–1449, the "Compute new and renamed classes" loop)Today: a
currentDoBindingwhoselogicalIdhas no prioralchemy:do:tag → pushed tonewSqliteClasses(fresh empty namespace = data loss). Change:and hoist
transferredClassesnext tonewSqliteClasses, wiring it into themigrationsobject 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 insideif (!previousClassName). On the transfer deploy, alchemy writesalchemy:do:{logicalId}:{className}and bumpsalchemy:migration-tag. On every later deploy,previousClassNameis set → neithertransferred_classesnornew_sqlite_classesre-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
oldDoClassNameByLogicalIdstill contains the tag, butgetDurableObjectBindingsexcludes the now-cross-script binding (line 2567) → the class lands indeletedClasses(line 1371–1379). That is exactly the #799 bug, and for the preserving path emittingdeleted_classeswould 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
MyDOClassmoves fromworker-b(former host) toworker-a(new host);worker-bkeeps a reference-only cross-script binding.worker-brefsworker-aviaOutput.stackRef→ engine deploys A before B (edge already exists).MyDOClassis new here (noalchemy:dotag for its logicalId),transferFrom: { scriptName: "worker-b" }set. Provider emitstransferred_classes: [{ from: "MyDOClass", from_script: "worker-b", to: "MyDOClass" }], notnew_sqlite_classes.worker-bis still, at this instant, hosting/exporting the class (it hasn't redeployed yet) → source valid. Cloudflare moves the namespace + data ontoworker-a. Alchemy writesalchemy:do:{lid}:MyDOClassand bumpsworker-a's migration tag.worker-b's existing binding auto-forwards to the new host immediately (docs).{ 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 emitdeleted_classes.worker-bends reference-only; namespace lives onworker-a; data intact.Failure / rollback:
Risks / unknowns
deleted_classesfor a transferred-away class. Sequence the PRs.stackRefedge (B→A) already produces the correct A-then-B order. Just document thattransferFrommust reference a script that is still live and hosting the class on this deploy.transferFromis only valid on the class's first deploy on the receiving worker. If alchemy already created the namespace here (freshnew_sqlite_classeson a prior deploy), transfer is impossible — need a clear error rather than silently falling through.fromvstomismatch (move+rename in one step) is allowed by the fields but untested here; safest to defaultfrom=to=classNameand treat rename-on-transfer as out of scope v1.from_scriptbeing an unqualified name) — fine for alchemy's single-account stacks, but state it.worker-a's settings (the provider already fetchesdurableObjectNamespacespost-put) — add an assertion there.Scope estimate
DurableObject.ts: +1 prop (transferFrom), thread it into thedurable_object_namespacebindingdata. (~10 LOC)WorkerProvider.ts: hoisttransferredClasses, add theif (binding.transferFrom)branch in the new/renamed loop, widen themigrations.transferredClassestype, plus a guard against transferring into an already-created namespace. (~25 LOC)transferred_classesis already fully modeled and encoded.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 exclusiongetDurableObjectBindings2545–2582, tag map 2584–2596.src/Cloudflare/Workers/DurableObject.ts—DurableObjectPropsline 111–126 (newtransferFromprop), binding emission 871–885.@distilled.cloud/cloudflare src/services/workers.ts—TransferredClass1557–1570,SingleStepMigrationParam2309–2358 (the shape the provider already ships).