From ddaf89eb2712b3e6512dc582fdbc70d69e3a34c2 Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Sun, 31 May 2026 21:55:16 -0700 Subject: [PATCH 1/2] fix(cloudflare): adopt durable object classes created outside alchemy When adopting a worker not created by Alchemy (raw API/Wrangler/dashboard), its Durable Object classes have no alchemy:do logical-id->class tag, so every class fell into newSqliteClasses and Cloudflare rejected the migration for trying to create a class that already exists. Fall back to matching the observed cloud binding by binding name so the existing class is reused (or renamed) instead of recreated. Closes #486 Co-Authored-By: Claude Opus 4.7 --- .../alchemy/src/Cloudflare/Workers/Worker.ts | 22 ++++- .../Workers/DurableObjectNamespace.test.ts | 87 +++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/packages/alchemy/src/Cloudflare/Workers/Worker.ts b/packages/alchemy/src/Cloudflare/Workers/Worker.ts index b19610cfa..0481c359a 100644 --- a/packages/alchemy/src/Cloudflare/Workers/Worker.ts +++ b/packages/alchemy/src/Cloudflare/Workers/Worker.ts @@ -1534,8 +1534,28 @@ export const LiveWorkerProvider = () => const newSqliteClasses: string[] = []; const renamedClasses: { from: string; to: string }[] = []; for (const binding of currentDoBindings) { - const previousClassName = + let previousClassName: string | undefined = oldDoClassNameByLogicalId[binding.logicalId]; + if (!previousClassName) { + // No `alchemy:do:` tag maps this logical id to a class — the + // worker was created outside Alchemy (raw API / Wrangler) or + // before these tags existed. Fall back to matching the observed + // cloud binding by binding name so adoption reuses the existing + // class instead of asking Cloudflare to create one that already + // exists (which fails the migration). This is the "first deploy + // must match the existing class name" path; once we write the + // `alchemy:do:` tag, subsequent renames are driven by logical id. + const observed = oldBindings.find( + (old) => + old.type === "durable_object_namespace" && + "className" in old && + old.className && + old.name === binding.bindingName, + ); + if (observed && "className" in observed && observed.className) { + previousClassName = observed.className; + } + } if (!previousClassName) { // Default all new Durable Object classes to SQLite. Cloudflare // recommends SQLite for new namespaces, and container-backed diff --git a/packages/alchemy/test/Cloudflare/Workers/DurableObjectNamespace.test.ts b/packages/alchemy/test/Cloudflare/Workers/DurableObjectNamespace.test.ts index 7b62a44e8..a9fcefe9a 100644 --- a/packages/alchemy/test/Cloudflare/Workers/DurableObjectNamespace.test.ts +++ b/packages/alchemy/test/Cloudflare/Workers/DurableObjectNamespace.test.ts @@ -1,5 +1,8 @@ +import { adopt } from "@/AdoptPolicy"; import * as Cloudflare from "@/Cloudflare"; +import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; import * as Test from "@/Test/Vitest"; +import * as workers from "@distilled.cloud/cloudflare/workers"; import { expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; import { MinimumLogLevel } from "effect/References"; @@ -348,3 +351,87 @@ export default { async fetch() { return new Response("v4"); } }; }).pipe(logLevel), { timeout: 180_000 }, ); + +// Adopt a Durable Object class that already exists on a worker created +// *outside* Alchemy (raw Cloudflare API here, standing in for Wrangler or the +// dashboard). The worker carries no `alchemy:*` ownership tags and no +// `alchemy:do:` logical-id→class mapping, so `read` returns `Unowned` and the +// takeover requires `adopt(true)`. The matching `Counter` class must be reused +// — if `putWorker` instead asked Cloudflare to create it as a *new* class the +// migration would fail with "class already exists". This is the documented +// limitation: on the first (adopting) deploy the binding's class name must +// match the existing class; renames only work once Alchemy owns the worker. +test.provider( + "adopts a durable object class created outside alchemy", + (scratch) => + Effect.gen(function* () { + const { accountId } = yield* CloudflareEnvironment; + + // Phase 1: provision a worker + `Counter` DO class straight through the + // Cloudflare API — no Alchemy involvement, so none of our tags. + const physicalName = `alchemy-test-do-adopt-${Math.random() + .toString(36) + .slice(2, 8)}`; + + yield* workers.putScript({ + accountId, + scriptName: physicalName, + metadata: { + mainModule: "main.js", + bindings: [ + { + type: "durable_object_namespace", + name: "Counter", + className: "Counter", + }, + ], + migrations: { + newSqliteClasses: ["Counter"], + }, + // Match Alchemy's default compatibility date so adoption is a + // pure class-reuse with no compat-date churn. Old dates predate + // `DurableObjectNamespace.getByName`, which `hostWorkerScript` + // relies on. + compatibilityDate: "2026-03-17", + }, + files: [ + new File([hostWorkerScript], "main.js", { + type: "application/javascript+module", + }), + ], + }); + + // Phase 2: deploy an async Alchemy Worker (inline script) over the same + // physical name with a matching `Counter` binding, opting in to the + // takeover via `adopt(true)`. + const adopted = yield* scratch + .deploy( + Effect.gen(function* () { + return yield* Cloudflare.Worker("AdoptDO", { + name: physicalName, + script: hostWorkerScript, + env: { + Counter: Cloudflare.DurableObjectNamespace("Counter"), + }, + }); + }), + ) + .pipe(adopt(true)); + + expect(adopted.workerName).toBe(physicalName); + // The existing class was adopted (and resolved to a namespace id), + // not recreated. + expect(adopted.durableObjectNamespaces.Counter).toBeDefined(); + + // The adopted DO is functional end-to-end: increment round-trips + // through the reused `Counter` class. + yield* fetchJsonReady<{ ok: boolean }>(`${adopted.url}/reset`); + const first = yield* fetchJsonReady<{ value: number }>( + `${adopted.url}/increment`, + ); + expect(first.value).toBe(1); + + yield* scratch.destroy(); + }).pipe(logLevel), + { timeout: 120_000 }, +); From 65e518a7493c26ec810e478aa9fc8f02485d1f0c Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Sun, 31 May 2026 22:01:18 -0700 Subject: [PATCH 2/2] docs(cloudflare): document durable object adoption logic Add an "Adopting an Existing Durable Object" section to the DurableObjectNamespace JSDoc explaining the binding-name fallback, the one-time className-match constraint on the adopting deploy, and that renames only work on subsequent deploys once the alchemy:do tag exists. Co-Authored-By: Claude Opus 4.7 --- .../Workers/DurableObjectNamespace.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/packages/alchemy/src/Cloudflare/Workers/DurableObjectNamespace.ts b/packages/alchemy/src/Cloudflare/Workers/DurableObjectNamespace.ts index 37c883802..80f85537a 100644 --- a/packages/alchemy/src/Cloudflare/Workers/DurableObjectNamespace.ts +++ b/packages/alchemy/src/Cloudflare/Workers/DurableObjectNamespace.ts @@ -731,6 +731,57 @@ export class DurableObjectNamespaceScope extends Context.Service< * }, * }); * ``` + * + * @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 + * Durable Object classes are adopted along with it. You opt in to the + * takeover the same way you adopt any foreign resource: with + * `adopt(true)` (or the `--adopt` CLI flag), since `Worker.read` reports + * a worker with no Alchemy ownership tags as `Unowned`. + * + * Alchemy normally tracks which class backs each binding through an + * `alchemy:do::` tag it writes on the script. A + * foreign worker has no such tag, so on the **adopting deploy** Alchemy + * falls back to matching your binding to the live class **by binding + * name**. The class is then reused in place — not recreated — so + * Cloudflare's migration engine doesn't reject the upload for creating a + * class that already exists. + * + * The consequence is a one-time constraint: **on the adopting deploy the + * binding's `className` must match the class that already exists on the + * worker.** You cannot rename the class in the same deploy that adopts + * it. Once the deploy completes, Alchemy owns the worker and has written + * the `alchemy:do:` tag, so subsequent renames are driven by logical id + * and work normally. + * + * @example Adopting a worker whose `Counter` class already exists + * ```typescript + * // The worker + `Counter` class were created outside Alchemy. + * // `className` must match the existing class on this first deploy. + * const worker = yield* Cloudflare.Worker("Worker", { + * name: "existing-worker", + * main: "./src/worker.ts", + * bindings: { + * Counter: Cloudflare.DurableObjectNamespace("Counter"), + * }, + * }).pipe(adopt(true)); + * ``` + * + * @example Renaming the class — only after adoption + * ```typescript + * // A SECOND deploy, after the one above. Alchemy now owns the worker + * // and maps the binding by logical id, so the class can be renamed. + * const worker = yield* Cloudflare.Worker("Worker", { + * name: "existing-worker", + * main: "./src/worker.ts", + * bindings: { + * Counter: Cloudflare.DurableObjectNamespace("Counter", { + * className: "CounterV2", + * }), + * }, + * }); + * ``` */ export const DurableObjectNamespace: DurableObjectNamespaceClass = taggedFunction(