Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions packages/alchemy/src/Cloudflare/Workers/DurableObjectNamespace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<logicalId>:<className>` 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>("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>("Counter", {
* className: "CounterV2",
* }),
* },
* });
* ```
*/
export const DurableObjectNamespace: DurableObjectNamespaceClass =
taggedFunction(
Expand Down
22 changes: 21 additions & 1 deletion packages/alchemy/src/Cloudflare/Workers/Worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 },
);
Loading