From 0d7a92db0d357c97f89a3fa9ded90975dab5e0f6 Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Fri, 10 Jul 2026 09:28:05 -0700 Subject: [PATCH 1/6] feat(cloudflare/workers): move DO classes between workers with transferredFrom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/Cloudflare/Workers/DurableObject.ts | 87 ++++++- .../Cloudflare/Workers/WorkerAsyncBindings.ts | 6 +- .../src/Cloudflare/Workers/WorkerBinding.ts | 20 +- .../src/Cloudflare/Workers/WorkerProvider.ts | 233 +++++++++++++++++- .../Workers/DurableObjectNamespace.test.ts | 147 +++++++++++ 5 files changed, 475 insertions(+), 18 deletions(-) diff --git a/packages/alchemy/src/Cloudflare/Workers/DurableObject.ts b/packages/alchemy/src/Cloudflare/Workers/DurableObject.ts index 05f918025..3c82e6678 100644 --- a/packages/alchemy/src/Cloudflare/Workers/DurableObject.ts +++ b/packages/alchemy/src/Cloudflare/Workers/DurableObject.ts @@ -62,6 +62,8 @@ export interface DurableObjectLike { /** @internal phantom */ scriptName?: Input; /** @internal phantom */ + transferredFrom?: Input; + /** @internal phantom */ Shape?: Shape; } @@ -120,6 +122,17 @@ export interface DurableObjectProps { * namespace is hosted by the Worker that declares the binding. */ scriptName?: Input | undefined; + /** + * Name of the Worker script that previously hosted this Durable Object + * class. When set, the deploy performs Cloudflare's data-preserving + * `transferred_classes` migration, moving the namespace — including all + * stored objects — from that script to this Worker. + * + * Once the transfer has completed, or when the source script does not host + * the class (e.g. on a fresh stage), the property is inert: the class is + * created fresh or left as-is, so it is safe to leave in place. + */ + transferredFrom?: Input | undefined; // environment?: string | undefined; // sqlite?: boolean | undefined; // namespaceId?: string | undefined; @@ -133,6 +146,7 @@ export interface DurableObjectClass extends Effect.Effect< (): { ( name: Name, + props?: Pick, ): Effect.Effect, never, Worker | Self> & { new (_: never): Shape & { /** @internal */ @@ -787,6 +801,59 @@ export class DurableObjectScope extends Context.Service< * }); * ``` * + * @section Moving a Class Between Workers + * A Durable Object class can move from one Worker to another with its + * data intact. Declare `transferredFrom` on the Durable Object at its + * **new host**, naming the script that previously hosted the class — + * Alchemy performs Cloudflare's data-preserving `transferred_classes` + * migration on the new host's deploy, and the former host's deploy + * converges on its own (it no longer owns the class, so no delete + * migration is emitted). Once the transfer has completed — or on a + * fresh stage where the source script never hosted the class — the + * property is inert, so it is safe to leave in place. + * + * Without `transferredFrom`, re-binding a previously-local class as a + * cross-script reference fails before any upload with + * `DurableObjectTransferRequired`: the namespace (and every stored + * object) would otherwise be destroyed by the class deletion. + * + * @example New host declares where the class moved from + * ```typescript + * // worker-a alchemy.run.ts — the NEW host of the class + * const workerA = yield* Cloudflare.Worker("WorkerA", { + * main: "./src/worker-a.ts", // exports MyDOClass + * bindings: { + * MyDO: Cloudflare.DurableObject("MyDO", { + * className: "MyDOClass", + * transferredFrom: "worker-b", // script that used to host it + * }), + * }, + * }); + * ``` + * + * @example Former host keeps a cross-script reference + * ```typescript + * // worker-b alchemy.run.ts — used to host the class, now just binds it + * const workerB = yield* Cloudflare.Worker("WorkerB", { + * main: "./src/worker-b.ts", // no longer exports MyDOClass + * bindings: { + * MyDO: Cloudflare.DurableObject("MyDO", { + * className: "MyDOClass", + * scriptName: workerA.workerName, + * }), + * }, + * }); + * ``` + * + * @example Transferring an Effect-native Durable Object + * ```typescript + * // The class form takes the same option at its new host. + * export class Counter extends Cloudflare.DurableObject()( + * "Counter", + * { transferredFrom: "old-host-script" }, + * ) {} + * ``` + * * @section Adopting an Existing Durable Object * When you adopt a Worker that already exists on Cloudflare — created * outside Alchemy via Wrangler, the dashboard, or the raw API — its @@ -868,18 +935,21 @@ export const DurableObject: DurableObjectClass = taggedFunction( const propsOrImpl = args[1]; const tag = Context.Service(namespace); - const binding = (scriptName?: Input) => + const binding = ( + scriptName?: Input, + transferredFrom?: Input, + ) => Effect.gen(function* () { const worker = yield* Worker; yield* worker.bind`${namespace}`({ - // TODO(sam): automate class migrations, probably in the provider bindings: [ { type: "durable_object_namespace", name: namespace, className: namespace, scriptName, + transferredFrom, }, ], }); @@ -932,6 +1002,16 @@ export const DurableObject: DurableObjectClass = taggedFunction( }; }); + // Class-form declarations (`DurableObject()("Name", props?)`) can + // carry `transferredFrom` so the host's local binding drives a + // data-preserving transfer migration. + const classProps = + isClassForm && !Effect.isEffect(propsOrImpl) + ? (propsOrImpl as + | Pick + | undefined) + : undefined; + const make = Effect.fn(function* ( impl: Effect.Effect< Effect.Effect, @@ -944,7 +1024,7 @@ export const DurableObject: DurableObjectClass = taggedFunction( // `DurableObjectScope` to the user's constructor effect // and also return it so a `Layer.effect(tag, make(impl))` Layer // resolves the tag to a concrete namespace value. - const self = yield* binding(); + const self = yield* binding(undefined, classProps?.transferredFrom); const phase = yield* ALCHEMY_PHASE; const constructor = impl.pipe( Effect.provide(Layer.succeed(DurableObjectScope, self as any)), @@ -979,6 +1059,7 @@ export const DurableObject: DurableObjectClass = taggedFunction( name: namespace, className: (args[1] as DurableObjectProps)?.className || namespace, scriptName: (args[1] as DurableObjectProps)?.scriptName, + transferredFrom: (args[1] as DurableObjectProps)?.transferredFrom, }; } else if (Effect.isEffect(propsOrImpl)) { // inline Effect DO diff --git a/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts b/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts index 6ba10c35b..ef278c6d7 100644 --- a/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts +++ b/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts @@ -1,4 +1,3 @@ -import type { PutScriptRequest } from "@distilled.cloud/cloudflare/workers"; import * as Effect from "effect/Effect"; import * as Redacted from "effect/Redacted"; import type { InputProps } from "../../Input.ts"; @@ -87,9 +86,7 @@ export const bindWorkerAsyncBindings = Effect.fn(function* ( } }); -type BindingSpec = InputProps< - Exclude[number] ->; +type BindingSpec = InputProps; const toBinding = ( bindingName: string, @@ -170,6 +167,7 @@ const toBinding = ( name: bindingName, className: binding.className ?? binding.name, scriptName: binding.scriptName, + transferredFrom: binding.transferredFrom, }; } else if (isWorkflowLike(binding)) { return { diff --git a/packages/alchemy/src/Cloudflare/Workers/WorkerBinding.ts b/packages/alchemy/src/Cloudflare/Workers/WorkerBinding.ts index de75d76fb..35497c766 100644 --- a/packages/alchemy/src/Cloudflare/Workers/WorkerBinding.ts +++ b/packages/alchemy/src/Cloudflare/Workers/WorkerBinding.ts @@ -31,11 +31,29 @@ import type { VersionMetadataBinding } from "./VersionMetadataBinding.ts"; import { Worker, WorkerEnvironment } from "./Worker.ts"; import type { WorkerLoader } from "./WorkerLoader.ts"; -export type WorkerBinding = Exclude< +type DistilledWorkerBinding = Exclude< workers.PutScriptRequest["metadata"]["bindings"], undefined >[number]; +/** + * The `durable_object_namespace` metadata binding extended with alchemy-only + * transfer metadata. `transferredFrom` names the Worker script that + * previously hosted the class so the provider can drive Cloudflare's + * data-preserving `transferred_classes` migration. It is stripped from the + * binding before the script upload — Cloudflare never sees it. + */ +export type DurableObjectNamespaceWorkerBinding = Extract< + DistilledWorkerBinding, + { type: "durable_object_namespace" } +> & { + transferredFrom?: string; +}; + +export type WorkerBinding = + | Exclude + | DurableObjectNamespaceWorkerBinding; + export type WorkerSettingsBinding = Exclude< workers.GetScriptScriptAndVersionSettingResponse["bindings"], null | undefined diff --git a/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts b/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts index 9c48aef1f..3702142d6 100644 --- a/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts +++ b/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts @@ -1,3 +1,4 @@ +import * as durableObjectsApi from "@distilled.cloud/cloudflare/durable-objects"; import * as workers from "@distilled.cloud/cloudflare/workers"; import * as wfp from "@distilled.cloud/cloudflare/workers-for-platforms"; import * as zones from "@distilled.cloud/cloudflare/zones"; @@ -37,6 +38,40 @@ class MissingDurableObjects extends Data.TaggedError("MissingDurableObjects")<{ expected: string[]; }> {} +/** + * A Durable Object class is being dropped from this Worker while a binding in + * the same deploy still references it on another script — the class moved + * cross-script, but its namespace (and every stored object in it) still lives + * on this Worker. Cloudflare rejects a single upload that both deletes the + * class and ships a binding referencing it, and silently deleting would + * destroy the namespace's data irreversibly, so the deploy fails before any + * upload. + * + * To move the data, declare `transferredFrom` on the Durable Object at its + * new host and deploy that Worker first — Alchemy then performs Cloudflare's + * data-preserving `transferred_classes` migration and this deploy converges + * on its own. To abandon the data instead, remove the binding entirely in one + * deploy (which deletes the class and its data), then add the cross-script + * binding in a second deploy. + */ +export class DurableObjectTransferRequired extends Data.TaggedError( + "DurableObjectTransferRequired", +)<{ + scriptName: string; + className: string; + targetScriptName: string | undefined; +}> { + override get message() { + return ( + `Durable Object class '${this.className}' still lives on Worker '${this.scriptName}' but this deploy re-binds it as a cross-script reference` + + (this.targetScriptName ? ` to '${this.targetScriptName}'` : "") + + ". Durable Object data does NOT move with the class automatically. " + + `To move the data, set transferredFrom: "${this.scriptName}" on the Durable Object declaration in its new host Worker and deploy that Worker first. ` + + "To abandon the data, remove the binding entirely in one deploy before re-adding it as a cross-script reference." + ); + } +} + /** * Resolve the Workers for Platforms dispatch-namespace *name* from a resolved * `namespace` prop or persisted attribute. The engine resolves a passed @@ -1218,7 +1253,22 @@ export const LiveWorkerProvider = () => assets: prebuiltAssets?.hash ?? preparedHash.assets, metadata: metadataHash, } satisfies Worker["Attributes"]["hash"]; - const metadataBindings = bindings.flatMap((b) => b.data.bindings ?? []); + // `transferredFrom` is alchemy-only transfer metadata on + // durable_object_namespace bindings — it drives the + // `transferred_classes` migration below and must be stripped from the + // wire-shape binding before upload. + const metadataBindings = bindings.flatMap((b) => + (b.data.bindings ?? []).map((item): WorkerBinding => { + if ( + item.type === "durable_object_namespace" && + item.transferredFrom !== undefined + ) { + const { transferredFrom: _, ...rest } = item; + return rest; + } + return item; + }), + ); const expectedDurableObjectClassNames = getExpectedDurableObjectClassNames(metadataBindings, name); let metadataAssets: @@ -1369,13 +1419,15 @@ export const LiveWorkerProvider = () => )[0]; const newMigrationTag = bumpMigrationTagVersion(oldMigrationTag); - // Compute deleted classes - const deletedClasses: string[] = []; + // Compute delete-class candidates. Candidates are validated against + // observed namespace ownership below — a class may already have been + // transferred to another script by its new host's deploy. + const deletedClassCandidates: string[] = []; for (const [logicalId, className] of Object.entries( oldDoClassNameByLogicalId, )) { if (!currentDoClassNameByLogicalId[logicalId]) { - deletedClasses.push(className); + deletedClassCandidates.push(className); } } @@ -1397,11 +1449,107 @@ export const LiveWorkerProvider = () => (binding) => binding.bindingName === oldBinding.name, ) ) { - deletedClasses.push(oldBinding.className); + deletedClassCandidates.push(oldBinding.className); } } } + // Class names the current deploy references *cross-script* (mapped to + // the foreign script). A class that both leaves the "hosted here" set + // and shows up here has moved to another Worker — Cloudflare rejects + // deleting a class while any binding in the upload references its + // class name, and deleting would destroy the namespace's data. + const crossScriptClassTargets = new Map(); + for (const item of metadataBindings) { + if ( + item.type === "durable_object_namespace" && + item.className && + typeof item.scriptName === "string" && + item.scriptName !== name + ) { + crossScriptClassTargets.set(item.className, item.scriptName); + } + } + + // One account-level namespace listing serves both sides of a + // transfer: the destination checks the source still hosts the class + // before emitting `transferred_classes`, and the former host checks + // whether a to-be-deleted class was already transferred away. + // Dispatch-namespace user workers keep the legacy behavior — their + // namespaces don't surface on the account-level list. + const mayTransferIn = currentDoBindings.some( + (binding) => + binding.transferredFrom && + !oldDoClassNameByLogicalId[binding.logicalId], + ); + const observedNamespaces = + !dispatchNamespace && + (deletedClassCandidates.length > 0 || mayTransferIn) + ? yield* listDurableObjectNamespaces(accountId) + : []; + const hosts = ( + namespaces: readonly { script: string; class: string }[], + scriptName: string, + className: string, + ) => + namespaces.some( + (ns) => ns.script === scriptName && ns.class === className, + ); + const scriptHostsClass = (scriptName: string, className: string) => + hosts(observedNamespaces, scriptName, className); + + const deletedClasses: string[] = []; + for (const className of deletedClassCandidates) { + if (dispatchNamespace) { + deletedClasses.push(className); + continue; + } + const targetScriptName = crossScriptClassTargets.get(className); + if (targetScriptName === undefined) { + // Plain removal. Delete only if the namespace actually still + // lives here — it may have been transferred to another script by + // that script's deploy, or removed out-of-band. The stale + // alchemy:do tag drops out either way because tags are recomputed + // from current bindings. + if (scriptHostsClass(name, className)) { + deletedClasses.push(className); + } + continue; + } + // The class went local → cross-script. When the new host's deploy + // ran a `transferred_classes` migration moments ago, the account + // listing can briefly still attribute the namespace to this script, + // so re-observe with a short bounded budget until the transfer + // becomes visible (namespace off this script) or the state is + // conclusively a conflict (still here — including the case where + // the target created a *fresh* namespace for the same class name). + const namespaces = yield* listDurableObjectNamespaces(accountId).pipe( + Effect.repeat({ + schedule: Schedule.spaced("2 seconds"), + until: (observed) => + !hosts(observed, name, className) || + hosts(observed, targetScriptName, className), + times: 5, + }), + ); + if (!hosts(namespaces, name, className)) { + // Transferred away — nothing to delete. + continue; + } + // local → cross-script transition without a transfer. Fail before + // any upload: Cloudflare would reject the combined delete + binding + // anyway, and silently deleting would destroy the namespace's + // data. See the error's docs for the two ways out + // (`transferredFrom` on the new host, or a two-phase removal). + return yield* Effect.fail( + new DurableObjectTransferRequired({ + scriptName: name, + className, + targetScriptName, + }), + ); + } + // Collect container-backed class names so we can send container metadata const containerClassNames = new Set( bindings.flatMap((b) => @@ -1409,10 +1557,15 @@ export const LiveWorkerProvider = () => ), ); - // Compute new and renamed classes + // Compute new, renamed, and transferred classes const newClasses: string[] = []; const newSqliteClasses: string[] = []; const renamedClasses: { from: string; to: string }[] = []; + const transferredClasses: { + from: string; + fromScript: string; + to: string; + }[] = []; for (const binding of currentDoBindings) { let previousClassName: string | undefined = oldDoClassNameByLogicalId[binding.logicalId]; @@ -1437,6 +1590,24 @@ export const LiveWorkerProvider = () => } } if (!previousClassName) { + if ( + binding.transferredFrom && + !dispatchNamespace && + scriptHostsClass(binding.transferredFrom, binding.className) + ) { + // Data-preserving move: the source script still hosts the + // namespace, so ship Cloudflare's `transferred_classes` + // migration instead of creating a fresh class. When the source + // doesn't host it (fresh stage, or the transfer already ran), + // fall through to a plain create — `transferredFrom` is inert + // then and safe to leave in place. + transferredClasses.push({ + from: binding.className, + fromScript: binding.transferredFrom, + to: binding.className, + }); + continue; + } // Default all new Durable Object classes to SQLite. Cloudflare // recommends SQLite for new namespaces, and container-backed // Durable Objects require it. @@ -1456,6 +1627,7 @@ export const LiveWorkerProvider = () => currentDoClassNameByLogicalId, deletedClasses, renamedClasses, + transferredClasses, newSqliteClasses, }, )}`, @@ -1486,7 +1658,7 @@ export const LiveWorkerProvider = () => newClasses, deletedClasses, renamedClasses, - transferredClasses: [] as { from: string; to: string }[], + transferredClasses, newSqliteClasses, }; @@ -2004,10 +2176,17 @@ export const LiveWorkerProvider = () => const exportDerived = Object.keys(exportMap) .filter((logicalId) => isDurableObjectExport(exportMap[logicalId])) .map((logicalId) => ({ logicalId, className: logicalId })); + // Transfer-marked classes are excluded from the placeholder + // entirely (class list, bindings, tags): Cloudflare forbids + // creating the destination class of a `transferred_classes` + // migration ahead of the transfer, so `reconcile` must perform it + // with the real upload. (`transferredFrom` may still be an + // unresolved Output here — precreate runs on raw props — but only + // its presence matters.) const durableObjects = mergeDurableObjectClasses( exportDerived, getDurableObjectBindings(bindings, name), - ); + ).filter((binding) => !binding.transferredFrom); const doClasses = durableObjects.map((binding) => binding.className); // Only attach container metadata for classes actually fronted by a // Container binding (mirrors reconcile's `containerClassNames`). @@ -2515,6 +2694,25 @@ const contentTypeFromExtension = (extension: string) => { } }; +/** + * Observe every Durable Object namespace on the account as `(script, class)` + * pairs. Namespace ownership is authoritative cloud state: after a + * `transferred_classes` migration the namespace moves to the receiving + * script, so this is how both sides of a transfer observe where a class + * currently lives — the destination checks the source still hosts the class + * before emitting the transfer, and the former host checks whether a class + * it is about to delete has already been transferred away. + */ +const listDurableObjectNamespaces = (accountId: string) => + durableObjectsApi.listNamespaces.items({ accountId }).pipe( + Stream.runCollect, + Effect.map((namespaces) => + Array.from(namespaces).flatMap((ns) => + ns.script && ns.class ? [{ script: ns.script, class: ns.class }] : [], + ), + ), + ); + function bumpMigrationTagVersion( oldTag: string | undefined, ): string | undefined { @@ -2531,8 +2729,16 @@ function bumpMigrationTagVersion( * the same logical id (the binding sid) that `reconcile` writes. */ function mergeDurableObjectClasses( - exportDerived: ReadonlyArray<{ logicalId: string; className: string }>, - bindingDerived: ReadonlyArray<{ logicalId: string; className: string }>, + exportDerived: ReadonlyArray<{ + logicalId: string; + className: string; + transferredFrom?: string; + }>, + bindingDerived: ReadonlyArray<{ + logicalId: string; + className: string; + transferredFrom?: string; + }>, ) { return Array.from( new Map( @@ -2576,6 +2782,13 @@ function getDurableObjectBindings( logicalId: binding.sid, bindingName: item.name, className: item.className, + // Source script of a data-preserving `transferred_classes` + // migration. A self-reference is meaningless — the class already + // lives here — so normalize it away. + transferredFrom: + item.transferredFrom !== workerName + ? item.transferredFrom + : undefined, }, ]; }), diff --git a/packages/alchemy/test/Cloudflare/Workers/DurableObjectNamespace.test.ts b/packages/alchemy/test/Cloudflare/Workers/DurableObjectNamespace.test.ts index 13a203f10..7b291acde 100644 --- a/packages/alchemy/test/Cloudflare/Workers/DurableObjectNamespace.test.ts +++ b/packages/alchemy/test/Cloudflare/Workers/DurableObjectNamespace.test.ts @@ -542,3 +542,150 @@ test.provider( }).pipe(logLevel), { timeout: 120_000 }, ); + +// #799: move a Durable Object class from one Worker to another with +// `transferredFrom`. The destination's deploy carries Cloudflare's +// data-preserving `transferred_classes` migration, and the former host's +// deploy converges (no `deleted_classes` for a class that moved away). +// +// v1 — worker-b hosts `Counter` locally; write data into the namespace +// v2 — worker-a hosts `Counter` with `transferredFrom: `; +// worker-b re-binds the same class cross-script. The stored data +// must survive the move and be reachable from both workers. +// v3 — redeploy of the same shape is inert (`transferredFrom` stays in +// the code; the transfer must not be re-attempted). +test.provider( + "transferredFrom moves a durable object class between workers preserving data", + (scratch) => + Effect.gen(function* () { + yield* scratch.destroy(); + + const v1 = yield* scratch.deploy( + Effect.gen(function* () { + return { + b: yield* Cloudflare.Worker("worker-b", { + script: hostWorkerScript, + env: { + Counter: Cloudflare.DurableObject("Counter"), + }, + }), + }; + }), + ); + + // Write data while worker-b hosts the namespace. + yield* fetchJsonReady<{ ok: boolean }>(`${v1.b.url}/reset`); + const written = yield* fetchJsonReady<{ value: number }>( + `${v1.b.url}/increment`, + ); + expect(written.value).toBe(1); + + // `transferredFrom` is the *physical* script name of the former host. + const bScriptName = v1.b.workerName; + + const moved = Effect.gen(function* () { + const a = yield* Cloudflare.Worker("worker-a", { + script: hostWorkerScript, + env: { + Counter: Cloudflare.DurableObject("Counter", { + transferredFrom: bScriptName, + }), + }, + }); + const b = yield* Cloudflare.Worker("worker-b", { + script: consumerWorkerScript, + env: { + Counter: Cloudflare.DurableObject("Counter", { + scriptName: a.workerName, + }), + }, + }); + return { a, b }; + }); + + const v2 = yield* scratch.deploy(moved); + + // The namespace moved to worker-a with its data intact... + const viaA = yield* fetchJsonReady<{ value: number }>(`${v2.a.url}/get`); + expect(viaA.value).toBe(1); + + // ...and worker-b reaches the same objects through the cross-script + // binding. + const viaB = yield* fetchJsonReady<{ value: number }>( + `${v2.b.url}/increment`, + ); + expect(viaB.value).toBe(2); + + // Redeploying the same shape is inert: the class now lives on + // worker-a (tracked by its alchemy:do tag), so `transferredFrom` + // must not re-emit a transfer migration. Data still intact. + const v3 = yield* scratch.deploy(moved); + const afterRedeploy = yield* fetchJsonReady<{ value: number }>( + `${v3.a.url}/get`, + ); + expect(afterRedeploy.value).toBe(2); + + yield* scratch.destroy(); + }).pipe(logLevel), + { timeout: 240_000 }, +); + +// #799 (safety interlock): the same local → cross-script move WITHOUT +// `transferredFrom` must fail loudly before the former host uploads +// anything. The namespace (and its data) still lives on worker-b; deleting +// the class would destroy it, and Cloudflare rejects a single upload that +// deletes a class while a binding still references its class name. +test.provider( + "moving a class cross-script without transferredFrom fails with DurableObjectTransferRequired", + (scratch) => + Effect.gen(function* () { + yield* scratch.destroy(); + + const v1 = yield* scratch.deploy( + Effect.gen(function* () { + return { + b: yield* Cloudflare.Worker("worker-b", { + script: hostWorkerScript, + env: { + Counter: Cloudflare.DurableObject("Counter"), + }, + }), + }; + }), + ); + + // Data exists on worker-b's namespace — this is what the interlock + // protects. + yield* fetchJsonReady<{ ok: boolean }>(`${v1.b.url}/reset`); + yield* fetchJsonReady<{ value: number }>(`${v1.b.url}/increment`); + + const error = yield* scratch + .deploy( + Effect.gen(function* () { + const a = yield* Cloudflare.Worker("worker-a", { + script: hostWorkerScript, + env: { + // No `transferredFrom` — worker-a creates a fresh, empty + // namespace for the same class name. + Counter: Cloudflare.DurableObject("Counter"), + }, + }); + const b = yield* Cloudflare.Worker("worker-b", { + script: consumerWorkerScript, + env: { + Counter: Cloudflare.DurableObject("Counter", { + scriptName: a.workerName, + }), + }, + }); + return { a, b }; + }), + ) + .pipe(Effect.flip); + + expect(error._tag).toEqual("DurableObjectTransferRequired"); + + yield* scratch.destroy(); + }).pipe(logLevel), + { timeout: 240_000 }, +); From 81acff0e32b851dc8ab72b206b3c1a853c73858d Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Fri, 10 Jul 2026 17:49:22 -0700 Subject: [PATCH 2/6] feat(cloudflare/workers): infer DO class transfers from plan provenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/Cloudflare/Workers/DurableObject.ts | 157 +++++++++--- .../alchemy/src/Cloudflare/Workers/Worker.ts | 17 ++ .../Cloudflare/Workers/WorkerAsyncBindings.ts | 16 +- .../src/Cloudflare/Workers/WorkerProvider.ts | 228 ++++++++++++++++-- .../Workers/DurableObjectNamespace.test.ts | 128 ++++++++-- 5 files changed, 460 insertions(+), 86 deletions(-) diff --git a/packages/alchemy/src/Cloudflare/Workers/DurableObject.ts b/packages/alchemy/src/Cloudflare/Workers/DurableObject.ts index 3c82e6678..a98ef66c5 100644 --- a/packages/alchemy/src/Cloudflare/Workers/DurableObject.ts +++ b/packages/alchemy/src/Cloudflare/Workers/DurableObject.ts @@ -22,7 +22,12 @@ import { } from "./DurableObjectState.ts"; import { makeRpcStub } from "./Rpc.ts"; import { type WebSocket } from "./WebSocket.ts"; -import { Worker, WorkerEnvironment, type WorkerServices } from "./Worker.ts"; +import { + isWorker, + Worker, + WorkerEnvironment, + type WorkerServices, +} from "./Worker.ts"; export interface DurableObjectExport { readonly kind: "durableObject"; @@ -128,6 +133,13 @@ export interface DurableObjectProps { * `transferred_classes` migration, moving the namespace — including all * stored objects — from that script to this Worker. * + * Only needed when the move can't be inferred from the plan — i.e. the + * former host deploys from a *different stack* (or references this Worker + * by a raw string). When both Workers deploy together and the former host + * references this one by resource (`scriptName: worker.workerName`, + * `Counter.from(Worker)`), the transfer is inferred automatically and + * this property is unnecessary. + * * Once the transfer has completed, or when the source script does not host * the class (e.g. on a fresh stage), the property is inert: the class is * created fresh or left as-is, so it is safe to leave in place. @@ -212,6 +224,58 @@ export class DurableObjectScope extends Context.Service< DurableObject >()("Cloudflare.DurableObject") {} +/** + * Recover the target Worker resource from a cross-script `scriptName` value + * at plan time. Covers the shapes that reference a Worker in the same plan: + * the resource itself, or its `workerName` output (`scriptName: + * host.workerName`, which is also what `.from(WorkerA)` passes). Raw strings + * and mapped/foreign Outputs (e.g. cross-stack `stackRef`s) carry no resource + * provenance and return `undefined` — no transfer can be inferred for them. + * + * @internal + */ +const workerFromScriptName = (scriptName: unknown): Worker | undefined => { + if (isWorker(scriptName)) { + return scriptName; + } + if ( + Output.isPropExpr(scriptName) && + scriptName.identifier === "workerName" && + Output.isResourceExpr(scriptName.expr) && + isWorker(scriptName.expr.src) + ) { + return scriptName.expr.src; + } + return undefined; +}; + +/** + * Attach a Durable Object transfer hint onto the target of a cross-script DO + * declaration. The hint — pure literals, so it adds no dependency edges — + * tells the target's provider that `self` references `(logicalId, className)` + * on it. If the target is newly hosting that class while `self`'s script + * still holds the namespace (verified against `alchemy:id:` / `alchemy:do:` + * tags and observed namespace ownership), the target's deploy infers + * Cloudflare's data-preserving `transferred_classes` migration — the + * cross-worker analog of how renames are inferred within one worker. + * + * @internal + */ +export const registerDoTransferHint = Effect.fn(function* ( + self: Worker, + scriptName: unknown, + logicalId: string, + className: string, +) { + const target = workerFromScriptName(scriptName); + if (target === undefined || target.FQN === self.FQN) { + return; + } + yield* target.bind`${logicalId}:transfer-hint`({ + doTransferHints: [{ logicalId, className, fromWorkerId: self.LogicalId }], + }); +}); + /** * A Cloudflare Durable Object namespace that manages globally unique, stateful * instances with WebSocket hibernation support. @@ -803,57 +867,71 @@ export class DurableObjectScope extends Context.Service< * * @section Moving a Class Between Workers * A Durable Object class can move from one Worker to another with its - * data intact. Declare `transferredFrom` on the Durable Object at its - * **new host**, naming the script that previously hosted the class — - * Alchemy performs Cloudflare's data-preserving `transferred_classes` - * migration on the new host's deploy, and the former host's deploy - * converges on its own (it no longer owns the class, so no delete - * migration is emitted). Once the transfer has completed — or on a - * fresh stage where the source script never hosted the class — the - * property is inert, so it is safe to leave in place. - * - * Without `transferredFrom`, re-binding a previously-local class as a - * cross-script reference fails before any upload with - * `DurableObjectTransferRequired`: the namespace (and every stored - * object) would otherwise be destroyed by the class deletion. - * - * @example New host declares where the class moved from + * data intact — and when both Workers deploy from the same stack, the + * move is **inferred**, exactly like class renames. Keep the Durable + * Object's name stable, host it on the new Worker, and have the former + * host reference it cross-script (`scriptName: newHost.workerName`, or + * `Counter.from(NewHost)` in the Effect-native form). At plan time the + * cross-script declaration carries the target's resource provenance, so + * the new host's deploy knows the class is arriving from the former + * host, verifies the namespace still lives there, and ships Cloudflare's + * data-preserving `transferred_classes` migration. The former host's + * deploy converges on its own — no delete migration is emitted for a + * class that moved away. + * + * @example Move a class by moving where it's hosted * ```typescript - * // worker-a alchemy.run.ts — the NEW host of the class - * const workerA = yield* Cloudflare.Worker("WorkerA", { - * main: "./src/worker-a.ts", // exports MyDOClass + * // 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; worker-b keeps a cross-script reference. + * // Nothing else to declare — the transfer is inferred and the data moves. + * const a = yield* Cloudflare.Worker("WorkerA", { + * main: "./src/worker-a.ts", // now exports MyDOClass + * bindings: { + * MyDO: Cloudflare.DurableObject("MyDO", { className: "MyDOClass" }), + * }, + * }); + * const b = yield* Cloudflare.Worker("WorkerB", { + * main: "./src/worker-b.ts", // no longer exports MyDOClass * bindings: { * MyDO: Cloudflare.DurableObject("MyDO", { * className: "MyDOClass", - * transferredFrom: "worker-b", // script that used to host it + * scriptName: a.workerName, // provenance ⇒ transfer is inferred * }), * }, * }); * ``` * - * @example Former host keeps a cross-script reference + * When the move spans two stacks — or the cross-script `scriptName` is a + * raw string with no resource provenance — the plan cannot see both + * sides, so nothing can be inferred. The deploy then fails before any + * upload with `DurableObjectTransferRequired` (Durable Object data does + * not move on its own, and deleting the class would destroy it). Name + * the source explicitly with `transferredFrom` on the Durable Object at + * its **new host** and deploy that stack first; once the transfer has + * completed — or on a fresh stage — the property is inert and safe to + * leave in place. + * + * @example Cross-stack move: name the source explicitly * ```typescript - * // worker-b alchemy.run.ts — used to host the class, now just binds it - * const workerB = yield* Cloudflare.Worker("WorkerB", { - * main: "./src/worker-b.ts", // no longer exports MyDOClass + * // stack-a alchemy.run.ts — the NEW host, deployed from its own stack + * const workerA = yield* Cloudflare.Worker("WorkerA", { + * main: "./src/worker-a.ts", // exports MyDOClass * bindings: { * MyDO: Cloudflare.DurableObject("MyDO", { * className: "MyDOClass", - * scriptName: workerA.workerName, + * transferredFrom: "worker-b", // script that used to host it * }), * }, * }); * ``` * - * @example Transferring an Effect-native Durable Object - * ```typescript - * // The class form takes the same option at its new host. - * export class Counter extends Cloudflare.DurableObject()( - * "Counter", - * { transferredFrom: "old-host-script" }, - * ) {} - * ``` - * * @section Adopting an Existing Durable Object * When you adopt a Worker that already exists on Cloudflare — created * outside Alchemy via Wrangler, the dashboard, or the raw API — its @@ -954,6 +1032,17 @@ export const DurableObject: DurableObjectClass = taggedFunction( ], }); + if (scriptName !== undefined) { + // Cross-script reference: hint the target so it can infer a + // data-preserving transfer when this class is moving host. + yield* registerDoTransferHint( + worker, + scriptName, + namespace, + namespace, + ); + } + const binding = yield* Effect.all([ WorkerEnvironment, ALCHEMY_PHASE, diff --git a/packages/alchemy/src/Cloudflare/Workers/Worker.ts b/packages/alchemy/src/Cloudflare/Workers/Worker.ts index 3b9ed49aa..20a4329e9 100644 --- a/packages/alchemy/src/Cloudflare/Workers/Worker.ts +++ b/packages/alchemy/src/Cloudflare/Workers/Worker.ts @@ -642,6 +642,23 @@ export type Worker = Resource< }, { bindings?: WorkerBinding[]; + /** + * Durable Object transfer hints contributed by *other* Workers' + * cross-script DO declarations. When Worker B re-binds a Durable Object + * it used to host as a cross-script reference to Worker A, B's + * declaration binds `{ logicalId, className, fromWorkerId: B }` onto A + * at plan time. A's reconcile uses the hint to infer Cloudflare's + * data-preserving `transferred_classes` migration: a class that is new + * to A whose hint resolves (via `alchemy:id:` / `alchemy:do:` tags, + * same stack + stage) to a script currently hosting the namespace is + * transferred instead of created fresh. Pure literals — hints never + * carry Outputs, so they add no dependency edges. + */ + doTransferHints?: { + logicalId: string; + className: string; + fromWorkerId: string; + }[]; /** * Workers Cache settings contributed by `yield* Cloudflare.cache()`. * Merged into the upload metadata's `cache_options`; an explicit diff --git a/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts b/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts index ef278c6d7..ca05e7a58 100644 --- a/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts +++ b/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts @@ -24,7 +24,10 @@ import { isDispatchNamespace } from "../WorkersForPlatforms/DispatchNamespace.ts import { isWorkflowLike, WorkflowResource } from "../Workflows/Workflow.ts"; import { isAssets } from "./Assets.ts"; import { isBrowser } from "./Browser.ts"; -import { isDurableObjectLike } from "./DurableObject.ts"; +import { + isDurableObjectLike, + registerDoTransferHint, +} from "./DurableObject.ts"; import { isRateLimit } from "./RateLimit.ts"; import { isVersionMetadata } from "./VersionMetadata.ts"; import type { WorkerBindingProps } from "./Worker.ts"; @@ -66,6 +69,17 @@ export const bindWorkerAsyncBindings = Effect.fn(function* ( : undefined, }); + if (isDurableObjectLike(binding) && binding.scriptName !== undefined) { + // Cross-script DO reference: hint the target so it can infer a + // data-preserving transfer when this class is moving host. + yield* registerDoTransferHint( + resource, + binding.scriptName, + bindingName, + binding.className ?? binding.name, + ); + } + // A locally-hosted Workflow (no `scriptName`) must be registered with // Cloudflare via `putWorkflow` once the host Worker exists. Cross-script // references (with `scriptName`) are reference-only — the host owns the diff --git a/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts b/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts index 3702142d6..58390c568 100644 --- a/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts +++ b/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts @@ -47,12 +47,16 @@ class MissingDurableObjects extends Data.TaggedError("MissingDurableObjects")<{ * destroy the namespace's data irreversibly, so the deploy fails before any * upload. * - * To move the data, declare `transferredFrom` on the Durable Object at its - * new host and deploy that Worker first — Alchemy then performs Cloudflare's - * data-preserving `transferred_classes` migration and this deploy converges - * on its own. To abandon the data instead, remove the binding entirely in one - * deploy (which deletes the class and its data), then add the cross-script - * binding in a second deploy. + * When both Workers are in the same stack and the cross-script `scriptName` + * carries resource provenance (the Worker resource or its `workerName` + * output, including `.from(WorkerA)`), Alchemy infers the data-preserving + * `transferred_classes` migration automatically and this error never fires. + * It fires when the move can't be seen in the plan — a cross-stack move, or a + * `scriptName` passed as a raw string. To move the data then, declare + * `transferredFrom` on the Durable Object at its new host and deploy that + * Worker first. To abandon the data instead, remove the binding entirely in + * one deploy (which deletes the class and its data), then add the + * cross-script binding in a second deploy. */ export class DurableObjectTransferRequired extends Data.TaggedError( "DurableObjectTransferRequired", @@ -66,12 +70,36 @@ export class DurableObjectTransferRequired extends Data.TaggedError( `Durable Object class '${this.className}' still lives on Worker '${this.scriptName}' but this deploy re-binds it as a cross-script reference` + (this.targetScriptName ? ` to '${this.targetScriptName}'` : "") + ". Durable Object data does NOT move with the class automatically. " + - `To move the data, set transferredFrom: "${this.scriptName}" on the Durable Object declaration in its new host Worker and deploy that Worker first. ` + + "When both Workers deploy from the same stack, reference the host by resource (e.g. scriptName: hostWorker.workerName) so Alchemy can infer the data-preserving transfer. " + + `For cross-stack moves, set transferredFrom: "${this.scriptName}" on the Durable Object declaration in its new host Worker and deploy that Worker first. ` + "To abandon the data, remove the binding entirely in one deploy before re-adding it as a cross-script reference." ); } } +/** + * More than one script in this stack + stage qualifies as the source of an + * inferred Durable Object transfer (e.g. an orphaned script left behind by a + * `name` prop change still carries the same alchemy tags). Alchemy refuses to + * guess which namespace's data to move — set `transferredFrom` on the Durable + * Object declaration to name the source script explicitly. + */ +export class AmbiguousDurableObjectTransfer extends Data.TaggedError( + "AmbiguousDurableObjectTransfer", +)<{ + scriptName: string; + logicalId: string; + className: string; + sources: string[]; +}> { + override get message() { + return ( + `Durable Object '${this.logicalId}' (class '${this.className}') is new to Worker '${this.scriptName}' and multiple scripts qualify as its transfer source: ${this.sources.join(", ")}. ` + + `Set transferredFrom: "" on the Durable Object declaration to name the source explicitly.` + ); + } +} + /** * Resolve the Workers for Platforms dispatch-namespace *name* from a resolved * `namespace` prop or persisted attribute. The engine resolves a passed @@ -927,6 +955,87 @@ export const LiveWorkerProvider = () => return createAlchemyWorkerTags(id).every((tag) => actualTags.has(tag)); }; + /** + * Resolve the source script of an inferred Durable Object transfer for + * a class that is new to `selfScriptName`. Transfer hints (see + * `registerDoTransferHint`) name the *worker* — by logical id — whose + * cross-script declaration points at this script; the source *script* + * is discovered from authoritative cloud state: it must currently host + * the class and carry the alchemy tags proving it is that worker in + * this stack + stage and hosted this exact Durable Object + * (`alchemy:do:{logicalId}:{className}`). The tag match is what keeps + * inference safe: it distinguishes "the worker that used to host this + * DO now points at you" from unrelated same-name classes on other + * scripts (other stages, or the two-hosts-two-namespaces pattern). + * Returns `undefined` when nothing matches — fresh stage, transfer + * already completed, or a foreign class — and the caller creates the + * class fresh. + */ + const findDoTransferSource = Effect.fn(function* (params: { + accountId: string; + selfScriptName: string; + logicalId: string; + className: string; + hints: readonly { + logicalId: string; + className: string; + fromWorkerId: string; + }[]; + observedNamespaces: readonly { script: string; class: string }[]; + }) { + const hints = params.hints.filter( + (hint) => + hint.logicalId === params.logicalId && + hint.className === params.className, + ); + if (hints.length === 0) { + return undefined; + } + const candidates = Array.from( + new Set( + params.observedNamespaces.flatMap((ns) => + ns.class === params.className && + ns.script !== params.selfScriptName + ? [ns.script] + : [], + ), + ), + ); + const sources: string[] = []; + for (const script of candidates) { + const settings = yield* getScriptSettings( + params.accountId, + script, + undefined, + ).pipe( + Effect.catchTag("WorkerNotFound", () => Effect.succeed(undefined)), + Effect.catchTag("WorkerHasNoVersions", () => + Effect.succeed(undefined), + ), + ); + const tags = new Set(settings?.tags ?? []); + if ( + tags.has(`alchemy:stack:${stack.name}`) && + tags.has(`alchemy:stage:${stack.stage}`) && + tags.has(`alchemy:do:${params.logicalId}:${params.className}`) && + hints.some((hint) => tags.has(`alchemy:id:${hint.fromWorkerId}`)) + ) { + sources.push(script); + } + } + if (sources.length > 1) { + return yield* Effect.fail( + new AmbiguousDurableObjectTransfer({ + scriptName: params.selfScriptName, + logicalId: params.logicalId, + className: params.className, + sources, + }), + ); + } + return sources[0]; + }); + const getDurableObjects = ( bindings: readonly WorkerSettingsBinding[] | null | undefined, ) => { @@ -1404,6 +1513,13 @@ export const LiveWorkerProvider = () => // Parse alchemy:do:{logicalId}:{className} tags const oldDoClassNameByLogicalId = getDurableObjectTagMap(oldTags); const currentDoBindings = getDurableObjectBindings(bindings, name); + // Transfer hints bound onto this worker by *other* workers' + // cross-script DO declarations (see registerDoTransferHint) — the + // signal that a class new to this script may be a host move rather + // than a fresh namespace. + const doTransferHints = bindings.flatMap( + (b) => b.data.doTransferHints ?? [], + ); const currentDoClassNameByLogicalId = Object.fromEntries( currentDoBindings.map((binding) => [ binding.logicalId, @@ -1479,8 +1595,13 @@ export const LiveWorkerProvider = () => // namespaces don't surface on the account-level list. const mayTransferIn = currentDoBindings.some( (binding) => - binding.transferredFrom && - !oldDoClassNameByLogicalId[binding.logicalId], + !oldDoClassNameByLogicalId[binding.logicalId] && + (binding.transferredFrom !== undefined || + doTransferHints.some( + (hint) => + hint.logicalId === binding.logicalId && + hint.className === binding.className, + )), ); const observedNamespaces = !dispatchNamespace && @@ -1590,20 +1711,40 @@ export const LiveWorkerProvider = () => } } if (!previousClassName) { - if ( - binding.transferredFrom && - !dispatchNamespace && - scriptHostsClass(binding.transferredFrom, binding.className) - ) { - // Data-preserving move: the source script still hosts the - // namespace, so ship Cloudflare's `transferred_classes` - // migration instead of creating a fresh class. When the source - // doesn't host it (fresh stage, or the transfer already ran), - // fall through to a plain create — `transferredFrom` is inert - // then and safe to leave in place. + // A class new to this script may be a host move rather than a + // fresh namespace. The source is inferred from transfer hints + // (the former host's cross-script declaration points here) — + // or named explicitly via `transferredFrom` for cross-stack + // moves the plan can't see. Either way the source must be + // observed to still host the namespace; otherwise (fresh stage, + // transfer already completed) fall through to a plain create. + let fromScript: string | undefined; + if (!dispatchNamespace) { + if (binding.transferredFrom !== undefined) { + fromScript = scriptHostsClass( + binding.transferredFrom, + binding.className, + ) + ? binding.transferredFrom + : undefined; + } else { + fromScript = yield* findDoTransferSource({ + accountId, + selfScriptName: name, + logicalId: binding.logicalId, + className: binding.className, + hints: doTransferHints, + observedNamespaces, + }); + } + } + if (fromScript !== undefined) { + // Data-preserving move: ship Cloudflare's + // `transferred_classes` migration instead of creating a + // fresh class. transferredClasses.push({ from: binding.className, - fromScript: binding.transferredFrom, + fromScript, to: binding.className, }); continue; @@ -2176,17 +2317,52 @@ export const LiveWorkerProvider = () => const exportDerived = Object.keys(exportMap) .filter((logicalId) => isDurableObjectExport(exportMap[logicalId])) .map((logicalId) => ({ logicalId, className: logicalId })); - // Transfer-marked classes are excluded from the placeholder + // Transfer-destination classes are excluded from the placeholder // entirely (class list, bindings, tags): Cloudflare forbids // creating the destination class of a `transferred_classes` // migration ahead of the transfer, so `reconcile` must perform it - // with the real upload. (`transferredFrom` may still be an - // unresolved Output here — precreate runs on raw props — but only - // its presence matters.) - const durableObjects = mergeDurableObjectClasses( + // with the real upload. Explicitly-marked classes + // (`transferredFrom` — may still be an unresolved Output here, + // precreate runs on raw props, but only its presence matters) are + // excluded outright; hinted classes are excluded only when the + // inferred source is confirmed to still host the namespace. + // Precreate only runs for creates/replaces (no prior state), so + // the observation costs nothing in steady state. + let durableObjects = mergeDurableObjectClasses( exportDerived, getDurableObjectBindings(bindings, name), ).filter((binding) => !binding.transferredFrom); + const doTransferHints = bindings.flatMap( + (b) => b.data.doTransferHints ?? [], + ); + const hinted = durableObjects.filter((binding) => + doTransferHints.some( + (hint) => + hint.logicalId === binding.logicalId && + hint.className === binding.className, + ), + ); + if (hinted.length > 0) { + const observedNamespaces = + yield* listDurableObjectNamespaces(accountId); + const transferring = new Set(); + for (const binding of hinted) { + const source = yield* findDoTransferSource({ + accountId, + selfScriptName: name, + logicalId: binding.logicalId, + className: binding.className, + hints: doTransferHints, + observedNamespaces, + }); + if (source !== undefined) { + transferring.add(binding.className); + } + } + durableObjects = durableObjects.filter( + (binding) => !transferring.has(binding.className), + ); + } const doClasses = durableObjects.map((binding) => binding.className); // Only attach container metadata for classes actually fronted by a // Container binding (mirrors reconcile's `containerClassNames`). diff --git a/packages/alchemy/test/Cloudflare/Workers/DurableObjectNamespace.test.ts b/packages/alchemy/test/Cloudflare/Workers/DurableObjectNamespace.test.ts index 7b291acde..e00b646a1 100644 --- a/packages/alchemy/test/Cloudflare/Workers/DurableObjectNamespace.test.ts +++ b/packages/alchemy/test/Cloudflare/Workers/DurableObjectNamespace.test.ts @@ -543,19 +543,22 @@ test.provider( { timeout: 120_000 }, ); -// #799: move a Durable Object class from one Worker to another with -// `transferredFrom`. The destination's deploy carries Cloudflare's -// data-preserving `transferred_classes` migration, and the former host's -// deploy converges (no `deleted_classes` for a class that moved away). +// #799: moving a Durable Object class from one Worker to another is +// *inferred* — no extra configuration. Worker-b's cross-script declaration +// (`scriptName: a.workerName`) carries resource provenance, so at plan time +// it binds a transfer hint onto worker-a. Worker-a's deploy sees a class +// that is new to it, resolves the hint against observed cloud state +// (worker-b still hosts the namespace and carries the matching +// `alchemy:do:` / `alchemy:id:` tags), and ships Cloudflare's +// data-preserving `transferred_classes` migration. Worker-b's deploy +// converges on its own (no `deleted_classes` for a class that moved away). // // v1 — worker-b hosts `Counter` locally; write data into the namespace -// v2 — worker-a hosts `Counter` with `transferredFrom: `; -// worker-b re-binds the same class cross-script. The stored data -// must survive the move and be reachable from both workers. -// v3 — redeploy of the same shape is inert (`transferredFrom` stays in -// the code; the transfer must not be re-attempted). +// v2 — worker-a hosts `Counter`; worker-b re-binds it cross-script. +// The stored data must survive the move, reachable from both. +// v3 — redeploy of the same shape is inert (no re-transfer). test.provider( - "transferredFrom moves a durable object class between workers preserving data", + "durable object host move is inferred and preserves data", (scratch) => Effect.gen(function* () { yield* scratch.destroy(); @@ -580,22 +583,20 @@ test.provider( ); expect(written.value).toBe(1); - // `transferredFrom` is the *physical* script name of the former host. - const bScriptName = v1.b.workerName; - const moved = Effect.gen(function* () { const a = yield* Cloudflare.Worker("worker-a", { script: hostWorkerScript, env: { - Counter: Cloudflare.DurableObject("Counter", { - transferredFrom: bScriptName, - }), + // No transfer configuration — the move is inferred. + Counter: Cloudflare.DurableObject("Counter"), }, }); const b = yield* Cloudflare.Worker("worker-b", { script: consumerWorkerScript, env: { Counter: Cloudflare.DurableObject("Counter", { + // `a.workerName` carries resource provenance, which is what + // lets the plan hint worker-a about the incoming transfer. scriptName: a.workerName, }), }, @@ -617,7 +618,7 @@ test.provider( expect(viaB.value).toBe(2); // Redeploying the same shape is inert: the class now lives on - // worker-a (tracked by its alchemy:do tag), so `transferredFrom` + // worker-a (tracked by its alchemy:do tag), so the steady-state hint // must not re-emit a transfer migration. Data still intact. const v3 = yield* scratch.deploy(moved); const afterRedeploy = yield* fetchJsonReady<{ value: number }>( @@ -630,13 +631,84 @@ test.provider( { timeout: 240_000 }, ); -// #799 (safety interlock): the same local → cross-script move WITHOUT +// #799: when the plan can't see the move — a raw-string `scriptName` here, +// standing in for a cross-stack move where the new host deploys from a +// different plan entirely — `transferredFrom` names the source script +// explicitly and drives the same data-preserving transfer. +test.provider( + "transferredFrom explicitly transfers when the plan can't infer the move", + (scratch) => + Effect.gen(function* () { + yield* scratch.destroy(); + + // v1: worker-b hosts the class; worker-a exists but has no DO yet — + // deploying it now is what makes its physical name knowable as a raw + // string in v2 (the cross-stack shape). + const v1 = yield* scratch.deploy( + Effect.gen(function* () { + return { + a: yield* Cloudflare.Worker("worker-a", { + script: `export default { fetch() { return new Response("plain"); } };`, + }), + b: yield* Cloudflare.Worker("worker-b", { + script: hostWorkerScript, + env: { + Counter: Cloudflare.DurableObject("Counter"), + }, + }), + }; + }), + ); + + yield* fetchJsonReady<{ ok: boolean }>(`${v1.b.url}/reset`); + const written = yield* fetchJsonReady<{ value: number }>( + `${v1.b.url}/increment`, + ); + expect(written.value).toBe(1); + + const aScriptName = v1.a.workerName; + const bScriptName = v1.b.workerName; + + const v2 = yield* scratch.deploy( + Effect.gen(function* () { + const a = yield* Cloudflare.Worker("worker-a", { + script: hostWorkerScript, + env: { + Counter: Cloudflare.DurableObject("Counter", { + transferredFrom: bScriptName, + }), + }, + }); + const b = yield* Cloudflare.Worker("worker-b", { + script: consumerWorkerScript, + env: { + // Service binding orders worker-b after worker-a (the raw + // string scriptName below carries no dependency edge). + A: a, + Counter: Cloudflare.DurableObject("Counter", { + scriptName: aScriptName, + }), + }, + }); + return { a, b }; + }), + ); + + const viaA = yield* fetchJsonReady<{ value: number }>(`${v2.a.url}/get`); + expect(viaA.value).toBe(1); + + yield* scratch.destroy(); + }).pipe(logLevel), + { timeout: 240_000 }, +); + +// #799 (safety interlock): the same raw-string move WITHOUT // `transferredFrom` must fail loudly before the former host uploads -// anything. The namespace (and its data) still lives on worker-b; deleting -// the class would destroy it, and Cloudflare rejects a single upload that -// deletes a class while a binding still references its class name. +// anything — the plan has no provenance to infer from, the namespace (and +// its data) still lives on worker-b, and deleting the class would destroy +// it. test.provider( - "moving a class cross-script without transferredFrom fails with DurableObjectTransferRequired", + "unresolvable host move fails with DurableObjectTransferRequired", (scratch) => Effect.gen(function* () { yield* scratch.destroy(); @@ -644,6 +716,9 @@ test.provider( const v1 = yield* scratch.deploy( Effect.gen(function* () { return { + a: yield* Cloudflare.Worker("worker-a", { + script: `export default { fetch() { return new Response("plain"); } };`, + }), b: yield* Cloudflare.Worker("worker-b", { script: hostWorkerScript, env: { @@ -659,14 +734,16 @@ test.provider( yield* fetchJsonReady<{ ok: boolean }>(`${v1.b.url}/reset`); yield* fetchJsonReady<{ value: number }>(`${v1.b.url}/increment`); + const aScriptName = v1.a.workerName; + const error = yield* scratch .deploy( Effect.gen(function* () { const a = yield* Cloudflare.Worker("worker-a", { script: hostWorkerScript, env: { - // No `transferredFrom` — worker-a creates a fresh, empty - // namespace for the same class name. + // Fresh, empty namespace for the same class name — no + // transfer configuration and no provenance to infer from. Counter: Cloudflare.DurableObject("Counter"), }, }); @@ -674,7 +751,8 @@ test.provider( script: consumerWorkerScript, env: { Counter: Cloudflare.DurableObject("Counter", { - scriptName: a.workerName, + // Raw string: no resource provenance, no hint. + scriptName: aScriptName, }), }, }); From dd0dafadfb7828ee7e20f900e2cb0c5e25dd9b88 Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Fri, 10 Jul 2026 21:50:43 -0700 Subject: [PATCH 3/6] feat(cloudflare/workers): always-explicit DO class transfers with host history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/Cloudflare/Workers/DurableObject.ts | 184 ++++++--------- .../alchemy/src/Cloudflare/Workers/Worker.ts | 17 -- .../Cloudflare/Workers/WorkerAsyncBindings.ts | 16 +- .../src/Cloudflare/Workers/WorkerBinding.ts | 11 +- .../src/Cloudflare/Workers/WorkerProvider.ts | 219 +++++++----------- .../Workers/DurableObjectNamespace.test.ts | 64 ++--- 6 files changed, 190 insertions(+), 321 deletions(-) diff --git a/packages/alchemy/src/Cloudflare/Workers/DurableObject.ts b/packages/alchemy/src/Cloudflare/Workers/DurableObject.ts index a98ef66c5..a990277cc 100644 --- a/packages/alchemy/src/Cloudflare/Workers/DurableObject.ts +++ b/packages/alchemy/src/Cloudflare/Workers/DurableObject.ts @@ -22,12 +22,7 @@ import { } from "./DurableObjectState.ts"; import { makeRpcStub } from "./Rpc.ts"; import { type WebSocket } from "./WebSocket.ts"; -import { - isWorker, - Worker, - WorkerEnvironment, - type WorkerServices, -} from "./Worker.ts"; +import { Worker, WorkerEnvironment, type WorkerServices } from "./Worker.ts"; export interface DurableObjectExport { readonly kind: "durableObject"; @@ -67,7 +62,7 @@ export interface DurableObjectLike { /** @internal phantom */ scriptName?: Input; /** @internal phantom */ - transferredFrom?: Input; + transferredFrom?: Input; /** @internal phantom */ Shape?: Shape; } @@ -128,23 +123,25 @@ export interface DurableObjectProps { */ scriptName?: Input | undefined; /** - * Name of the Worker script that previously hosted this Durable Object - * class. When set, the deploy performs Cloudflare's data-preserving - * `transferred_classes` migration, moving the namespace — including all - * stored objects — from that script to this Worker. + * The Worker(s) that previously hosted this Durable Object class. When one + * of them still holds the namespace, the deploy performs Cloudflare's + * data-preserving `transferred_classes` migration, moving the namespace — + * including all stored objects — from that script to this Worker. * - * Only needed when the move can't be inferred from the plan — i.e. the - * former host deploys from a *different stack* (or references this Worker - * by a raw string). When both Workers deploy together and the former host - * references this one by resource (`scriptName: worker.workerName`, - * `Counter.from(Worker)`), the transfer is inferred automatically and - * this property is unnecessary. + * Each entry names a former host either by its Worker **logical id** in + * this stack + stage (resolved via alchemy's ownership tags) or by its + * **physical script name** (required for cross-stack moves). Moving a + * class is always declared this way — there is no inference. A list is a + * host *history*: as the class moves again, append the previous host so + * stages that lag behind (or skipped an intermediate release) still + * transfer from wherever their namespace currently lives. * - * Once the transfer has completed, or when the source script does not host - * the class (e.g. on a fresh stage), the property is inert: the class is - * created fresh or left as-is, so it is safe to leave in place. + * When no listed host holds the namespace — a fresh stage, or every + * transfer already completed — the property is inert and the class is + * created fresh (or left as-is), so it is safe to leave in place + * indefinitely. */ - transferredFrom?: Input | undefined; + transferredFrom?: Input | undefined; // environment?: string | undefined; // sqlite?: boolean | undefined; // namespaceId?: string | undefined; @@ -224,58 +221,6 @@ export class DurableObjectScope extends Context.Service< DurableObject >()("Cloudflare.DurableObject") {} -/** - * Recover the target Worker resource from a cross-script `scriptName` value - * at plan time. Covers the shapes that reference a Worker in the same plan: - * the resource itself, or its `workerName` output (`scriptName: - * host.workerName`, which is also what `.from(WorkerA)` passes). Raw strings - * and mapped/foreign Outputs (e.g. cross-stack `stackRef`s) carry no resource - * provenance and return `undefined` — no transfer can be inferred for them. - * - * @internal - */ -const workerFromScriptName = (scriptName: unknown): Worker | undefined => { - if (isWorker(scriptName)) { - return scriptName; - } - if ( - Output.isPropExpr(scriptName) && - scriptName.identifier === "workerName" && - Output.isResourceExpr(scriptName.expr) && - isWorker(scriptName.expr.src) - ) { - return scriptName.expr.src; - } - return undefined; -}; - -/** - * Attach a Durable Object transfer hint onto the target of a cross-script DO - * declaration. The hint — pure literals, so it adds no dependency edges — - * tells the target's provider that `self` references `(logicalId, className)` - * on it. If the target is newly hosting that class while `self`'s script - * still holds the namespace (verified against `alchemy:id:` / `alchemy:do:` - * tags and observed namespace ownership), the target's deploy infers - * Cloudflare's data-preserving `transferred_classes` migration — the - * cross-worker analog of how renames are inferred within one worker. - * - * @internal - */ -export const registerDoTransferHint = Effect.fn(function* ( - self: Worker, - scriptName: unknown, - logicalId: string, - className: string, -) { - const target = workerFromScriptName(scriptName); - if (target === undefined || target.FQN === self.FQN) { - return; - } - yield* target.bind`${logicalId}:transfer-hint`({ - doTransferHints: [{ logicalId, className, fromWorkerId: self.LogicalId }], - }); -}); - /** * A Cloudflare Durable Object namespace that manages globally unique, stateful * instances with WebSocket hibernation support. @@ -867,19 +812,24 @@ export const registerDoTransferHint = Effect.fn(function* ( * * @section Moving a Class Between Workers * A Durable Object class can move from one Worker to another with its - * data intact — and when both Workers deploy from the same stack, the - * move is **inferred**, exactly like class renames. Keep the Durable - * Object's name stable, host it on the new Worker, and have the former - * host reference it cross-script (`scriptName: newHost.workerName`, or - * `Counter.from(NewHost)` in the Effect-native form). At plan time the - * cross-script declaration carries the target's resource provenance, so - * the new host's deploy knows the class is arriving from the former - * host, verifies the namespace still lives there, and ships Cloudflare's + * data intact. The move is always **declared** — a class that disappears + * from one worker and appears on another is otherwise ambiguous between + * "transfer the data" and "delete it, start fresh", so Alchemy never + * guesses (removing a DO deletes it; that is the default). Declare + * `transferredFrom` on the Durable Object at its **new host**, naming the + * former host, and the new host's deploy ships Cloudflare's * data-preserving `transferred_classes` migration. The former host's * deploy converges on its own — no delete migration is emitted for a * class that moved away. * - * @example Move a class by moving where it's hosted + * Each `transferredFrom` entry is either the former host's Worker + * **logical id** (same stack + stage, resolved via alchemy's ownership + * tags) or its **physical script name** (required for cross-stack moves). + * When no listed host holds the namespace — a fresh stage, or the + * transfer already completed — the declaration is inert, so it is safe to + * leave in place indefinitely. + * + * @example Move a class from WorkerB to WorkerA * ```typescript * // BEFORE: worker-b hosts the class * const b = yield* Cloudflare.Worker("WorkerB", { @@ -889,12 +839,15 @@ export const registerDoTransferHint = Effect.fn(function* ( * }, * }); * - * // AFTER: worker-a hosts it; worker-b keeps a cross-script reference. - * // Nothing else to declare — the transfer is inferred and the data moves. + * // 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" }), + * MyDO: Cloudflare.DurableObject("MyDO", { + * className: "MyDOClass", + * transferredFrom: "WorkerB", // logical id (or its script name) + * }), * }, * }); * const b = yield* Cloudflare.Worker("WorkerB", { @@ -902,36 +855,40 @@ export const registerDoTransferHint = Effect.fn(function* ( * bindings: { * MyDO: Cloudflare.DurableObject("MyDO", { * className: "MyDOClass", - * scriptName: a.workerName, // provenance ⇒ transfer is inferred + * scriptName: a.workerName, * }), * }, * }); * ``` * - * When the move spans two stacks — or the cross-script `scriptName` is a - * raw string with no resource provenance — the plan cannot see both - * sides, so nothing can be inferred. The deploy then fails before any - * upload with `DurableObjectTransferRequired` (Durable Object data does - * not move on its own, and deleting the class would destroy it). Name - * the source explicitly with `transferredFrom` on the Durable Object at - * its **new host** and deploy that stack first; once the transfer has - * completed — or on a fresh stage — the property is inert and safe to - * leave in place. - * - * @example Cross-stack move: name the source explicitly + * Forgetting the declaration is safe: when the former host still + * references the class cross-script, its deploy fails before any upload + * with `DurableObjectTransferRequired`, telling you exactly what to + * declare — data is never silently destroyed or forked. + * + * @example Chained moves keep the host history * ```typescript - * // stack-a alchemy.run.ts — the NEW host, deployed from its own stack - * const workerA = yield* Cloudflare.Worker("WorkerA", { - * main: "./src/worker-a.ts", // exports MyDOClass - * bindings: { - * MyDO: Cloudflare.DurableObject("MyDO", { - * className: "MyDOClass", - * transferredFrom: "worker-b", // script that used to host it - * }), - * }, - * }); + * // The class moved WorkerB → WorkerA last release and WorkerA → + * // WorkerC this release. Keep the full history so a stage that lagged + * // behind (or skipped the intermediate release) still transfers from + * // wherever its namespace currently lives. + * Cloudflare.DurableObject("MyDO", { + * className: "MyDOClass", + * transferredFrom: ["WorkerB", "WorkerA"], + * }) * ``` * + * Two rules for multi-worker migrations: + * + * - **Pure moves (the former host drops the DO entirely, keeping no + * cross-script reference) must be two deploys**: first add the class to + * the new host with `transferredFrom` and deploy; then remove it from + * the former host and deploy. In a single deploy nothing orders the + * transfer before the former host's delete. + * - **Cross-stack moves deploy the new host's stack first**, naming the + * former host by physical script name; the former host's stack deploys + * after and converges. + * * @section Adopting an Existing Durable Object * When you adopt a Worker that already exists on Cloudflare — created * outside Alchemy via Wrangler, the dashboard, or the raw API — its @@ -1015,7 +972,7 @@ export const DurableObject: DurableObjectClass = taggedFunction( const binding = ( scriptName?: Input, - transferredFrom?: Input, + transferredFrom?: Input, ) => Effect.gen(function* () { const worker = yield* Worker; @@ -1032,17 +989,6 @@ export const DurableObject: DurableObjectClass = taggedFunction( ], }); - if (scriptName !== undefined) { - // Cross-script reference: hint the target so it can infer a - // data-preserving transfer when this class is moving host. - yield* registerDoTransferHint( - worker, - scriptName, - namespace, - namespace, - ); - } - const binding = yield* Effect.all([ WorkerEnvironment, ALCHEMY_PHASE, diff --git a/packages/alchemy/src/Cloudflare/Workers/Worker.ts b/packages/alchemy/src/Cloudflare/Workers/Worker.ts index 20a4329e9..3b9ed49aa 100644 --- a/packages/alchemy/src/Cloudflare/Workers/Worker.ts +++ b/packages/alchemy/src/Cloudflare/Workers/Worker.ts @@ -642,23 +642,6 @@ export type Worker = Resource< }, { bindings?: WorkerBinding[]; - /** - * Durable Object transfer hints contributed by *other* Workers' - * cross-script DO declarations. When Worker B re-binds a Durable Object - * it used to host as a cross-script reference to Worker A, B's - * declaration binds `{ logicalId, className, fromWorkerId: B }` onto A - * at plan time. A's reconcile uses the hint to infer Cloudflare's - * data-preserving `transferred_classes` migration: a class that is new - * to A whose hint resolves (via `alchemy:id:` / `alchemy:do:` tags, - * same stack + stage) to a script currently hosting the namespace is - * transferred instead of created fresh. Pure literals — hints never - * carry Outputs, so they add no dependency edges. - */ - doTransferHints?: { - logicalId: string; - className: string; - fromWorkerId: string; - }[]; /** * Workers Cache settings contributed by `yield* Cloudflare.cache()`. * Merged into the upload metadata's `cache_options`; an explicit diff --git a/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts b/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts index ca05e7a58..ef278c6d7 100644 --- a/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts +++ b/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts @@ -24,10 +24,7 @@ import { isDispatchNamespace } from "../WorkersForPlatforms/DispatchNamespace.ts import { isWorkflowLike, WorkflowResource } from "../Workflows/Workflow.ts"; import { isAssets } from "./Assets.ts"; import { isBrowser } from "./Browser.ts"; -import { - isDurableObjectLike, - registerDoTransferHint, -} from "./DurableObject.ts"; +import { isDurableObjectLike } from "./DurableObject.ts"; import { isRateLimit } from "./RateLimit.ts"; import { isVersionMetadata } from "./VersionMetadata.ts"; import type { WorkerBindingProps } from "./Worker.ts"; @@ -69,17 +66,6 @@ export const bindWorkerAsyncBindings = Effect.fn(function* ( : undefined, }); - if (isDurableObjectLike(binding) && binding.scriptName !== undefined) { - // Cross-script DO reference: hint the target so it can infer a - // data-preserving transfer when this class is moving host. - yield* registerDoTransferHint( - resource, - binding.scriptName, - bindingName, - binding.className ?? binding.name, - ); - } - // A locally-hosted Workflow (no `scriptName`) must be registered with // Cloudflare via `putWorkflow` once the host Worker exists. Cross-script // references (with `scriptName`) are reference-only — the host owns the diff --git a/packages/alchemy/src/Cloudflare/Workers/WorkerBinding.ts b/packages/alchemy/src/Cloudflare/Workers/WorkerBinding.ts index 35497c766..f55c361f3 100644 --- a/packages/alchemy/src/Cloudflare/Workers/WorkerBinding.ts +++ b/packages/alchemy/src/Cloudflare/Workers/WorkerBinding.ts @@ -38,16 +38,17 @@ type DistilledWorkerBinding = Exclude< /** * The `durable_object_namespace` metadata binding extended with alchemy-only - * transfer metadata. `transferredFrom` names the Worker script that - * previously hosted the class so the provider can drive Cloudflare's - * data-preserving `transferred_classes` migration. It is stripped from the - * binding before the script upload — Cloudflare never sees it. + * transfer metadata. `transferredFrom` names the Worker(s) — by logical id in + * this stack + stage, or by physical script name — that previously hosted the + * class, so the provider can drive Cloudflare's data-preserving + * `transferred_classes` migration. It is stripped from the binding before the + * script upload — Cloudflare never sees it. */ export type DurableObjectNamespaceWorkerBinding = Extract< DistilledWorkerBinding, { type: "durable_object_namespace" } > & { - transferredFrom?: string; + transferredFrom?: string | string[]; }; export type WorkerBinding = diff --git a/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts b/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts index 58390c568..4eabfdfe2 100644 --- a/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts +++ b/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts @@ -47,16 +47,13 @@ class MissingDurableObjects extends Data.TaggedError("MissingDurableObjects")<{ * destroy the namespace's data irreversibly, so the deploy fails before any * upload. * - * When both Workers are in the same stack and the cross-script `scriptName` - * carries resource provenance (the Worker resource or its `workerName` - * output, including `.from(WorkerA)`), Alchemy infers the data-preserving - * `transferred_classes` migration automatically and this error never fires. - * It fires when the move can't be seen in the plan — a cross-stack move, or a - * `scriptName` passed as a raw string. To move the data then, declare - * `transferredFrom` on the Durable Object at its new host and deploy that - * Worker first. To abandon the data instead, remove the binding entirely in - * one deploy (which deletes the class and its data), then add the - * cross-script binding in a second deploy. + * Moving a Durable Object class between Workers is always declared: set + * `transferredFrom` on the Durable Object at its **new host** — naming the + * former host by Worker logical id (same stack) or physical script name — and + * Alchemy performs the data-preserving `transferred_classes` migration on the + * new host's deploy; this deploy then converges on its own. To abandon the + * data instead, remove the binding entirely in one deploy (which deletes the + * class and its data), then add the cross-script binding in a second deploy. */ export class DurableObjectTransferRequired extends Data.TaggedError( "DurableObjectTransferRequired", @@ -70,19 +67,19 @@ export class DurableObjectTransferRequired extends Data.TaggedError( `Durable Object class '${this.className}' still lives on Worker '${this.scriptName}' but this deploy re-binds it as a cross-script reference` + (this.targetScriptName ? ` to '${this.targetScriptName}'` : "") + ". Durable Object data does NOT move with the class automatically. " + - "When both Workers deploy from the same stack, reference the host by resource (e.g. scriptName: hostWorker.workerName) so Alchemy can infer the data-preserving transfer. " + - `For cross-stack moves, set transferredFrom: "${this.scriptName}" on the Durable Object declaration in its new host Worker and deploy that Worker first. ` + + `To move the data, set transferredFrom: "${this.scriptName}" (the former host's script name, or its Worker logical id for same-stack moves) on the Durable Object declaration in its new host Worker. ` + "To abandon the data, remove the binding entirely in one deploy before re-adding it as a cross-script reference." ); } } /** - * More than one script in this stack + stage qualifies as the source of an - * inferred Durable Object transfer (e.g. an orphaned script left behind by a - * `name` prop change still carries the same alchemy tags). Alchemy refuses to - * guess which namespace's data to move — set `transferredFrom` on the Durable - * Object declaration to name the source script explicitly. + * More than one script matches the `transferredFrom` declaration of a Durable + * Object (e.g. an orphaned script left behind by a `name` prop change still + * carries the same alchemy tags, or the host history lists several scripts + * that each still hold a same-class namespace). Alchemy refuses to guess + * which namespace's data to move — narrow the declaration to the exact + * physical script name that holds the data. */ export class AmbiguousDurableObjectTransfer extends Data.TaggedError( "AmbiguousDurableObjectTransfer", @@ -94,8 +91,8 @@ export class AmbiguousDurableObjectTransfer extends Data.TaggedError( }> { override get message() { return ( - `Durable Object '${this.logicalId}' (class '${this.className}') is new to Worker '${this.scriptName}' and multiple scripts qualify as its transfer source: ${this.sources.join(", ")}. ` + - `Set transferredFrom: "" on the Durable Object declaration to name the source explicitly.` + `Durable Object '${this.logicalId}' (class '${this.className}') is new to Worker '${this.scriptName}' and multiple scripts match its transferredFrom declaration: ${this.sources.join(", ")}. ` + + `Narrow transferredFrom to the exact physical script name that holds the data.` ); } } @@ -956,39 +953,28 @@ export const LiveWorkerProvider = () => }; /** - * Resolve the source script of an inferred Durable Object transfer for - * a class that is new to `selfScriptName`. Transfer hints (see - * `registerDoTransferHint`) name the *worker* — by logical id — whose - * cross-script declaration points at this script; the source *script* - * is discovered from authoritative cloud state: it must currently host - * the class and carry the alchemy tags proving it is that worker in - * this stack + stage and hosted this exact Durable Object - * (`alchemy:do:{logicalId}:{className}`). The tag match is what keeps - * inference safe: it distinguishes "the worker that used to host this - * DO now points at you" from unrelated same-name classes on other - * scripts (other stages, or the two-hosts-two-namespaces pattern). - * Returns `undefined` when nothing matches — fresh stage, transfer - * already completed, or a foreign class — and the caller creates the - * class fresh. + * Resolve the source script of a declared Durable Object transfer for + * a class that is new to `selfScriptName`. `sources` is the + * `transferredFrom` host history: each entry names a former host either + * by *physical script name* or by *Worker logical id* in this stack + + * stage (matched via the `alchemy:id:`/stack/stage ownership tags). + * The namespace is transferred from whichever listed host currently + * holds a same-class namespace; when none does (fresh stage, or every + * transfer already completed) this returns `undefined` and the caller + * creates the class fresh — the declaration is inert and safe to keep. + * More than one match (e.g. an orphaned script from a `name` change) + * fails with {@link AmbiguousDurableObjectTransfer} rather than + * guessing whose data to move. */ - const findDoTransferSource = Effect.fn(function* (params: { + const resolveTransferSource = Effect.fn(function* (params: { accountId: string; selfScriptName: string; logicalId: string; className: string; - hints: readonly { - logicalId: string; - className: string; - fromWorkerId: string; - }[]; + sources: readonly string[]; observedNamespaces: readonly { script: string; class: string }[]; }) { - const hints = params.hints.filter( - (hint) => - hint.logicalId === params.logicalId && - hint.className === params.className, - ); - if (hints.length === 0) { + if (params.sources.length === 0) { return undefined; } const candidates = Array.from( @@ -1001,8 +987,12 @@ export const LiveWorkerProvider = () => ), ), ); - const sources: string[] = []; + const matched: string[] = []; for (const script of candidates) { + if (params.sources.includes(script)) { + matched.push(script); + continue; + } const settings = yield* getScriptSettings( params.accountId, script, @@ -1017,23 +1007,22 @@ export const LiveWorkerProvider = () => if ( tags.has(`alchemy:stack:${stack.name}`) && tags.has(`alchemy:stage:${stack.stage}`) && - tags.has(`alchemy:do:${params.logicalId}:${params.className}`) && - hints.some((hint) => tags.has(`alchemy:id:${hint.fromWorkerId}`)) + params.sources.some((source) => tags.has(`alchemy:id:${source}`)) ) { - sources.push(script); + matched.push(script); } } - if (sources.length > 1) { + if (matched.length > 1) { return yield* Effect.fail( new AmbiguousDurableObjectTransfer({ scriptName: params.selfScriptName, logicalId: params.logicalId, className: params.className, - sources, + sources: matched, }), ); } - return sources[0]; + return matched[0]; }); const getDurableObjects = ( @@ -1513,13 +1502,6 @@ export const LiveWorkerProvider = () => // Parse alchemy:do:{logicalId}:{className} tags const oldDoClassNameByLogicalId = getDurableObjectTagMap(oldTags); const currentDoBindings = getDurableObjectBindings(bindings, name); - // Transfer hints bound onto this worker by *other* workers' - // cross-script DO declarations (see registerDoTransferHint) — the - // signal that a class new to this script may be a host move rather - // than a fresh namespace. - const doTransferHints = bindings.flatMap( - (b) => b.data.doTransferHints ?? [], - ); const currentDoClassNameByLogicalId = Object.fromEntries( currentDoBindings.map((binding) => [ binding.logicalId, @@ -1596,12 +1578,7 @@ export const LiveWorkerProvider = () => const mayTransferIn = currentDoBindings.some( (binding) => !oldDoClassNameByLogicalId[binding.logicalId] && - (binding.transferredFrom !== undefined || - doTransferHints.some( - (hint) => - hint.logicalId === binding.logicalId && - hint.className === binding.className, - )), + binding.transferredFrom !== undefined, ); const observedNamespaces = !dispatchNamespace && @@ -1711,33 +1688,27 @@ export const LiveWorkerProvider = () => } } if (!previousClassName) { - // A class new to this script may be a host move rather than a - // fresh namespace. The source is inferred from transfer hints - // (the former host's cross-script declaration points here) — - // or named explicitly via `transferredFrom` for cross-stack - // moves the plan can't see. Either way the source must be - // observed to still host the namespace; otherwise (fresh stage, - // transfer already completed) fall through to a plain create. - let fromScript: string | undefined; - if (!dispatchNamespace) { - if (binding.transferredFrom !== undefined) { - fromScript = scriptHostsClass( - binding.transferredFrom, - binding.className, - ) - ? binding.transferredFrom - : undefined; - } else { - fromScript = yield* findDoTransferSource({ + // A class new to this script is a host move when the declaration + // says so: `transferredFrom` lists the former host(s) — moves + // are always declared, never inferred, because a class deleted + // on one worker and created on another is otherwise ambiguous + // between "move the data" and "delete + fresh namespace". The + // declared source must be observed to still host the namespace; + // otherwise (fresh stage, transfer already completed) fall + // through to a plain create. + const fromScript = dispatchNamespace + ? undefined + : yield* resolveTransferSource({ accountId, selfScriptName: name, logicalId: binding.logicalId, className: binding.className, - hints: doTransferHints, + sources: normalizeTransferSources( + binding.transferredFrom, + name, + ), observedNamespaces, }); - } - } if (fromScript !== undefined) { // Data-preserving move: ship Cloudflare's // `transferred_classes` migration instead of creating a @@ -2321,48 +2292,13 @@ export const LiveWorkerProvider = () => // entirely (class list, bindings, tags): Cloudflare forbids // creating the destination class of a `transferred_classes` // migration ahead of the transfer, so `reconcile` must perform it - // with the real upload. Explicitly-marked classes - // (`transferredFrom` — may still be an unresolved Output here, - // precreate runs on raw props, but only its presence matters) are - // excluded outright; hinted classes are excluded only when the - // inferred source is confirmed to still host the namespace. - // Precreate only runs for creates/replaces (no prior state), so - // the observation costs nothing in steady state. - let durableObjects = mergeDurableObjectClasses( + // with the real upload. (`transferredFrom` may still be an + // unresolved Output here — precreate runs on raw props — but only + // its presence matters.) + const durableObjects = mergeDurableObjectClasses( exportDerived, getDurableObjectBindings(bindings, name), ).filter((binding) => !binding.transferredFrom); - const doTransferHints = bindings.flatMap( - (b) => b.data.doTransferHints ?? [], - ); - const hinted = durableObjects.filter((binding) => - doTransferHints.some( - (hint) => - hint.logicalId === binding.logicalId && - hint.className === binding.className, - ), - ); - if (hinted.length > 0) { - const observedNamespaces = - yield* listDurableObjectNamespaces(accountId); - const transferring = new Set(); - for (const binding of hinted) { - const source = yield* findDoTransferSource({ - accountId, - selfScriptName: name, - logicalId: binding.logicalId, - className: binding.className, - hints: doTransferHints, - observedNamespaces, - }); - if (source !== undefined) { - transferring.add(binding.className); - } - } - durableObjects = durableObjects.filter( - (binding) => !transferring.has(binding.className), - ); - } const doClasses = durableObjects.map((binding) => binding.className); // Only attach container metadata for classes actually fronted by a // Container binding (mirrors reconcile's `containerClassNames`). @@ -2889,6 +2825,22 @@ const listDurableObjectNamespaces = (accountId: string) => ), ); +/** + * Coerce a resolved `transferredFrom` declaration to its list form, dropping + * self-references (a worker naming itself as its own former host is + * meaningless — the class already lives there). + */ +const normalizeTransferSources = ( + value: string | readonly string[] | undefined, + selfScriptName: string, +): string[] => + (value === undefined + ? [] + : Array.isArray(value) + ? value + : [value as string] + ).filter((source) => source !== selfScriptName); + function bumpMigrationTagVersion( oldTag: string | undefined, ): string | undefined { @@ -2908,12 +2860,12 @@ function mergeDurableObjectClasses( exportDerived: ReadonlyArray<{ logicalId: string; className: string; - transferredFrom?: string; + transferredFrom?: string | string[]; }>, bindingDerived: ReadonlyArray<{ logicalId: string; className: string; - transferredFrom?: string; + transferredFrom?: string | string[]; }>, ) { return Array.from( @@ -2958,13 +2910,14 @@ function getDurableObjectBindings( logicalId: binding.sid, bindingName: item.name, className: item.className, - // Source script of a data-preserving `transferred_classes` - // migration. A self-reference is meaningless — the class already - // lives here — so normalize it away. + // Declared host history for a data-preserving + // `transferred_classes` migration. Normalize an empty list to + // "not declared"; self-references are dropped at resolution time. transferredFrom: - item.transferredFrom !== workerName - ? item.transferredFrom - : undefined, + Array.isArray(item.transferredFrom) && + item.transferredFrom.length === 0 + ? undefined + : item.transferredFrom, }, ]; }), diff --git a/packages/alchemy/test/Cloudflare/Workers/DurableObjectNamespace.test.ts b/packages/alchemy/test/Cloudflare/Workers/DurableObjectNamespace.test.ts index e00b646a1..911eedd09 100644 --- a/packages/alchemy/test/Cloudflare/Workers/DurableObjectNamespace.test.ts +++ b/packages/alchemy/test/Cloudflare/Workers/DurableObjectNamespace.test.ts @@ -544,21 +544,21 @@ test.provider( ); // #799: moving a Durable Object class from one Worker to another is -// *inferred* — no extra configuration. Worker-b's cross-script declaration -// (`scriptName: a.workerName`) carries resource provenance, so at plan time -// it binds a transfer hint onto worker-a. Worker-a's deploy sees a class -// that is new to it, resolves the hint against observed cloud state -// (worker-b still hosts the namespace and carries the matching -// `alchemy:do:` / `alchemy:id:` tags), and ships Cloudflare's -// data-preserving `transferred_classes` migration. Worker-b's deploy -// converges on its own (no `deleted_classes` for a class that moved away). +// *declared*: the new host names the former host with `transferredFrom` — +// here by Worker *logical id*, resolved to the actual script via alchemy's +// ownership tags (`alchemy:id:` + stack + stage) among scripts observed to +// host the class. Worker-a's deploy ships Cloudflare's data-preserving +// `transferred_classes` migration; worker-b's deploy converges on its own +// (no `deleted_classes` for a class that moved away). // // v1 — worker-b hosts `Counter` locally; write data into the namespace -// v2 — worker-a hosts `Counter`; worker-b re-binds it cross-script. -// The stored data must survive the move, reachable from both. -// v3 — redeploy of the same shape is inert (no re-transfer). +// v2 — worker-a hosts `Counter` with `transferredFrom: "worker-b"`; +// worker-b re-binds it cross-script. The stored data must survive +// the move, reachable from both. +// v3 — redeploy of the same shape is inert (no re-transfer; the +// declaration stays in the code). test.provider( - "durable object host move is inferred and preserves data", + "durable object host move declared by worker logical id preserves data", (scratch) => Effect.gen(function* () { yield* scratch.destroy(); @@ -587,16 +587,17 @@ test.provider( const a = yield* Cloudflare.Worker("worker-a", { script: hostWorkerScript, env: { - // No transfer configuration — the move is inferred. - Counter: Cloudflare.DurableObject("Counter"), + Counter: Cloudflare.DurableObject("Counter", { + // The former host's Worker *logical id* — resolved to its + // physical script via alchemy's ownership tags. + transferredFrom: "worker-b", + }), }, }); const b = yield* Cloudflare.Worker("worker-b", { script: consumerWorkerScript, env: { Counter: Cloudflare.DurableObject("Counter", { - // `a.workerName` carries resource provenance, which is what - // lets the plan hint worker-a about the incoming transfer. scriptName: a.workerName, }), }, @@ -618,8 +619,8 @@ test.provider( expect(viaB.value).toBe(2); // Redeploying the same shape is inert: the class now lives on - // worker-a (tracked by its alchemy:do tag), so the steady-state hint - // must not re-emit a transfer migration. Data still intact. + // worker-a (tracked by its alchemy:do tag), so the standing + // declaration must not re-emit a transfer migration. Data intact. const v3 = yield* scratch.deploy(moved); const afterRedeploy = yield* fetchJsonReady<{ value: number }>( `${v3.a.url}/get`, @@ -631,12 +632,12 @@ test.provider( { timeout: 240_000 }, ); -// #799: when the plan can't see the move — a raw-string `scriptName` here, -// standing in for a cross-stack move where the new host deploys from a -// different plan entirely — `transferredFrom` names the source script -// explicitly and drives the same data-preserving transfer. +// #799: `transferredFrom` also accepts the former host's *physical script +// name* — the form required for cross-stack moves, where the new host +// deploys from a different plan entirely (modeled here with raw-string +// script names on a pre-existing worker). test.provider( - "transferredFrom explicitly transfers when the plan can't infer the move", + "transferredFrom by physical script name transfers the namespace", (scratch) => Effect.gen(function* () { yield* scratch.destroy(); @@ -702,13 +703,13 @@ test.provider( { timeout: 240_000 }, ); -// #799 (safety interlock): the same raw-string move WITHOUT -// `transferredFrom` must fail loudly before the former host uploads -// anything — the plan has no provenance to infer from, the namespace (and -// its data) still lives on worker-b, and deleting the class would destroy -// it. +// #799 (safety interlock): a host move WITHOUT `transferredFrom` must fail +// loudly before the former host uploads anything — moves are never +// inferred, the namespace (and its data) still lives on worker-b, and +// deleting the class would destroy it. The error names the exact +// declaration to add. test.provider( - "unresolvable host move fails with DurableObjectTransferRequired", + "undeclared host move fails with DurableObjectTransferRequired", (scratch) => Effect.gen(function* () { yield* scratch.destroy(); @@ -742,8 +743,8 @@ test.provider( const a = yield* Cloudflare.Worker("worker-a", { script: hostWorkerScript, env: { - // Fresh, empty namespace for the same class name — no - // transfer configuration and no provenance to infer from. + // Fresh, empty namespace for the same class name — the + // move is not declared, so nothing transfers. Counter: Cloudflare.DurableObject("Counter"), }, }); @@ -751,7 +752,6 @@ test.provider( script: consumerWorkerScript, env: { Counter: Cloudflare.DurableObject("Counter", { - // Raw string: no resource provenance, no hint. scriptName: aScriptName, }), }, From a29aaefb284a641fb63b48f7a795339d420b19f4 Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Fri, 10 Jul 2026 22:36:46 -0700 Subject: [PATCH 4/6] docs(cloudflare): document Durable Object host moves in the cross-worker 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 --- .../compute/cross-worker-durable-object.mdx | 166 +++++++++++++++++- .../cloudflare/compute/durable-objects.mdx | 3 +- 2 files changed, 167 insertions(+), 2 deletions(-) diff --git a/website/src/content/docs/cloudflare/compute/cross-worker-durable-object.mdx b/website/src/content/docs/cloudflare/compute/cross-worker-durable-object.mdx index db15a1def..e2440aee0 100644 --- a/website/src/content/docs/cloudflare/compute/cross-worker-durable-object.mdx +++ b/website/src/content/docs/cloudflare/compute/cross-worker-durable-object.mdx @@ -1,6 +1,6 @@ --- title: Bind to another Worker's Durable Object -description: Share a Durable Object across multiple Workers — one Worker hosts the runtime, others bind to it by scriptName for a typed RPC stub. +description: Share a Durable Object across multiple Workers — one Worker hosts the runtime, others bind to it by scriptName for a typed RPC stub — and move the host later with the data intact. sidebar: order: 9 --- @@ -310,6 +310,170 @@ isolated from WorkerA's. This is how you give each script its own private set of DO instances using the same class. ::: +## Move the Durable Object to a new host + +Hosting isn't forever — suppose `Counter` should now live under +`WorkerB` instead of `WorkerA`. A Durable Object class can move +between Workers **with its data intact**: Cloudflare's +`transferred_classes` migration re-homes the namespace (same +instances, same storage) onto the new host's script. Moves are +always *declared* — a class that disappears from one Worker and +appears on another is otherwise ambiguous between "transfer the +data" and "delete it, start fresh" — so the class states where it +came from. + +### Declare where the class came from + +Add `transferredFrom` to the class declaration, naming the former +host: + +```diff lang="typescript" +// src/object.ts + export class Counter extends Cloudflare.DurableObject< + Counter, + { + increment: () => Effect.Effect; + get: () => Effect.Effect; + } +->()("Counter") {} ++>()("Counter", { transferredFrom: "WorkerA" }) {} +``` + +`"WorkerA"` is the former host's Worker **logical id**, resolved to +its physical script through alchemy's ownership tags in the same +stack + stage (for a cross-stack move, use the physical script name +instead). Whichever Worker hosts `Counter` next will transfer the +namespace from it. + +### Make WorkerB the host + +`WorkerB` takes over the host role: it declares `Counter` in its +`Deps`, provides `CounterLive`, and binds with `.from(Self)`: + +```typescript +// src/workerB.ts +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import CounterLive, { Counter } from "./object.ts"; + +export class WorkerB extends Cloudflare.Worker()( + "WorkerB", +) {} + +export default WorkerB.make( + { main: import.meta.url }, + Effect.gen(function* () { + const counters = yield* Counter.from(WorkerB); + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + const name = new URL(request.url, "http://x").pathname.slice(1); + const value = yield* counters.getByName(name).get(); + return HttpServerResponse.json({ value }); + }), + }; + }).pipe(Effect.provide(CounterLive)), +); +``` + +### Turn WorkerA into a consumer + +`WorkerA` stops shipping the runtime and binds the same namespace +cross-script, now hosted by `WorkerB`: + +```diff lang="typescript" +// src/workerA.ts +-import CounterLive, { Counter } from "./object.ts"; ++import { Counter } from "./object.ts"; ++import { WorkerB } from "./workerB.ts"; + +-export class WorkerA extends Cloudflare.Worker()( ++export class WorkerA extends Cloudflare.Worker()( + "WorkerA", + ) {} + + export default WorkerA.make( + { main: import.meta.url }, + Effect.gen(function* () { +- const counters = yield* Counter.from(WorkerA); ++ const counters = yield* Counter.from(WorkerB); + return { + fetch: Effect.gen(function* () { ... }), + }; +- }).pipe(Effect.provide(CounterLive)), ++ }), + ); +``` + +### Provide WorkerB's Layer in the Stack + +The Stack now provides both Layers — `WorkerBLive` carries the DO +runtime, `WorkerALive` no longer does: + +```diff lang="typescript" +// alchemy.run.ts + import WorkerALive, { WorkerA } from "./src/workerA.ts"; +-import WorkerB from "./src/workerB.ts"; ++import WorkerBLive, { WorkerB } from "./src/workerB.ts"; + + Effect.gen(function* () { + const a = yield* WorkerA; + const b = yield* WorkerB; + return { + urlA: a.url.as(), + urlB: b.url.as(), + }; +- }).pipe(Effect.provide(WorkerALive)), ++ }).pipe(Effect.provide([WorkerALive, WorkerBLive])), +``` + +### Deploy the move + +```sh +bun alchemy deploy +``` + +`WorkerA` references `WorkerB` through `Counter.from(WorkerB)`, so +the engine deploys `WorkerB` first. Its upload carries the +`transferred_classes` migration — the namespace and every stored +object move to `WorkerB`'s script — and `WorkerA`'s deploy then +converges on its own (no delete migration is emitted for a class +that moved away). Every counter keeps its count. Afterwards the +`transferredFrom` declaration is inert — on fresh stages and on +every later deploy it does nothing — so leave it in place. + +:::caution[Undeclared moves fail loudly] +If you move the host without declaring `transferredFrom`, the former +host's deploy fails **before any upload** with +`DurableObjectTransferRequired`, telling you exactly what to declare. +Durable Object data is never silently destroyed or forked. +::: + +### Keep the host history for chained moves + +If the class later moves again — say `WorkerB` → `WorkerC` — append +to the declaration rather than replacing it: + +```typescript +// src/object.ts — after a second move +>()("Counter", { transferredFrom: ["WorkerA", "WorkerB"] }) {} +``` + +The namespace transfers from whichever listed host currently holds +it, so a stage that lagged behind (or skipped the intermediate +release) still converges from wherever *its* namespace lives. + +Two rules for less common shapes: + +- **Pure moves** — the former host drops the DO entirely, keeping no + cross-script reference — take two deploys: first add the class to + the new host with `transferredFrom` and deploy, then remove it from + the former host and deploy. +- **Cross-stack moves** name the former host by physical script name, + and the new host's stack deploys first. + ## Closing Two Workers, one DO, type-safe end-to-end. The third type argument diff --git a/website/src/content/docs/cloudflare/compute/durable-objects.mdx b/website/src/content/docs/cloudflare/compute/durable-objects.mdx index 5ad5199ff..13ca9b547 100644 --- a/website/src/content/docs/cloudflare/compute/durable-objects.mdx +++ b/website/src/content/docs/cloudflare/compute/durable-objects.mdx @@ -419,7 +419,8 @@ Guides that build on Durable Objects: - [Accept WebSockets](/cloudflare/compute/hibernatable-websockets) — WebSocket connections that survive hibernation. - [Bind to another Worker's Durable Object](/cloudflare/compute/cross-worker-durable-object) - — one Worker hosts the DO, others get a typed stub. + — one Worker hosts the DO, others get a typed stub; also covers + moving the host to a different Worker with the data intact. - [Run a Container](/cloudflare/compute/run-a-container) — a long-lived container managed by a DO. - [Workflows](/cloudflare/compute/workflows) — durable multi-step From 21b9a8cdb80635b40a619673ee603823e58df6db Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Fri, 10 Jul 2026 23:24:59 -0700 Subject: [PATCH 5/6] feat(cloudflare/workers): accept Worker references in transferredFrom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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()("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 --- .../src/Cloudflare/Workers/DurableObject.ts | 128 ++++++++++++++++-- .../Cloudflare/Workers/WorkerAsyncBindings.ts | 7 +- packages/alchemy/src/Platform.ts | 15 +- .../Workers/DurableObjectNamespace.test.ts | 91 ++++++++++++- .../compute/cross-worker-durable-object.mdx | 16 +++ 5 files changed, 239 insertions(+), 18 deletions(-) diff --git a/packages/alchemy/src/Cloudflare/Workers/DurableObject.ts b/packages/alchemy/src/Cloudflare/Workers/DurableObject.ts index a990277cc..843af40a9 100644 --- a/packages/alchemy/src/Cloudflare/Workers/DurableObject.ts +++ b/packages/alchemy/src/Cloudflare/Workers/DurableObject.ts @@ -22,7 +22,12 @@ import { } from "./DurableObjectState.ts"; import { makeRpcStub } from "./Rpc.ts"; import { type WebSocket } from "./WebSocket.ts"; -import { Worker, WorkerEnvironment, type WorkerServices } from "./Worker.ts"; +import { + isWorker, + Worker, + WorkerEnvironment, + type WorkerServices, +} from "./Worker.ts"; export interface DurableObjectExport { readonly kind: "durableObject"; @@ -62,7 +67,7 @@ export interface DurableObjectLike { /** @internal phantom */ scriptName?: Input; /** @internal phantom */ - transferredFrom?: Input; + transferredFrom?: DurableObjectTransferSource | DurableObjectTransferSource[]; /** @internal phantom */ Shape?: Shape; } @@ -110,6 +115,98 @@ export type DurableObjectServices = | WorkerEnvironment | PlatformServices; +/** + * A reference to the Worker that previously hosted a Durable Object class, + * accepted by {@link DurableObjectProps.transferredFrom}: + * + * - the Worker's **logical id** (same stack + stage) or **physical script + * name** (cross-stack) as a string + * - the Worker **resource** (`const b = yield* Cloudflare.Worker(...)`) + * - the Worker **class** (`transferredFrom: WorkerA`) + * - a **thunk** of any of the above (`() => WorkerA`) — use this for + * forward references and import cycles; it is evaluated lazily at plan + * time, after all modules have loaded + * - a string `Output` (e.g. a cross-stack `stackRef`'s worker name) + * + * Every form is normalized at plan time to a plain identifier: a Worker + * reference contributes its logical id, and a same-stack `workerName` + * Output contributes its source resource's logical id rather than the + * Output itself. Keeping the former host's *value* out of the binding data + * matters — consuming its Output would add a dependency edge from the new + * host onto the former host, inverting the deploy order the transfer + * requires (the new host must upload first). + */ +export type DurableObjectTransferSource = + | Input + | Worker + | Effect.Effect + | (() => DurableObjectTransferSource); + +const resolveTransferSourceRef = ( + source: DurableObjectTransferSource, + depth = 0, +): Input => { + if (typeof source === "string") { + return source; + } + // A Worker resource — contribute its logical id. + if (isWorker(source)) { + return source.LogicalId; + } + // A Worker platform class — carries its logical id statically. (On an + // Output proxy this property access yields another Output, never a + // string, so the check cannot misfire there.) + const logicalId = (source as { LogicalId?: unknown }).LogicalId; + if (typeof logicalId === "string") { + return logicalId; + } + // A same-stack Output on a Worker (e.g. `b.workerName`): take the + // identity from its provenance, not the value. + if (Output.isPropExpr(source)) { + const expr = (source as Output.PropExpr).expr; + if (Output.isResourceExpr(expr) && isWorker(expr.src)) { + return expr.src.LogicalId; + } + } + if (Output.isResourceExpr(source) && isWorker(source.src)) { + return source.src.LogicalId; + } + if (Output.isOutput(source)) { + // No local resource to take identity from (e.g. a cross-stack + // `stackRef`) — the engine resolves the string at deploy time. + return source as Input; + } + if (typeof source === "function" && depth < 8) { + return resolveTransferSourceRef( + (source as () => DurableObjectTransferSource)(), + depth + 1, + ); + } + throw new Error( + "Invalid transferredFrom entry: pass the former host's logical id or script name, its Worker class or resource, a thunk of one of those, or a string Output.", + ); +}; + +/** + * Normalize a `transferredFrom` declaration to plain host identifiers at + * plan time. See {@link DurableObjectTransferSource} for the accepted forms + * and why Worker references reduce to logical ids instead of Outputs. + * + * @internal + */ +export const normalizeTransferredFrom = ( + value: + | DurableObjectTransferSource + | readonly DurableObjectTransferSource[] + | undefined, +): Input[] | undefined => + value === undefined + ? undefined + : (Array.isArray(value) + ? (value as readonly DurableObjectTransferSource[]) + : [value as DurableObjectTransferSource] + ).map((source) => resolveTransferSourceRef(source)); + export interface DurableObjectProps { /** * Name of the exported `DurableObject` class. @@ -128,20 +225,25 @@ export interface DurableObjectProps { * data-preserving `transferred_classes` migration, moving the namespace — * including all stored objects — from that script to this Worker. * - * Each entry names a former host either by its Worker **logical id** in - * this stack + stage (resolved via alchemy's ownership tags) or by its - * **physical script name** (required for cross-stack moves). Moving a - * class is always declared this way — there is no inference. A list is a - * host *history*: as the class moves again, append the previous host so - * stages that lag behind (or skipped an intermediate release) still - * transfer from wherever their namespace currently lives. + * Each entry names a former host — see + * {@link DurableObjectTransferSource} for the accepted forms: a string + * (Worker logical id in this stack + stage, or physical script name for + * cross-stack moves), the Worker class or resource itself, or a thunk + * (`() => WorkerA`) for forward references. Moving a class is always + * declared this way — there is no inference. A list is a host *history*: + * as the class moves again, append the previous host so stages that lag + * behind (or skipped an intermediate release) still transfer from + * wherever their namespace currently lives. * * When no listed host holds the namespace — a fresh stage, or every * transfer already completed — the property is inert and the class is * created fresh (or left as-is), so it is safe to leave in place * indefinitely. */ - transferredFrom?: Input | undefined; + transferredFrom?: + | DurableObjectTransferSource + | DurableObjectTransferSource[] + | undefined; // environment?: string | undefined; // sqlite?: boolean | undefined; // namespaceId?: string | undefined; @@ -972,7 +1074,9 @@ export const DurableObject: DurableObjectClass = taggedFunction( const binding = ( scriptName?: Input, - transferredFrom?: Input, + transferredFrom?: + | DurableObjectTransferSource + | DurableObjectTransferSource[], ) => Effect.gen(function* () { const worker = yield* Worker; @@ -984,7 +1088,7 @@ export const DurableObject: DurableObjectClass = taggedFunction( name: namespace, className: namespace, scriptName, - transferredFrom, + transferredFrom: normalizeTransferredFrom(transferredFrom), }, ], }); diff --git a/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts b/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts index ef278c6d7..0b3be74f9 100644 --- a/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts +++ b/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts @@ -24,7 +24,10 @@ import { isDispatchNamespace } from "../WorkersForPlatforms/DispatchNamespace.ts import { isWorkflowLike, WorkflowResource } from "../Workflows/Workflow.ts"; import { isAssets } from "./Assets.ts"; import { isBrowser } from "./Browser.ts"; -import { isDurableObjectLike } from "./DurableObject.ts"; +import { + isDurableObjectLike, + normalizeTransferredFrom, +} from "./DurableObject.ts"; import { isRateLimit } from "./RateLimit.ts"; import { isVersionMetadata } from "./VersionMetadata.ts"; import type { WorkerBindingProps } from "./Worker.ts"; @@ -167,7 +170,7 @@ const toBinding = ( name: bindingName, className: binding.className ?? binding.name, scriptName: binding.scriptName, - transferredFrom: binding.transferredFrom, + transferredFrom: normalizeTransferredFrom(binding.transferredFrom), }; } else if (isWorkflowLike(binding)) { return { diff --git a/packages/alchemy/src/Platform.ts b/packages/alchemy/src/Platform.ts index cd5968801..bca904ed5 100644 --- a/packages/alchemy/src/Platform.ts +++ b/packages/alchemy/src/Platform.ts @@ -385,15 +385,24 @@ export const Platform = < // e.g. // export default Cloudflare.Worker("id", { main: "./src/worker.ts" }, Effect.gen(function* () { .. }) const cls = makeClass(id); - return cls.Self.pipe( - provideClassLayer(cls.make(props, impl)), - effectClass, + return Object.assign( + cls.Self.pipe(provideClassLayer(cls.make(props, impl)), effectClass), + // Expose the logical id statically (mirrors `makeClass`) so a class + // reference identifies its resource without being yielded. + { LogicalId: id }, ); } }; const makeClass = (id: string) => { class Platform { + /** + * Logical id of the resource this class declares. Statically readable + * — e.g. `DurableObjectProps.transferredFrom` accepts a Worker class + * and takes its identity from here without yielding it (yielding + * would add a dependency edge on the referenced resource). + */ + static readonly LogicalId = id; static readonly Self = Self(`${type}<${id}>`); static readonly Platform = Context.Service( `Platform<${type}<${id}>>`, diff --git a/packages/alchemy/test/Cloudflare/Workers/DurableObjectNamespace.test.ts b/packages/alchemy/test/Cloudflare/Workers/DurableObjectNamespace.test.ts index 911eedd09..75a0a2af3 100644 --- a/packages/alchemy/test/Cloudflare/Workers/DurableObjectNamespace.test.ts +++ b/packages/alchemy/test/Cloudflare/Workers/DurableObjectNamespace.test.ts @@ -676,7 +676,9 @@ test.provider( script: hostWorkerScript, env: { Counter: Cloudflare.DurableObject("Counter", { - transferredFrom: bScriptName, + // Thunk form — evaluated lazily at plan time and + // normalized to the plain script name. + transferredFrom: () => bScriptName, }), }, }); @@ -767,3 +769,90 @@ test.provider( }).pipe(logLevel), { timeout: 240_000 }, ); + +// #799: the documented *pure move* — the former host drops the DO entirely, +// keeping no cross-script reference — done as two deploys. Phase 1 adds the +// class to worker-a, declaring the former host by **Worker resource +// reference** (`transferredFrom: b`, normalized at plan time to worker-b's +// logical id — no dependency edge on b); worker-b is untouched. Phase 2 +// removes the DO from worker-b, whose deploy observes the class already +// transferred away and skips the delete. +test.provider( + "two-deploy pure move with a Worker resource reference", + (scratch) => + Effect.gen(function* () { + yield* scratch.destroy(); + + const v1 = yield* scratch.deploy( + Effect.gen(function* () { + return { + b: yield* Cloudflare.Worker("worker-b", { + script: hostWorkerScript, + env: { + Counter: Cloudflare.DurableObject("Counter"), + }, + }), + }; + }), + ); + + yield* fetchJsonReady<{ ok: boolean }>(`${v1.b.url}/reset`); + const written = yield* fetchJsonReady<{ value: number }>( + `${v1.b.url}/increment`, + ); + expect(written.value).toBe(1); + + // Phase 1: worker-a takes the class, naming the former host by + // resource reference. worker-b's declaration is unchanged (noop). + const v2 = yield* scratch.deploy( + Effect.gen(function* () { + const b = yield* Cloudflare.Worker("worker-b", { + script: hostWorkerScript, + env: { + Counter: Cloudflare.DurableObject("Counter"), + }, + }); + const a = yield* Cloudflare.Worker("worker-a", { + script: hostWorkerScript, + env: { + Counter: Cloudflare.DurableObject("Counter", { + transferredFrom: b, + }), + }, + }); + return { a, b }; + }), + ); + + // The namespace moved to worker-a with its data intact. + const viaA = yield* fetchJsonReady<{ value: number }>(`${v2.a.url}/get`); + expect(viaA.value).toBe(1); + + // Phase 2: worker-b drops the DO entirely. Its deploy observes the + // class already lives on worker-a and emits no delete migration. + const v3 = yield* scratch.deploy( + Effect.gen(function* () { + const b = yield* Cloudflare.Worker("worker-b", { + script: `export default { fetch() { return new Response("plain"); } };`, + }); + const a = yield* Cloudflare.Worker("worker-a", { + script: hostWorkerScript, + env: { + Counter: Cloudflare.DurableObject("Counter", { + transferredFrom: b, + }), + }, + }); + return { a, b }; + }), + ); + + const afterRemoval = yield* fetchJsonReady<{ value: number }>( + `${v3.a.url}/increment`, + ); + expect(afterRemoval.value).toBe(2); + + yield* scratch.destroy(); + }).pipe(logLevel), + { timeout: 240_000 }, +); diff --git a/website/src/content/docs/cloudflare/compute/cross-worker-durable-object.mdx b/website/src/content/docs/cloudflare/compute/cross-worker-durable-object.mdx index e2440aee0..404b57adb 100644 --- a/website/src/content/docs/cloudflare/compute/cross-worker-durable-object.mdx +++ b/website/src/content/docs/cloudflare/compute/cross-worker-durable-object.mdx @@ -345,6 +345,22 @@ stack + stage (for a cross-stack move, use the physical script name instead). Whichever Worker hosts `Counter` next will transfer the namespace from it. +:::tip[Prefer a typed reference over a string] +`transferredFrom` also accepts the Worker class or resource itself — +or a thunk for forward references and import cycles: + +```typescript +{ transferredFrom: () => WorkerA } +``` + +The thunk is evaluated lazily at plan time and normalized to +WorkerA's logical id, so it stays safe in module cycles (here, +`object.ts` referencing `workerA.ts` while `workerA.ts` imports +`object.ts`). A reference never becomes a dependency edge — alchemy +takes the *identity*, not the deployed value, which keeps the deploy +order correct (the new host uploads first). +::: + ### Make WorkerB the host `WorkerB` takes over the host role: it declares `Counter` in its From 19561632d1d2ac890a2d594840a28ff062c4c0b1 Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Wed, 15 Jul 2026 13:49:42 -0700 Subject: [PATCH 6/6] test(cloudflare/workers): prove transferredFrom idempotence; fix cross-script adoption fallback Live-verifies every non-destructive guarantee of a standing transferredFrom declaration: - steady-state redeploys emit no migration (existing) and the declaration can be REMOVED afterwards with no migration and no data change - a fresh stage deployed with the declaration present creates a fresh namespace (same-class namespaces of other stacks/stages never match) - a stale declaration never steals a same-name namespace later created on the former host (isolated-twins pattern), even on a forced reconcile The twin test exposed a pre-existing bug: the adoption fallback matched an observed durable_object_namespace binding by NAME only, so a worker switching a binding from cross-script reference to locally hosted treated the foreign binding as proof the class existed locally, suppressed the create migration, and Cloudflare rejected the upload. The fallback now requires the observed binding to be locally owned. Co-Authored-By: Claude Fable 5 --- .../src/Cloudflare/Workers/WorkerProvider.ts | 10 ++ .../Workers/DurableObjectNamespace.test.ts | 157 ++++++++++++++++++ 2 files changed, 167 insertions(+) diff --git a/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts b/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts index 984106653..bc014737f 100644 --- a/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts +++ b/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts @@ -1686,6 +1686,16 @@ export const LiveWorkerProvider = () => old.type === "durable_object_namespace" && "className" in old && old.className && + // Only a *locally-owned* binding proves the class exists on + // this script. A cross-script binding under the same name — + // e.g. this worker previously referenced another host's + // class and now hosts its own — points at a foreign + // namespace and must not suppress the create migration + // (Cloudflare rejects a local binding for a class the + // script isn't configured to implement). + (!("scriptName" in old) || + old.scriptName === undefined || + old.scriptName === name) && old.name === binding.bindingName, ); if (observed && "className" in observed && observed.className) { diff --git a/packages/alchemy/test/Cloudflare/Workers/DurableObjectNamespace.test.ts b/packages/alchemy/test/Cloudflare/Workers/DurableObjectNamespace.test.ts index a9c58f9d5..f3c726c99 100644 --- a/packages/alchemy/test/Cloudflare/Workers/DurableObjectNamespace.test.ts +++ b/packages/alchemy/test/Cloudflare/Workers/DurableObjectNamespace.test.ts @@ -635,6 +635,33 @@ test.provider( ); expect(afterRedeploy.value).toBe(2); + // ...and once the move has landed everywhere, the declaration can be + // removed entirely: the class is anchored by its alchemy:do tag, so + // dropping `transferredFrom` emits no migration and touches no data. + const v4 = yield* scratch.deploy( + Effect.gen(function* () { + const a = yield* Cloudflare.Worker("worker-a", { + script: hostWorkerScript, + env: { + Counter: Cloudflare.DurableObject("Counter"), + }, + }); + const b = yield* Cloudflare.Worker("worker-b", { + script: consumerWorkerScript, + env: { + Counter: Cloudflare.DurableObject("Counter", { + scriptName: a.workerName, + }), + }, + }); + return { a, b }; + }), + ); + const afterRemoval = yield* fetchJsonReady<{ value: number }>( + `${v4.a.url}/get`, + ); + expect(afterRemoval.value).toBe(2); + yield* scratch.destroy(); }).pipe(logLevel), { timeout: 240_000 }, @@ -1030,3 +1057,133 @@ export default { async fetch() { return new Response("v2"); } }; }).pipe(logLevel), { timeout: 180_000 }, ); + +// #799 (idempotence): a standing `transferredFrom` declaration must never do +// anything destructive after its job is done. +// +// v1 — a FRESH stage deployed directly in the final topology, declaration +// present: no listed source hosts the class, so worker-a creates a +// fresh namespace. Same-class namespaces owned by other stacks and +// stages on the account must not match the declaration. +// v2 — worker-b later hosts its OWN Durable Object under the same name and +// class (the isolated-twins pattern): a fresh, separate namespace. +// v3 — worker-a redeploys (forced update) with the now-stale declaration +// while that same-name namespace exists on worker-b: the class is +// already anchored to worker-a by its DO tag, so the stale +// declaration must not steal worker-b's namespace or emit any +// migration. Distinct counter values prove both namespaces survive +// untouched. +test.provider( + "standing transferredFrom is inert on fresh stages and never steals a same-name namespace", + (scratch) => + Effect.gen(function* () { + yield* scratch.destroy(); + + // Fresh stage, final topology, declaration present from day one. + const v1 = yield* scratch.deploy( + Effect.gen(function* () { + const a = yield* Cloudflare.Worker("worker-a", { + script: hostWorkerScript, + env: { + Counter: Cloudflare.DurableObject("Counter", { + transferredFrom: "worker-b", + }), + }, + }); + const b = yield* Cloudflare.Worker("worker-b", { + script: consumerWorkerScript, + env: { + Counter: Cloudflare.DurableObject("Counter", { + scriptName: a.workerName, + }), + }, + }); + return { a, b }; + }), + ); + + // Fresh namespace on worker-a; give it a distinctly non-zero count. + // (`/increment` is not idempotent and readiness retries can re-run + // it, so assertions use the idempotent `/get` instead of increment + // return values.) + yield* fetchJsonReady<{ ok: boolean }>(`${v1.a.url}/reset`); + yield* fetchJsonReady<{ value: number }>(`${v1.a.url}/increment`); + yield* fetchJsonReady<{ value: number }>(`${v1.a.url}/increment`); + const aBefore = (yield* fetchJsonReady<{ value: number }>( + `${v1.a.url}/get`, + )).value; + expect(aBefore).toBeGreaterThanOrEqual(2); + + // worker-b now hosts its OWN same-name Counter — an isolated twin. + const v2 = yield* scratch.deploy( + Effect.gen(function* () { + const a = yield* Cloudflare.Worker("worker-a", { + script: hostWorkerScript, + env: { + Counter: Cloudflare.DurableObject("Counter", { + transferredFrom: "worker-b", + }), + }, + }); + const b = yield* Cloudflare.Worker("worker-b", { + script: hostWorkerScript, + env: { + Counter: Cloudflare.DurableObject("Counter"), + }, + }); + return { a, b }; + }), + ); + + // The twin starts empty — its own namespace, not worker-a's. b's + // edge may briefly serve the previous (cross-script) version, whose + // /get reads worker-a's non-zero count, so poll until the fresh + // version answers with the twin's empty count. + yield* fetchJsonReady<{ value: number }>(`${v2.b.url}/get`).pipe( + Effect.flatMap((r) => + r.value === 0 + ? Effect.void + : Effect.fail(new Error(`stale: twin sees ${r.value}`)), + ), + Effect.retry({ schedule: readinessSchedule, times: readinessRetries }), + ); + + // Force a real reconcile of worker-a with the stale declaration while + // the twin exists. The class is tag-anchored to worker-a, so no + // migration may be emitted and neither namespace may change. + const v3 = yield* scratch.deploy( + Effect.gen(function* () { + const a = yield* Cloudflare.Worker("worker-a", { + script: hostWorkerScript, + env: { + FORCE_UPDATE: "1", + Counter: Cloudflare.DurableObject("Counter", { + transferredFrom: "worker-b", + }), + }, + }); + const b = yield* Cloudflare.Worker("worker-b", { + script: hostWorkerScript, + env: { + Counter: Cloudflare.DurableObject("Counter"), + }, + }); + return { a, b }; + }), + ); + + const aAfter = yield* fetchJsonReady<{ value: number }>( + `${v3.a.url}/get`, + ); + expect(aAfter.value).toBe(aBefore); + // worker-b was untouched in v3 (noop), so no version staleness here: + // its twin namespace must still exist and still be empty. + const twinAfter = yield* fetchJsonReady<{ value: number }>( + `${v3.b.url}/get`, + ); + expect(twinAfter.value).toBe(0); + + yield* scratch.destroy(); + }).pipe(logLevel), + { timeout: 240_000 }, +);