diff --git a/packages/alchemy/src/Cloudflare/Workers/Worker.ts b/packages/alchemy/src/Cloudflare/Workers/Worker.ts index 3b9ed49aaa..7ec00089ce 100644 --- a/packages/alchemy/src/Cloudflare/Workers/Worker.ts +++ b/packages/alchemy/src/Cloudflare/Workers/Worker.ts @@ -419,6 +419,33 @@ 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. 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. + * + * @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..ed5e3c65c7 100644 --- a/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts +++ b/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts @@ -37,6 +37,34 @@ 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; + movedClasses: { className: string; scriptName: string }[]; +}> { + override get message(): string { + const moves = this.movedClasses + .map((m) => `'${m.className}' (now hosted on '${m.scriptName}')`) + .join(", "); + return [ + `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).`, + ].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 +1552,129 @@ 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, which + // the delete-class migration irreversibly destroys. + const conflictingCrossScriptBindings = + getConflictingCrossScriptDoBindings( + metadataBindings, + deletedClasses, + name, + ); + if ( + conflictingCrossScriptBindings.length > 0 && + news.deleteMovedDurableObjectClasses !== true + ) { + return yield* Effect.fail( + new DurableObjectClassMoved({ + scriptName: name, + movedClasses: Array.from( + new Map( + conflictingCrossScriptBindings.map((b) => [ + `${b.className}${b.scriptName}`, + { className: b.className, scriptName: b.scriptName }, + ]), + ).values(), + ), + }), + ); + } + // 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. + // 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) => !conflictingBindings.includes(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 +2653,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..4d6387e9aa 100644 --- a/packages/alchemy/test/Cloudflare/Workers/WorkerProvider.test.ts +++ b/packages/alchemy/test/Cloudflare/Workers/WorkerProvider.test.ts @@ -1,7 +1,136 @@ -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", + movedClasses: [{ className: "MyDOClass", scriptName: "worker-a" }], + }); + + 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", () => { + 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"); + }); + }); + + 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://`