From 13064f0ed1019cda61306263b1c039c697b03d11 Mon Sep 17 00:00:00 2001 From: Daniel Gangl Date: Fri, 10 Jul 2026 17:09:18 +0200 Subject: [PATCH 1/2] fix(cloudflare/workers): guard DO class move across scripts (#799) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Durable Object class moves from one Worker to another and the former host keeps a cross-script `DurableObject(..., { scriptName })` reference, the class lands in `deletedClasses` while its `class_name` is still bound. A single script upload then ships the delete-class migration alongside the binding that references the class, which Cloudflare rejects ("Cannot apply --delete-class migration to class 'X' without also removing the binding that references it") — its validator matches DO bindings by `class_name` alone, ignoring `script_name`. That rejection is a safety interlock: Durable Object storage does not move with the class, so applying the delete migration irreversibly destroys the old namespace and its data on the former host. So: - By default the deploy now fails before any upload with `DurableObjectClassMoved`, an actionable error explaining the move, the irreversible data loss, and how to proceed — strictly better than the opaque Cloudflare error. - Opt in via the new `deleteMovedDurableObjectClasses` Worker prop to confirm the deletion. The provider then performs the move as two uploads (Cloudflare rejects doing both at once): upload #1 applies the delete-class migration with the conflicting cross-script binding(s) omitted, upload #2 re-attaches the full binding set with no migration, keeping the migration/`alchemy:migration-tag` bookkeeping consistent. Extracts `getConflictingCrossScriptDoBindings` as a unit-testable seam and adds unit coverage for the conflict detection and the error guidance. The live move scenario is covered only by integration tests against real Cloudflare. Co-Authored-By: Claude Fable 5 --- .../alchemy/src/Cloudflare/Workers/Worker.ts | 24 ++ .../src/Cloudflare/Workers/WorkerProvider.ts | 212 ++++++++++++++---- .../Cloudflare/Workers/WorkerProvider.test.ts | 134 ++++++++++- 3 files changed, 330 insertions(+), 40 deletions(-) diff --git a/packages/alchemy/src/Cloudflare/Workers/Worker.ts b/packages/alchemy/src/Cloudflare/Workers/Worker.ts index 3b9ed49aaa..1d53541c18 100644 --- a/packages/alchemy/src/Cloudflare/Workers/Worker.ts +++ b/packages/alchemy/src/Cloudflare/Workers/Worker.ts @@ -419,6 +419,30 @@ export interface WorkerProps< }; limits?: WorkerLimits; placement?: WorkerPlacement; + /** + * Opt in to deleting a Durable Object class (and its stored data) when it + * moves from this Worker to another script. + * + * When a Durable Object class that this Worker previously hosted becomes a + * cross-script reference (a `Cloudflare.DurableObject(..., { scriptName })` + * binding pointing at another Worker), Cloudflare requires the class to be + * removed from this Worker with a delete-class migration. **Durable Object + * storage does not move with the class** — the new host starts with a fresh, + * empty namespace, and the delete-class migration permanently destroys the + * old namespace and all of its data on this Worker. This is irreversible. + * + * Because of that, the deploy fails by default with an explanatory error. + * Set this to `true` to confirm the deletion: Alchemy then performs the move + * as two uploads (Cloudflare rejects doing both at once) — first removing the + * cross-script binding while applying the delete-class migration, then + * re-attaching the full binding set. + * + * Only affects the transition where the class leaves this Worker; it never + * deletes classes the Worker still hosts locally. + * + * @default false + */ + deleteMovedDurableObjectClasses?: boolean; /** * Tracks Durable Object and Workflow exports for Effect-native Workers only. * Populated automatically from bindings; do not set manually. diff --git a/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts b/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts index 9c48aef1fc..d527abbfe1 100644 --- a/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts +++ b/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts @@ -37,6 +37,32 @@ class MissingDurableObjects extends Data.TaggedError("MissingDurableObjects")<{ expected: string[]; }> {} +/** + * Raised when a Durable Object class moves from this Worker to another script + * (it becomes a cross-script binding with `scriptName` set) while the class is + * simultaneously being removed from this Worker, and the user has not opted in + * to the destructive delete. Durable Object storage does not transfer with the + * class, so applying the delete-class migration would irreversibly destroy the + * namespace and its data on this Worker. See + * {@link WorkerProps.deleteMovedDurableObjectClasses}. + */ +export class DurableObjectClassMoved extends Data.TaggedError( + "DurableObjectClassMoved", +)<{ + scriptName: string; + classNames: string[]; +}> { + override get message(): string { + const classes = this.classNames.map((c) => `'${c}'`).join(", "); + return [ + `Durable Object class ${classes} moved from Worker '${this.scriptName}' to another script (now a cross-script binding with 'scriptName' set), and is being removed from '${this.scriptName}'.`, + `Durable Object storage does NOT transfer when a class moves: the new host starts with a fresh, empty namespace, while the namespace on '${this.scriptName}' still holds all of its data. Deploying this change applies a delete-class migration that permanently destroys that namespace and every object stored in it. This cannot be undone.`, + `If the data is disposable (or you have already migrated it), opt in explicitly by setting 'deleteMovedDurableObjectClasses: true' on Worker '${this.scriptName}'. Alchemy will then remove the binding and delete the class in two separate uploads, as Cloudflare requires.`, + `To keep the data, either keep the class defined on '${this.scriptName}', or perform the move manually across two deploys (first remove the binding, then re-add it as a cross-script reference).`, + ].join("\n\n"); + } +} + /** * Resolve the Workers for Platforms dispatch-namespace *name* from a resolved * `namespace` prop or persisted attribute. The engine resolves a passed @@ -1524,47 +1550,126 @@ export const LiveWorkerProvider = () => tailConsumers: undefined, usageModel: undefined, }; - const worker = yield* putWorkerScript({ - accountId, - scriptName: name, - dispatchNamespace, - metadata, - files: bundle.files, - }).pipe( - Effect.catch((err) => { - // When adopting a Worker managed by Wrangler (or after a previous - // deploy with mismatched migrations), the old_tag precondition - // fails. The only way to discover the actual tag is through the - // error message — getScriptSettings is meant to return it but - // doesn't at runtime. - const msg = String( - typeof err === "object" && err !== null && "message" in err - ? err.message - : err, - ); - const expectedTag = msg.match( - /when expected tag is ['"]?([^'"]+)['"]?/, - )?.[1]; - if (expectedTag) { - return putWorkerScript({ - accountId, - scriptName: name, - dispatchNamespace, - metadata: { - ...metadata, - migrations: { - ...migrations, - oldTag: expectedTag, - newTag: bumpMigrationTagVersion(expectedTag), + // A Durable Object class that moved from this worker to another script + // (now referenced via a cross-script binding with `scriptName` set) + // lands in `deletedClasses` here while its class name is still bound. + // Cloudflare rejects that single upload: its validator matches DO + // bindings by `class_name` alone (ignoring `script_name`) and refuses + // to delete a class whose binding is still attached ("Cannot apply + // --delete-class migration to class 'X' without also removing the + // binding that references it"). + // + // That rejection is a safety interlock: Durable Object storage does + // NOT move with the class — the new host gets a fresh, empty namespace + // and the old namespace on THIS worker still holds all its data. + // Silently splitting the upload would turn an innocent-looking topology + // refactor into irreversible deletion of that data. So by default we + // fail loudly with guidance; only an explicit per-worker opt-in + // (`deleteMovedDurableObjectClasses`) performs the destructive + // two-phase upload. + const conflictingCrossScriptBindings = + getConflictingCrossScriptDoBindings( + metadataBindings, + deletedClasses, + name, + ); + if ( + conflictingCrossScriptBindings.length > 0 && + news.deleteMovedDurableObjectClasses !== true + ) { + return yield* Effect.fail( + new DurableObjectClassMoved({ + scriptName: name, + classNames: Array.from( + new Set(conflictingCrossScriptBindings.map((b) => b.className)), + ), + }), + ); + } + // Opt-in confirmed (or no conflict). When there is a conflict, split + // the deploy: upload #1 applies the delete migration with the + // conflicting cross-script binding(s) omitted, upload #2 re-attaches + // the full binding set with no migration. + const isTwoPhaseDoMove = conflictingCrossScriptBindings.length > 0; + if (isTwoPhaseDoMove) { + yield* session.note( + "DO class(es) moved cross-script: applying delete migration in a separate upload before attaching cross-script bindings", + ); + } + + const uploadWithTagRetry = ( + uploadMetadata: workers.PutScriptRequest["metadata"], + ) => + putWorkerScript({ + accountId, + scriptName: name, + dispatchNamespace, + metadata: uploadMetadata, + files: bundle.files, + }).pipe( + Effect.catch((err) => { + // When adopting a Worker managed by Wrangler (or after a previous + // deploy with mismatched migrations), the old_tag precondition + // fails. The only way to discover the actual tag is through the + // error message — getScriptSettings is meant to return it but + // doesn't at runtime. + const msg = String( + typeof err === "object" && err !== null && "message" in err + ? err.message + : err, + ); + const expectedTag = msg.match( + /when expected tag is ['"]?([^'"]+)['"]?/, + )?.[1]; + if (expectedTag && uploadMetadata.migrations) { + return putWorkerScript({ + accountId, + scriptName: name, + dispatchNamespace, + metadata: { + ...uploadMetadata, + migrations: { + ...uploadMetadata.migrations, + oldTag: expectedTag, + newTag: bumpMigrationTagVersion(expectedTag), + }, }, - }, - files: bundle.files, - }); - } - // @effect-diagnostics-next-line anyUnknownInErrorContext:off - return Effect.fail(err as any); - }), + files: bundle.files, + }); + } + // @effect-diagnostics-next-line anyUnknownInErrorContext:off + return Effect.fail(err as any); + }), + ); + + // Upload #1 carries the delete migration. In the two-phase case it + // omits the conflicting cross-script bindings so Cloudflare accepts the + // class delete; otherwise it is the normal single upload with the full + // binding set. + const conflictingBindingSet = new Set( + conflictingCrossScriptBindings, + ); + let worker = yield* uploadWithTagRetry( + isTwoPhaseDoMove + ? { + ...metadata, + bindings: metadataBindings.filter( + (b) => !conflictingBindingSet.has(b), + ), + } + : metadata, ); + // Upload #2 re-attaches the full binding set (cross-script bindings + // included) with no migration. Upload #1 already advanced Cloudflare's + // migration tag to `newMigrationTag`, and the metadata tags carry the + // same `alchemy:migration-tag:` value, so the recorded tag stays + // consistent after this final upload. + if (isTwoPhaseDoMove) { + worker = yield* uploadWithTagRetry({ + ...metadata, + migrations: undefined, + }); + } const { settings, durableObjectNamespaces } = yield* getWorkerSettingsWithDurableObjects( name, @@ -2543,6 +2648,35 @@ function mergeDurableObjectClasses( ); } +/** + * Given the resolved `durable_object_namespace` metadata bindings and the set + * of class names about to be dropped by a `deleted_classes` migration, return + * the bindings that would make Cloudflare reject the upload: cross-script + * bindings (`scriptName` set to another worker) whose `className` is in + * `deletedClasses`. Cloudflare matches DO bindings by `class_name` alone when + * validating a delete-class migration, so a `script_name` pointing at the new + * host does not exempt the binding — the delete and the binding removal must + * ship in two separate uploads. When this returns a non-empty list the provider + * takes the two-phase upload path. + * + * @internal exported for unit testing. + */ +export const getConflictingCrossScriptDoBindings = < + B extends { type: string; className?: string; scriptName?: string }, +>( + metadataBindings: readonly B[], + deletedClasses: readonly string[], + workerName: string, +): (B & { className: string; scriptName: string })[] => + metadataBindings.filter( + (b): b is B & { className: string; scriptName: string } => + b.type === "durable_object_namespace" && + b.className !== undefined && + deletedClasses.includes(b.className) && + b.scriptName !== undefined && + b.scriptName !== workerName, + ); + function getDurableObjectBindings( bindings: ReadonlyArray, workerName: string, diff --git a/packages/alchemy/test/Cloudflare/Workers/WorkerProvider.test.ts b/packages/alchemy/test/Cloudflare/Workers/WorkerProvider.test.ts index 67bfdf3e11..ec2601aa9c 100644 --- a/packages/alchemy/test/Cloudflare/Workers/WorkerProvider.test.ts +++ b/packages/alchemy/test/Cloudflare/Workers/WorkerProvider.test.ts @@ -1,7 +1,139 @@ -import { normalizeStateDomains } from "@/Cloudflare/Workers/WorkerProvider"; +import { + DurableObjectClassMoved, + getConflictingCrossScriptDoBindings, + normalizeStateDomains, +} from "@/Cloudflare/Workers/WorkerProvider"; import { describe, expect, test } from "@effect/vitest"; describe("WorkerProvider", () => { + describe("DurableObjectClassMoved", () => { + // The default (non-opt-in) path fails loudly before any upload with an + // actionable message: (a) the class moved cross-script, (b) DO data does + // not transfer and the delete is irreversible, (c) how to proceed (#799). + const err = new DurableObjectClassMoved({ + scriptName: "worker-b", + classNames: ["MyDOClass"], + }); + + test("names the moved class and the former host", () => { + expect(err.message).toContain("'MyDOClass'"); + expect(err.message).toContain("worker-b"); + }); + + test("warns that data does not transfer and the delete is irreversible", () => { + expect(err.message).toMatch(/does NOT transfer/i); + expect(err.message).toMatch(/cannot be undone/i); + }); + + test("points at the explicit opt-in to proceed", () => { + expect(err.message).toContain("deleteMovedDurableObjectClasses: true"); + }); + + test("is a tagged error", () => { + expect(err._tag).toBe("DurableObjectClassMoved"); + }); + }); + + describe("getConflictingCrossScriptDoBindings", () => { + // A Durable Object class that moves from this worker to another script + // becomes a cross-script binding (`scriptName` set to the new host) while + // the class simultaneously lands in `deletedClasses` for this worker. That + // combination is exactly what Cloudflare rejects in a single upload, so it + // must trigger the two-phase upload path (#799). + const worker = "worker-b"; + const foreign = "worker-a"; + + test("flags a cross-script binding whose class is being deleted", () => { + const bindings = [ + { + type: "durable_object_namespace", + name: "EDITOR_DO", + className: "MyDOClass", + scriptName: foreign, + }, + ]; + expect( + getConflictingCrossScriptDoBindings(bindings, ["MyDOClass"], worker), + ).toEqual(bindings); + }); + + test("ignores a locally-owned DO binding (no scriptName)", () => { + const bindings = [ + { + type: "durable_object_namespace", + name: "DO", + className: "MyDOClass", + }, + ]; + expect( + getConflictingCrossScriptDoBindings(bindings, ["MyDOClass"], worker), + ).toEqual([]); + }); + + test("ignores a self-referencing binding (scriptName === worker)", () => { + const bindings = [ + { + type: "durable_object_namespace", + name: "DO", + className: "MyDOClass", + scriptName: worker, + }, + ]; + expect( + getConflictingCrossScriptDoBindings(bindings, ["MyDOClass"], worker), + ).toEqual([]); + }); + + test("ignores a cross-script binding whose class is NOT being deleted", () => { + const bindings = [ + { + type: "durable_object_namespace", + name: "DO", + className: "OtherClass", + scriptName: foreign, + }, + ]; + expect( + getConflictingCrossScriptDoBindings(bindings, ["MyDOClass"], worker), + ).toEqual([]); + }); + + test("ignores non-DO bindings and returns only conflicting entries", () => { + const conflicting = { + type: "durable_object_namespace", + name: "EDITOR_DO", + className: "MyDOClass", + scriptName: foreign, + }; + const bindings = [ + { type: "plain_text", name: "VAR", className: "MyDOClass" }, + { + type: "durable_object_namespace", + name: "LOCAL_DO", + className: "LocalClass", + }, + conflicting, + ]; + expect( + getConflictingCrossScriptDoBindings(bindings, ["MyDOClass"], worker), + ).toEqual([conflicting]); + }); + + test("returns empty when nothing is being deleted", () => { + const bindings = [ + { + type: "durable_object_namespace", + name: "EDITOR_DO", + className: "MyDOClass", + scriptName: foreign, + }, + ]; + expect(getConflictingCrossScriptDoBindings(bindings, [], worker)).toEqual( + [], + ); + }); + }); + describe("normalizeStateDomains", () => { // Worker state written by Alchemy <= beta.44 stored each custom domain as a // `{ id, hostname, zoneId }` object; beta.45+ stores `https://` From 8e5c410e90d190fbec9763bc3fbd1c032ba04288 Mon Sep 17 00:00:00 2001 From: Daniel Gangl Date: Fri, 10 Jul 2026 17:48:16 +0200 Subject: [PATCH 2/2] fix(cloudflare/workers): address review on DO cross-script move guard - Name the destination script(s) in DurableObjectClassMoved: payload is now `movedClasses: { className, scriptName }[]` and the message says which worker hosts each moved class. - Document the brief window between the two uploads where the moved binding is absent (in-flight requests using it fail) in the `deleteMovedDurableObjectClasses` JSDoc. - Drop the `unknown`-typed Set in favor of a plain `.includes` over the (1-2 element) conflict list. - Trim the conflict-detection block comment to the Cloudflare-rejection facts and safety-interlock rationale. - Remove the zero-signal `_tag` assertion test. Co-Authored-By: Claude Fable 5 --- .../alchemy/src/Cloudflare/Workers/Worker.ts | 5 ++- .../src/Cloudflare/Workers/WorkerProvider.ts | 35 +++++++++++-------- .../Cloudflare/Workers/WorkerProvider.test.ts | 9 ++--- 3 files changed, 27 insertions(+), 22 deletions(-) diff --git a/packages/alchemy/src/Cloudflare/Workers/Worker.ts b/packages/alchemy/src/Cloudflare/Workers/Worker.ts index 1d53541c18..7ec00089ce 100644 --- a/packages/alchemy/src/Cloudflare/Workers/Worker.ts +++ b/packages/alchemy/src/Cloudflare/Workers/Worker.ts @@ -435,7 +435,10 @@ export interface WorkerProps< * Set this to `true` to confirm the deletion: Alchemy then performs the move * as two uploads (Cloudflare rejects doing both at once) — first removing the * cross-script binding while applying the delete-class migration, then - * re-attaching the full binding set. + * re-attaching the full binding set. Between the two uploads there is a + * brief window where the moved binding is absent — in-flight requests that + * use it will fail; the same window exists when performing the move + * manually across two deploys. * * Only affects the transition where the class leaves this Worker; it never * deletes classes the Worker still hosts locally. diff --git a/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts b/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts index d527abbfe1..ed5e3c65c7 100644 --- a/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts +++ b/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts @@ -50,12 +50,14 @@ export class DurableObjectClassMoved extends Data.TaggedError( "DurableObjectClassMoved", )<{ scriptName: string; - classNames: string[]; + movedClasses: { className: string; scriptName: string }[]; }> { override get message(): string { - const classes = this.classNames.map((c) => `'${c}'`).join(", "); + const moves = this.movedClasses + .map((m) => `'${m.className}' (now hosted on '${m.scriptName}')`) + .join(", "); return [ - `Durable Object class ${classes} moved from Worker '${this.scriptName}' to another script (now a cross-script binding with 'scriptName' set), and is being removed from '${this.scriptName}'.`, + `Durable Object class ${moves} moved from Worker '${this.scriptName}' to another script — the binding is now a cross-script reference — and the class is being removed from '${this.scriptName}'.`, `Durable Object storage does NOT transfer when a class moves: the new host starts with a fresh, empty namespace, while the namespace on '${this.scriptName}' still holds all of its data. Deploying this change applies a delete-class migration that permanently destroys that namespace and every object stored in it. This cannot be undone.`, `If the data is disposable (or you have already migrated it), opt in explicitly by setting 'deleteMovedDurableObjectClasses: true' on Worker '${this.scriptName}'. Alchemy will then remove the binding and delete the class in two separate uploads, as Cloudflare requires.`, `To keep the data, either keep the class defined on '${this.scriptName}', or perform the move manually across two deploys (first remove the binding, then re-add it as a cross-script reference).`, @@ -1561,12 +1563,8 @@ export const LiveWorkerProvider = () => // // That rejection is a safety interlock: Durable Object storage does // NOT move with the class — the new host gets a fresh, empty namespace - // and the old namespace on THIS worker still holds all its data. - // Silently splitting the upload would turn an innocent-looking topology - // refactor into irreversible deletion of that data. So by default we - // fail loudly with guidance; only an explicit per-worker opt-in - // (`deleteMovedDurableObjectClasses`) performs the destructive - // two-phase upload. + // and the old namespace on THIS worker still holds all its data, which + // the delete-class migration irreversibly destroys. const conflictingCrossScriptBindings = getConflictingCrossScriptDoBindings( metadataBindings, @@ -1580,8 +1578,13 @@ export const LiveWorkerProvider = () => return yield* Effect.fail( new DurableObjectClassMoved({ scriptName: name, - classNames: Array.from( - new Set(conflictingCrossScriptBindings.map((b) => b.className)), + movedClasses: Array.from( + new Map( + conflictingCrossScriptBindings.map((b) => [ + `${b.className}${b.scriptName}`, + { className: b.className, scriptName: b.scriptName }, + ]), + ).values(), ), }), ); @@ -1646,15 +1649,17 @@ export const LiveWorkerProvider = () => // omits the conflicting cross-script bindings so Cloudflare accepts the // class delete; otherwise it is the normal single upload with the full // binding set. - const conflictingBindingSet = new Set( - conflictingCrossScriptBindings, - ); + // Widened to the metadata element type so `.includes` accepts any + // binding (the conflict list is narrowed by the type-guard filter). + const conflictingBindings: ReadonlyArray< + (typeof metadataBindings)[number] + > = conflictingCrossScriptBindings; let worker = yield* uploadWithTagRetry( isTwoPhaseDoMove ? { ...metadata, bindings: metadataBindings.filter( - (b) => !conflictingBindingSet.has(b), + (b) => !conflictingBindings.includes(b), ), } : metadata, diff --git a/packages/alchemy/test/Cloudflare/Workers/WorkerProvider.test.ts b/packages/alchemy/test/Cloudflare/Workers/WorkerProvider.test.ts index ec2601aa9c..4d6387e9aa 100644 --- a/packages/alchemy/test/Cloudflare/Workers/WorkerProvider.test.ts +++ b/packages/alchemy/test/Cloudflare/Workers/WorkerProvider.test.ts @@ -12,12 +12,13 @@ describe("WorkerProvider", () => { // not transfer and the delete is irreversible, (c) how to proceed (#799). const err = new DurableObjectClassMoved({ scriptName: "worker-b", - classNames: ["MyDOClass"], + movedClasses: [{ className: "MyDOClass", scriptName: "worker-a" }], }); - test("names the moved class and the former host", () => { + test("names the moved class, the former host, and the new host", () => { expect(err.message).toContain("'MyDOClass'"); expect(err.message).toContain("worker-b"); + expect(err.message).toContain("'worker-a'"); }); test("warns that data does not transfer and the delete is irreversible", () => { @@ -28,10 +29,6 @@ describe("WorkerProvider", () => { test("points at the explicit opt-in to proceed", () => { expect(err.message).toContain("deleteMovedDurableObjectClasses: true"); }); - - test("is a tagged error", () => { - expect(err._tag).toBe("DurableObjectClassMoved"); - }); }); describe("getConflictingCrossScriptDoBindings", () => {