t3-code-android-nightly/.repos/alchemy-effect/packages/alchemy/test/Cloudflare/Workers/DurableObjectNamespace.test.ts
Julius Marminge 6f9cea00ae
chore(refs): sync Effect and Alchemy references to 4.0.1 and beta.80 (#16170)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 13:22:30 -07:00

1535 lines
58 KiB
TypeScript

import { adopt } from "@/AdoptPolicy";
import * as Cloudflare from "@/Cloudflare";
import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
import * as Output from "@/Output";
import * as Test from "@/Test/Alchemy";
import * as durableObjects from "@distilled.cloud/cloudflare/durable-objects";
import * as workers from "@distilled.cloud/cloudflare/workers";
import { describe, expect } from "alchemy-test";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import { MinimumLogLevel } from "effect/References";
import * as Schedule from "effect/Schedule";
import * as Stream from "effect/Stream";
import * as HttpClient from "effect/http/HttpClient";
import * as HttpClientRequest from "effect/http/HttpClientRequest";
import { getWorkerTags } from "../Utils/Worker.ts";
import Stack from "./fixtures/do-rpc/stack.ts";
const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
providers: Cloudflare.providers(),
});
const logLevel = Effect.provideService(
MinimumLogLevel,
process.env.DEBUG ? "Debug" : "Info",
);
const stack = beforeAll(deploy(Stack));
afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack));
// Cap exponential backoff at 3s — keeps the fast-path snappy but stops
// the geometric blow-up (0.5 + 1 + 2 + 4 + 8 + 16 + 32 + 64s ...) that
// makes retries dominate test wall time when CF edge is slow.
const readinessSchedule = Schedule.min([
Schedule.exponential("500 millis"),
Schedule.spaced("3 seconds"),
]);
const readinessRetries = 15;
// The test runtime's HttpClient (FetchHttpClient/undici) keeps HTTP/1.1
// connections alive and pooled. A pooled connection stays pinned to a single
// Cloudflare edge metal, so when that metal lags the freshly-deployed version
// every retry rides the same stale socket and keeps reading the old body for
// the life of the keep-alive — even though the new version is already live.
// Forcing `Connection: close` makes each readiness attempt open a fresh
// connection, letting it land on an edge that has the new version (this is
// why a brand-new `curl` sees the update immediately while a kept-alive
// client does not). See do-rpc DurableObject test investigation.
const freshConn = HttpClient.mapRequest(
HttpClientRequest.setHeader("connection", "close"),
);
test(
"durable object methods can use binding clients",
Effect.gen(function* () {
const { url } = yield* stack;
const client = yield* HttpClient.HttpClient;
const res = yield* client.post(`${url}/roundtrip`).pipe(
Effect.flatMap((res) =>
res.status === 200
? Effect.succeed(res)
: Effect.fail(new Error(`Worker not ready: ${res.status}`)),
),
Effect.retry({ schedule: readinessSchedule, times: 15 }),
);
expect(res.status).toBe(200);
const body = (yield* res.json) as { value: string };
expect(body.value).toBe("ok");
}).pipe(logLevel),
{
tags: [
"provider:cloudflare",
"provider:cloudflare:kv",
"provider:cloudflare:worker",
"live",
],
timeout: 60_000,
},
);
class DurableObjectLocationNotReady extends Data.TaggedError(
"DurableObjectLocationNotReady",
)<{ readonly message: string }> {}
const locationFor = Effect.fn(function* (
url: string,
options: { name?: string; hint?: Cloudflare.DurableObjectLocationHint } = {},
) {
const client = freshConn(yield* HttpClient.HttpClient);
const name = options.name ?? (yield* Effect.sync(() => crypto.randomUUID()));
const query = `name=${encodeURIComponent(name)}${options.hint ? `&hint=${options.hint}` : ""}`;
return yield* client.get(`${url}/colo?${query}`).pipe(
Effect.flatMap((res) =>
Effect.gen(function* () {
if (res.status !== 200) {
return yield* new DurableObjectLocationNotReady({
message: `Worker not ready: ${res.status}`,
});
}
const body = (yield* res.json) as {
id: string;
colo: string;
locationHintRead: boolean;
};
if (!body.id || !body.colo || body.colo === "unknown") {
return yield* new DurableObjectLocationNotReady({
message: `Instance not ready: ${JSON.stringify(body)}`,
});
}
return body;
}),
),
Effect.retry({ schedule: readinessSchedule, times: readinessRetries }),
);
});
// Nearby placement is best-effort; named identity is the stable contract.
test(
"unhinted lookups preserve named instance identity",
Effect.gen(function* () {
const { url } = yield* stack;
const name = yield* Effect.sync(() => crypto.randomUUID());
const [first, second] = yield* Effect.all(
[locationFor(url, { name }), locationFor(url, { name })],
{ concurrency: 2 },
);
const other = yield* locationFor(url);
expect(first.id).toMatch(/^[0-9a-f]{64}$/);
expect(first.id).toBe(second.id);
expect(first.id).not.toBe(other.id);
for (const result of [first, second, other]) {
expect(result.locationHintRead).toBe(false);
}
}).pipe(logLevel),
{
tags: [
"provider:cloudflare",
"provider:cloudflare:kv",
"provider:cloudflare:worker",
"live",
],
timeout: 60_000,
},
);
// The getter is read by the real native namespace only when options reach it.
test(
"locationHint reaches the native namespace for new instances",
Effect.gen(function* () {
const { url } = yield* stack;
const [wnam, apac] = yield* Effect.all(
[locationFor(url, { hint: "wnam" }), locationFor(url, { hint: "apac" })],
{ concurrency: 2 },
);
expect(wnam.locationHintRead).toBe(true);
expect(apac.locationHintRead).toBe(true);
expect(wnam.id).not.toBe(apac.id);
}).pipe(logLevel),
{
tags: [
"provider:cloudflare",
"provider:cloudflare:kv",
"provider:cloudflare:worker",
"live",
],
timeout: 60_000,
},
);
// Reproduces the `tick` streaming example from the Durable Objects tutorial:
// https://alchemy.run/cloudflare/compute/durable-objects
//
// The DO exposes `tick(n): Stream<number>` and the Worker forwards it to the
// HTTP response with `HttpServerResponse.stream`. The client reads the body as
// newline-delimited integers. With `/tick/5` we expect ["0","1","2","3","4"].
test(
"tick streams sequential values from a durable object (tutorial repro)",
Effect.gen(function* () {
const { url } = yield* stack;
const client = freshConn(yield* HttpClient.HttpClient);
const lines = yield* client.get(`${url}/tick/5`).pipe(
Effect.flatMap((res) =>
res.status === 200
? res.stream.pipe(
Stream.decodeText,
Stream.splitLines,
Stream.filter((line) => line.length > 0),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
// A cold edge can answer 200 with an empty/placeholder body
// (the script isn't serving yet), which collects to `[]`
// instead of failing — fail so the readiness retry rides it out
// rather than asserting against an empty stream.
Effect.flatMap((rows) =>
rows.length > 0
? Effect.succeed(rows)
: Effect.fail(new Error("Worker not ready: empty stream")),
),
)
: Effect.fail(new Error(`Worker not ready: ${res.status}`)),
),
Effect.retry({ schedule: readinessSchedule, times: readinessRetries }),
);
expect(lines).toEqual(["0", "1", "2", "3", "4"]);
}).pipe(logLevel),
{
tags: [
"provider:cloudflare",
"provider:cloudflare:kv",
"provider:cloudflare:worker",
"live",
],
timeout: 60_000,
},
);
// While a freshly pre-created worker is propagating, Cloudflare's edge
// serves Alchemy's pre-create stub, which responds 200 with this plain-text
// body. It is not the real script, so any poll that sees it must retry.
const DEPLOY_PLACEHOLDER = "Alchemy worker is being deployed...";
// Cloudflare's edge keeps serving the previous worker version (or the
// pre-create stub) for a while after a (re)deploy, so retrying on 200-only
// is not enough — the stale version still returns 200 with the old body.
// Retry until the body matches the expected version string.
const fetchReady = (url: string, expected: string) =>
Effect.gen(function* () {
const client = freshConn(yield* HttpClient.HttpClient);
return yield* client.get(url).pipe(
Effect.flatMap((r) =>
r.status === 200
? Effect.flatMap(r.text, (body) =>
body === expected
? Effect.succeed(body)
: Effect.fail(
new Error(`stale: got ${body}, want ${expected}`),
),
)
: Effect.fail(new Error(`Worker not ready: ${r.status}`)),
),
Effect.retry({ schedule: readinessSchedule, times: readinessRetries }),
);
});
const fetchJsonReady = <T>(url: string) =>
Effect.gen(function* () {
const client = freshConn(yield* HttpClient.HttpClient);
return yield* client.get(url).pipe(
// Parse the body INSIDE the retry: the pre-create stub answers 200 with
// a non-JSON placeholder, so JSON decoding must be part of the readiness
// check (a 200 status alone does not mean the real script is live yet).
Effect.flatMap((r) =>
r.status !== 200
? Effect.flatMap(r.text, (body) =>
Effect.fail(
new Error(
`Worker not ready at ${url}: ${r.status} ${body.slice(0, 500)}`,
),
),
)
: Effect.flatMap(r.text, (body) =>
body.includes(DEPLOY_PLACEHOLDER)
? Effect.fail(new Error("stale: still deploying"))
: Effect.try({
try: () => JSON.parse(body) as T,
catch: () => new Error(`non-json body: ${body}`),
}),
),
),
Effect.retry({ schedule: readinessSchedule, times: readinessRetries }),
);
});
const hostWorkerScript = `import { DurableObject } from "cloudflare:workers";
export class Counter extends DurableObject {
async increment() {
const value = (await this.ctx.storage.get("count")) ?? 0;
const next = value + 1;
await this.ctx.storage.put("count", next);
return next;
}
async reset() {
await this.ctx.storage.delete("count");
}
async get() {
return (await this.ctx.storage.get("count")) ?? 0;
}
}
export default {
async fetch(request, env) {
const stub = env.Counter.getByName("shared");
const url = new URL(request.url);
if (url.pathname === "/increment") {
return Response.json({ value: await stub.increment() });
}
if (url.pathname === "/get") {
return Response.json({ value: await stub.get() });
}
if (url.pathname === "/reset") {
await stub.reset();
return Response.json({ ok: true });
}
return new Response("Not Found", { status: 404 });
},
};
`;
const consumerWorkerScript = `export default {
async fetch(request, env) {
const stub = env.Counter.getByName("shared");
const url = new URL(request.url);
if (url.pathname === "/increment") {
return Response.json({ value: await stub.increment() });
}
if (url.pathname === "/get") {
return Response.json({ value: await stub.get() });
}
if (url.pathname === "/reset") {
await stub.reset();
return Response.json({ ok: true });
}
return new Response("Not Found", { status: 404 });
},
};
`;
// Every `test.provider` test below owns an isolated scratch stack (private
// in-memory state, physical names derived from the unique test name), so
// none of them can observe another's cloud resources. Run them concurrently:
// each test's wall time is dominated by workers.dev edge-propagation waits,
// so overlapping them collapses the file from the sum of all tests (~7 min)
// to roughly the slowest single test.
describe.concurrent(
"scratch",
{
tags: [
"provider:cloudflare",
"provider:cloudflare:kv",
"provider:cloudflare:worker",
"live",
],
},
() => {
test.provider(
"async worker durable object binding accepts scriptName",
(scratch) =>
Effect.gen(function* () {
yield* scratch.deploy(
Effect.gen(function* () {
return {
host: yield* Cloudflare.Worker("host-worker", {
script: hostWorkerScript,
env: {
Counter: Cloudflare.DurableObject("Counter"),
},
}),
};
}),
);
const deployed = yield* scratch.deploy(
Effect.gen(function* () {
const host = yield* Cloudflare.Worker("host-worker", {
script: hostWorkerScript,
env: {
Counter: Cloudflare.DurableObject("Counter"),
},
});
const consumer = yield* Cloudflare.Worker("consumer-worker", {
script: consumerWorkerScript,
env: {
Counter: Cloudflare.DurableObject("Counter", {
scriptName: host.workerName,
}),
},
});
return { consumer, host };
}),
);
const reset = yield* fetchJsonReady<{ ok: boolean }>(
`${deployed.host.url}/reset`,
);
expect(reset.ok).toBe(true);
const first = yield* fetchJsonReady<{ value: number }>(
`${deployed.consumer.url}/increment`,
);
expect(first.value).toBe(1);
const second = yield* fetchJsonReady<{ value: number }>(
`${deployed.host.url}/get`,
);
expect(second.value).toBe(1);
yield* scratch.destroy();
}).pipe(logLevel),
{ timeout: 60_000 },
);
// Walk an async worker through four redeploys against the same scratch state,
// each one swapping in a new script + bindings shape so we exercise the
// migration paths `putWorker` relies on:
// v1 — create with a single DO class `DO_A`
// v2 — rename `DO_A` → `DO_A_v2` (className change, same binding id)
// v3 — add a brand-new DO class `DO_B` alongside `DO_A_v2`
// v4 — delete `DO_A`, keep only `DO_B`
test.provider(
"durable object class migrations across redeploys",
(scratch) =>
Effect.gen(function* () {
const v1 = yield* scratch.deploy(
Effect.gen(function* () {
return {
worker: yield* Cloudflare.Worker("worker", {
script: `import { DurableObject } from "cloudflare:workers";
export class DO_A extends DurableObject {}
export default { async fetch() { return new Response("v1"); } };
`,
env: {
DO_A: Cloudflare.DurableObject("DO_A"),
},
}),
};
}),
);
expect(yield* fetchReady(v1.worker.url!, "v1")).toBe("v1");
const v2 = yield* scratch.deploy(
Effect.gen(function* () {
return {
worker: yield* Cloudflare.Worker("worker", {
script: `import { DurableObject } from "cloudflare:workers";
export class DO_A_v2 extends DurableObject {}
export default { async fetch() { return new Response("v2"); } };
`,
env: {
DO_A: Cloudflare.DurableObject("DO_A", {
className: "DO_A_v2",
}),
},
}),
};
}),
);
expect(yield* fetchReady(v2.worker.url!, "v2")).toBe("v2");
const v3 = yield* scratch.deploy(
Effect.gen(function* () {
return {
worker: yield* Cloudflare.Worker("worker", {
script: `import { DurableObject } from "cloudflare:workers";
export class DO_A_v2 extends DurableObject {}
export class DO_B extends DurableObject {}
export default { async fetch() { return new Response("v3"); } };
`,
env: {
DO_A: Cloudflare.DurableObject("DO_A", {
className: "DO_A_v2",
}),
DO_B: Cloudflare.DurableObject("DO_B"),
},
}),
};
}),
);
expect(yield* fetchReady(v3.worker.url!, "v3")).toBe("v3");
const v4 = yield* scratch.deploy(
Effect.gen(function* () {
return {
worker: yield* Cloudflare.Worker("worker", {
script: `import { DurableObject } from "cloudflare:workers";
export class DO_B extends DurableObject {}
export default { async fetch() { return new Response("v4"); } };
`,
env: {
DO_B: Cloudflare.DurableObject("DO_B"),
},
}),
};
}),
);
expect(yield* fetchReady(v4.worker.url!, "v4")).toBe("v4");
yield* scratch.destroy();
}).pipe(logLevel),
{ timeout: 180_000 },
);
// Reproduces #763: a Worker's precreate stub must declare Durable Object
// classes that exist only as env *bindings* — an async worker has no
// Effect-native exports, so before the fix the stub declared no DO classes at
// all. A resource caught in a dependency cycle with the worker resolves
// `worker.durableObjectNamespaces` against that stub (not the final reconcile
// output), so a binding-only class had no namespace id there and the deploy
// failed with "Worker did not expose Durable Object namespace <class>" on
// every fresh stage's FIRST deploy (reruns found the script already existing
// and passed — which is why this must run on a scratch stack, not a shared
// beforeAll deploy).
//
// The cycle here mirrors the worker<->container shape from the issue (a bare
// `Cloudflare.DurableObject` fronting a Container, where the container binds
// the DO's namespace id and the worker binds the container) without needing a
// Docker build: the consumer worker binds the host's `Counter` namespace id,
// and the host binds the consumer's name back.
test.provider(
"precreate stub exposes binding-only durable object namespaces to cycle peers",
(scratch) =>
Effect.gen(function* () {
const deployed = yield* scratch.deploy(
Effect.gen(function* () {
// `Counter` lives only in the inline script + env binding — it is
// never an Effect-native export.
const host = yield* Cloudflare.Worker("host-worker", {
script: hostWorkerScript,
env: {
Counter: Cloudflare.DurableObject("Counter"),
},
});
const consumer = yield* Cloudflare.Worker("consumer-worker", {
script: `export default {
async fetch(request, env) {
return Response.json({ namespaceId: env.COUNTER_NS });
},
};
`,
});
// consumer -> host: resolve the DO namespace id, exactly how a
// Container application binds `durableObjects.namespaceId`. The
// throw fires when the precreate stub omits the class — failing the
// deploy the same way the container cycle in #763 did.
yield* consumer.bind("counter-namespace", {
bindings: [
{
type: "plain_text",
name: "COUNTER_NS",
text: host.durableObjectNamespaces.pipe(
Output.map((namespaces) => {
const id = namespaces.Counter;
if (!id) {
throw new Error(
"Worker did not expose Durable Object namespace Counter.",
);
}
return id;
}),
),
},
],
});
// host -> consumer: closes the cycle so both workers are SCC
// members and rendezvous on each other's precreate stubs.
yield* host.bind("consumer-name", {
bindings: [
{
type: "plain_text",
name: "CONSUMER_NAME",
text: consumer.workerName,
},
],
});
return { host, consumer };
}),
);
// The namespace id created for the precreate stub must survive into the
// final reconcile output...
const finalNamespaceId =
deployed.host.durableObjectNamespaces.Counter;
expect(finalNamespaceId).toBeDefined();
// ...and be the same id the consumer resolved from the stub and
// deployed into its live environment.
const body = yield* fetchJsonReady<{ namespaceId: string }>(
deployed.consumer.url!,
);
expect(body.namespaceId).toBe(finalNamespaceId);
yield* scratch.destroy();
}).pipe(logLevel),
{ timeout: 120_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* yield* CloudflareEnvironment;
// Phase 1: provision a worker + `Counter` DO class straight through the
// Cloudflare API — no Alchemy involvement, so none of our tags. The
// name is deterministic (never Date.now()/random); a crashed run can
// leave the worker behind, and `putScript` would then reject the
// `newSqliteClasses` migration ("class already exists"), so purge any
// leftover first.
const physicalName = "alchemy-test-do-adopt";
yield* workers
.deleteScript({ accountId, scriptName: physicalName, force: true })
.pipe(Effect.catchTag("WorkerNotFound", () => Effect.void));
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.DurableObject("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 },
);
// #799: moving a Durable Object class from one Worker to another is
// *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` 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 declared by worker logical id preserves 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"),
},
}),
};
}),
);
const originalNamespaceId = v1.b.durableObjectNamespaces.Counter;
expect(originalNamespaceId).toBeDefined();
// 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);
const moved = Effect.gen(function* () {
const a = yield* Cloudflare.Worker("worker-a", {
script: hostWorkerScript,
env: {
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", {
scriptName: a.workerName,
}),
},
});
return { a, b };
});
const v2 = yield* scratch.deploy(moved);
expect(v2.a.durableObjectNamespaces.Counter).toBe(
originalNamespaceId,
);
expect(v2.b.durableObjectNamespaces.Counter).toBe(
originalNamespaceId,
);
// 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 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`,
);
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 },
);
// #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 by physical script name transfers the namespace",
(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", {
// Thunk form — evaluated lazily at plan time and
// normalized to the plain script name.
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): 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(
"undeclared host move fails with DurableObjectTransferRequired",
(scratch) =>
Effect.gen(function* () {
yield* scratch.destroy();
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"),
},
}),
};
}),
);
// 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 aScriptName = v1.a.workerName;
const error = yield* scratch
.deploy(
Effect.gen(function* () {
const a = yield* Cloudflare.Worker("worker-a", {
script: hostWorkerScript,
env: {
// Fresh, empty namespace for the same class name — the
// move is not declared, so nothing transfers.
Counter: Cloudflare.DurableObject("Counter"),
},
});
const b = yield* Cloudflare.Worker("worker-b", {
script: consumerWorkerScript,
env: {
Counter: Cloudflare.DurableObject("Counter", {
scriptName: aScriptName,
}),
},
});
return { a, b };
}),
)
.pipe(Effect.flip);
expect(error._tag).toEqual("DurableObjectTransferRequired");
expect(
(yield* fetchJsonReady<{ value: number }>(`${v1.b.url}/get`)).value,
).toBe(1);
yield* scratch.destroy();
}).pipe(logLevel),
{ timeout: 240_000 },
);
test.provider(
"a transferred namespace cannot be rebound to a different namespace",
(scratch) =>
Effect.gen(function* () {
yield* scratch.destroy();
const { accountId } = yield* yield* CloudflareEnvironment;
const v1 = yield* scratch.deploy(
Effect.gen(function* () {
return {
a: yield* Cloudflare.Worker("worker-a", {
script: hostWorkerScript,
env: { Counter: Cloudflare.DurableObject("Counter") },
}),
};
}),
);
const originalNamespaceId = v1.a.durableObjectNamespaces.Counter;
expect(originalNamespaceId).toBeDefined();
yield* fetchJsonReady<{ ok: boolean }>(`${v1.a.url}/reset`);
expect(
(yield* fetchJsonReady<{ value: number }>(`${v1.a.url}/increment`))
.value,
).toBe(1);
const transferredStack = (scriptName?: string) =>
Effect.gen(function* () {
const a = yield* Cloudflare.Worker("worker-a", {
script:
scriptName === undefined
? hostWorkerScript
: consumerWorkerScript,
env: {
Counter:
scriptName === undefined
? Cloudflare.DurableObject("Counter")
: Cloudflare.DurableObject("Counter", { scriptName }),
},
});
const b = yield* Cloudflare.Worker("worker-b", {
script: hostWorkerScript,
env: {
Counter: Cloudflare.DurableObject("Counter", {
transferredFrom: a,
}),
},
});
const c = yield* Cloudflare.Worker("worker-c", {
script: hostWorkerScript,
env: { Counter: Cloudflare.DurableObject("Counter") },
});
return { a, b, c };
});
// A stays unchanged while B receives its namespace; C owns a fresh one.
const v2 = yield* scratch.deploy(transferredStack());
expect(v2.b.durableObjectNamespaces.Counter).toBe(
originalNamespaceId,
);
const unrelatedNamespaceId = v2.c.durableObjectNamespaces.Counter;
expect(unrelatedNamespaceId).toBeDefined();
expect(unrelatedNamespaceId).not.toBe(originalNamespaceId);
const namespaces = yield* durableObjects.listNamespaces
.items({ accountId })
.pipe(
Stream.runCollect,
Effect.repeat({
schedule: Schedule.spaced("1 second"),
times: 8,
until: (namespaces) =>
namespaces.some(
(ns) =>
ns.id === originalNamespaceId &&
ns.script === v2.b.workerName,
) &&
namespaces.some(
(ns) =>
ns.id === unrelatedNamespaceId &&
ns.script === v2.c.workerName,
) &&
!namespaces.some(
(ns) =>
ns.script === v2.a.workerName && ns.class === "Counter",
),
}),
);
expect(
namespaces.find((ns) => ns.id === originalNamespaceId),
).toMatchObject({ script: v2.b.workerName, class: "Counter" });
expect(
namespaces.find((ns) => ns.id === unrelatedNamespaceId),
).toMatchObject({ script: v2.c.workerName, class: "Counter" });
expect(
namespaces.some(
(ns) => ns.script === v2.a.workerName && ns.class === "Counter",
),
).toBe(false);
expect(
(yield* fetchJsonReady<{ value: number }>(`${v2.b.url}/get`)).value,
).toBe(1);
expect(
(yield* fetchJsonReady<{ value: number }>(`${v2.c.url}/get`)).value,
).toBe(0);
const error = yield* scratch
.deploy(transferredStack(v2.c.workerName))
.pipe(Effect.flip);
expect(error._tag).toBe("DurableObjectTransferRequired");
expect(
(yield* fetchJsonReady<{ value: number }>(`${v2.b.url}/get`)).value,
).toBe(1);
expect(
(yield* fetchJsonReady<{ value: number }>(`${v2.c.url}/get`)).value,
).toBe(0);
yield* scratch.destroy();
}).pipe(logLevel),
{ timeout: 120_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 },
);
// Reproduces #811: one script tag per DO binding blew Cloudflare's 10-tag
// limit at 7+ Durable Objects (3 ownership tags + 1 migration tag + N DO
// tags). The logical-id→class mapping is now packed into `alchemy:dos:`
// tags, so a worker with 20 DOs — double the count that used to consume the
// entire tag budget — deploys fine and the whole tag set stays within the
// limit. The second deploy renames a class and drops another to prove
// migrations are still driven by the packed mapping.
test.provider(
"worker with 20 durable objects deploys within the 10-tag limit",
(scratch) =>
Effect.gen(function* () {
yield* scratch.destroy();
const { accountId } = yield* yield* CloudflareEnvironment;
const ids = Array.from({ length: 20 }, (_, i) => `DO_${i}`);
const makeScript = (classes: string[], version: string) =>
`import { DurableObject } from "cloudflare:workers";
${classes.map((c) => `export class ${c} extends DurableObject {}`).join("\n")}
export default { async fetch() { return new Response("${version}"); } };
`;
const v1 = yield* scratch.deploy(
Effect.gen(function* () {
return {
worker: yield* Cloudflare.Worker("worker", {
script: makeScript(
ids.map((_, i) => `Class${i}`),
"v1",
),
env: Object.fromEntries(
ids.map((id, i) => [
id,
Cloudflare.DurableObject(id, { className: `Class${i}` }),
]),
),
}),
};
}),
);
expect(yield* fetchReady(v1.worker.url!, "v1")).toBe("v1");
const tags = yield* getWorkerTags(v1.worker.workerName, accountId);
expect(tags.length).toBeLessThanOrEqual(10);
expect(tags.filter((t) => t.startsWith("alchemy:dos:"))).toHaveLength(
1,
);
expect(tags.filter((t) => t.startsWith("alchemy:do:"))).toHaveLength(
0,
);
expect(Object.keys(v1.worker.durableObjectNamespaces).sort()).toEqual(
ids.map((_, i) => `Class${i}`).sort(),
);
// Rename Class0 → Class0V2 (same binding id) and delete DO_19 — both
// migrations resolve their previous class through the packed tag.
const v2 = yield* scratch.deploy(
Effect.gen(function* () {
return {
worker: yield* Cloudflare.Worker("worker", {
script: makeScript(
[
"Class0V2",
...ids.slice(1, 19).map((_, i) => `Class${i + 1}`),
],
"v2",
),
env: Object.fromEntries(
ids.slice(0, 19).map((id, i) => [
id,
Cloudflare.DurableObject(id, {
className: i === 0 ? "Class0V2" : `Class${i}`,
}),
]),
),
}),
};
}),
);
expect(yield* fetchReady(v2.worker.url!, "v2")).toBe("v2");
expect(Object.keys(v2.worker.durableObjectNamespaces).sort()).toEqual(
[
"Class0V2",
...ids.slice(1, 19).map((_, i) => `Class${i + 1}`),
].sort(),
);
expect(v2.worker.durableObjectNamespaces.Class0V2).toBe(
v1.worker.durableObjectNamespaces.Class0,
);
expect(v2.worker.durableObjectNamespaces.Class19).toBeUndefined();
for (let i = 1; i < 19; i++) {
expect(v2.worker.durableObjectNamespaces[`Class${i}`]).toBe(
v1.worker.durableObjectNamespaces[`Class${i}`],
);
}
const namespaces = yield* durableObjects.listNamespaces
.items({ accountId })
.pipe(
Stream.runCollect,
Effect.map((namespaces) =>
namespaces.filter((ns) => ns.script === v2.worker.workerName),
),
Effect.repeat({
schedule: Schedule.spaced("2 seconds"),
until: (namespaces) =>
namespaces.length === 19 &&
new Set(namespaces.map((ns) => ns.id)).size === 19 &&
namespaces.every(
(ns) =>
ns.class !== undefined &&
ns.class !== null &&
v2.worker.durableObjectNamespaces[ns.class] === ns.id,
),
times: 8,
}),
);
expect(namespaces).toHaveLength(19);
expect(namespaces.map((ns) => ns.id).sort()).toEqual(
Object.values(v2.worker.durableObjectNamespaces).sort(),
);
expect(
namespaces.some(
(ns) => ns.id === v1.worker.durableObjectNamespaces.Class19,
),
).toBe(false);
yield* scratch.destroy();
}).pipe(logLevel),
{ timeout: 120_000 },
);
// Roll-forward from the legacy tag format: a worker last deployed by an
// older alchemy carries one `alchemy:do:{logicalId}:{className}` tag per DO.
// Simulate that by rewriting the live script tags to the legacy format
// out-of-band, then redeploy with a class rename — reconcile must parse the
// legacy tags to resolve the previous class, and the deploy rewrites the
// mapping in the packed format.
test.provider(
"rolls forward from legacy per-DO alchemy:do: tags",
(scratch) =>
Effect.gen(function* () {
const { accountId } = yield* yield* CloudflareEnvironment;
const v1 = yield* scratch.deploy(
Effect.gen(function* () {
return {
worker: yield* Cloudflare.Worker("worker", {
script: `import { DurableObject } from "cloudflare:workers";
export class CounterClass extends DurableObject {}
export class MeterClass extends DurableObject {}
export default { async fetch() { return new Response("v1"); } };
`,
env: {
Counter: Cloudflare.DurableObject("Counter", {
className: "CounterClass",
}),
Meter: Cloudflare.DurableObject("Meter", {
className: "MeterClass",
}),
},
}),
};
}),
);
expect(yield* fetchReady(v1.worker.url!, "v1")).toBe("v1");
const scriptName = v1.worker.workerName;
// Rewrite the tags the way alchemy <= beta.62 wrote them: one
// `alchemy:do:` tag per binding, no packed tag.
const deployedTags = yield* getWorkerTags(scriptName, accountId);
yield* workers.patchScriptScriptAndVersionSetting({
accountId,
scriptName,
settings: {
tags: [
...deployedTags.filter((t) => !t.startsWith("alchemy:dos:")),
"alchemy:do:Counter:CounterClass",
"alchemy:do:Meter:MeterClass",
],
},
});
// Redeploy with a rename; the previous class must be resolved from the
// legacy tags for the migration to succeed.
const v2 = yield* scratch.deploy(
Effect.gen(function* () {
return {
worker: yield* Cloudflare.Worker("worker", {
script: `import { DurableObject } from "cloudflare:workers";
export class CounterClassV2 extends DurableObject {}
export class MeterClass extends DurableObject {}
export default { async fetch() { return new Response("v2"); } };
`,
env: {
Counter: Cloudflare.DurableObject("Counter", {
className: "CounterClassV2",
}),
Meter: Cloudflare.DurableObject("Meter", {
className: "MeterClass",
}),
},
}),
};
}),
);
expect(yield* fetchReady(v2.worker.url!, "v2")).toBe("v2");
// The deploy rewrote the mapping in the packed format.
const rolledForward = yield* getWorkerTags(scriptName, accountId);
expect(
rolledForward.filter((t) => t.startsWith("alchemy:dos:")),
).toHaveLength(1);
expect(
rolledForward.filter((t) => t.startsWith("alchemy:do:")),
).toHaveLength(0);
yield* scratch.destroy();
}).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 a noop in v3, but its v2 upload was only moments ago —
// an edge metal can still serve the v1 (cross-script) version, whose
// /get reads worker-a's non-zero counter. Poll until the twin's own
// empty namespace answers: a genuinely stolen/deleted namespace never
// reads 0, so the bounded retry still fails in the regression case.
const twinAfter = yield* fetchJsonReady<{ value: number }>(
`${v3.b.url}/get`,
).pipe(
Effect.flatMap((r) =>
r.value === 0
? Effect.succeed(r)
: Effect.fail(new Error(`stale: twin sees ${r.value}`)),
),
Effect.retry({
schedule: readinessSchedule,
times: readinessRetries,
}),
);
expect(twinAfter.value).toBe(0);
yield* scratch.destroy();
}).pipe(logLevel),
{ timeout: 240_000 },
);
},
);