1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import { isResourceState, State, type ResourceState } from "@/State";
4import * as Test from "@/Test/Alchemy";
5import * as workers from "@distilled.cloud/cloudflare/workers";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import * as Option from "effect/Option";
9import { MinimumLogLevel } from "effect/References";
10import * as Stream from "effect/Stream";
11
12const { test } = Test.make({ providers: Cloudflare.providers() });
13
14const logLevel = Effect.provideService(
15 MinimumLogLevel,
16 process.env.DEBUG ? "Debug" : "Info",
17);
18
19const script = (marker: string) => `
20export default {
21 async fetch() {
22 return new Response(${JSON.stringify(marker)});
23 }
24}`;
25
26const HEX_ID = /^[0-9a-f]{32}$/;
27
28/** The live script's tag straight from the account's script listing. */
29const liveScriptTag = (accountId: string, scriptName: string) =>
30 workers.listScripts.items({ accountId }).pipe(
31 Stream.filter((s) => s.id === scriptName),
32 Stream.runHead,
33 Effect.map(Option.getOrUndefined),
34 Effect.map((s) => s?.tag ?? undefined),
35 );
36
37test.provider(
38 "workerId is the immutable script ID and survives updates",
39 (stack) =>
40 Effect.gen(function* () {
41 const { accountId } = yield* yield* CloudflareEnvironment;
42
43 yield* stack.destroy();
44
45 const deployWith = (marker: string) =>
46 stack.deploy(Cloudflare.Worker("IdWorker", { script: script(marker) }));
47
48 const v1 = yield* deployWith("v1");
49 // The immutable hex ID — decisively not the script name.
50 expect(v1.workerId).toMatch(HEX_ID);
51 expect(v1.workerId).not.toEqual(v1.workerName);
52 // It is exactly what Cloudflare reports as the script's tag.
53 expect(yield* liveScriptTag(accountId, v1.workerName)).toEqual(
54 v1.workerId,
55 );
56
57 // A code update keeps both identifiers.
58 const v2 = yield* deployWith("v2");
59 expect(v2.workerId).toEqual(v1.workerId);
60 expect(v2.workerName).toEqual(v1.workerName);
61
62 yield* stack.destroy();
63 }).pipe(logLevel),
64 {
65 tags: ["provider:cloudflare", "provider:cloudflare:worker", "live"],
66 timeout: 240_000,
67 },
68);
69
70test.provider(
71 "legacy state carrying the script name in workerId heals on a no-change redeploy",
72 (stack) =>
73 Effect.gen(function* () {
74 yield* stack.destroy();
75
76 const deployWith = (marker: string) =>
77 stack.deploy(
78 Cloudflare.Worker("LegacyIdWorker", { script: script(marker) }),
79 );
80
81 const v1 = yield* deployWith("v1");
82 const realId = v1.workerId;
83 expect(realId).toMatch(HEX_ID);
84
85 // Rewrite the persisted row into the pre-rename shape: older betas
86 // stored the script *name* in `workerId`.
87 const state = yield* yield* State;
88 const stage = stack.stage;
89 const fqns = yield* state.list({ stack: stack.name, stage });
90 const rows = yield* Effect.forEach(fqns, (fqn) =>
91 state
92 .get({ stack: stack.name, stage, fqn })
93 .pipe(Effect.map((row) => ({ fqn, row }))),
94 );
95 const workerRow = rows.find(
96 (r): r is { fqn: string; row: ResourceState } =>
97 isResourceState(r.row) && r.row.resourceType === "Cloudflare.Worker",
98 );
99 if (!workerRow) {
100 return yield* Effect.die(new Error("no Worker state row after deploy"));
101 }
102 const attr = workerRow.row.attr as { workerId?: string } | undefined;
103 expect(attr?.workerId).toEqual(realId);
104 yield* state.set({
105 stack: stack.name,
106 stage,
107 fqn: workerRow.fqn,
108 value: {
109 ...workerRow.row,
110 attr: { ...(attr ?? {}), workerId: v1.workerName },
111 },
112 });
113
114 // A NO-CHANGE redeploy must heal: diff detects the legacy shape and
115 // plans an update even though no prop changed, and reconcile records
116 // the real immutable ID again (from the upload response).
117 const healed = yield* deployWith("v1");
118 expect(healed.workerId).toEqual(realId);
119 expect(healed.workerId).not.toEqual(healed.workerName);
120
121 yield* stack.destroy();
122 }).pipe(logLevel),
123 {
124 tags: ["provider:cloudflare", "provider:cloudflare:worker", "live"],
125 timeout: 240_000,
126 },
127);
128
129test.provider(
130 "state loss: adoption re-records the same immutable ID",
131 (stack) =>
132 Effect.gen(function* () {
133 yield* stack.destroy();
134
135 // A deterministic name: with auto-generated names, state loss mints a
136 // fresh instance id (and so a fresh script) — adoption is only
137 // meaningful when the physical name is stable.
138 const deploy = () =>
139 stack.deploy(
140 Cloudflare.Worker("AdoptIdWorker", {
141 name: "alchemy-test-workerid-adopt",
142 script: script("v1"),
143 }),
144 );
145
146 const v1 = yield* deploy();
147 expect(v1.workerId).toMatch(HEX_ID);
148
149 // Simulate state loss: the script lives on in Cloudflare, but the
150 // engine has no row for it.
151 const state = yield* yield* State;
152 const stage = stack.stage;
153 const fqns = yield* state.list({ stack: stack.name, stage });
154 const rows = yield* Effect.forEach(fqns, (fqn) =>
155 state
156 .get({ stack: stack.name, stage, fqn })
157 .pipe(Effect.map((row) => ({ fqn, row }))),
158 );
159 const workerRow = rows.find(
160 (r) =>
161 isResourceState(r.row) && r.row.resourceType === "Cloudflare.Worker",
162 );
163 if (!workerRow) {
164 return yield* Effect.die(new Error("no Worker state row after deploy"));
165 }
166 yield* state.delete({ stack: stack.name, stage, fqn: workerRow.fqn });
167
168 // The re-deploy upserts the same script — same name, same immutable
169 // ID (proof the script was adopted, not replaced).
170 const readopted = yield* deploy();
171 expect(readopted.workerName).toEqual(v1.workerName);
172 expect(readopted.workerId).toEqual(v1.workerId);
173
174 yield* stack.destroy();
175 }).pipe(logLevel),
176 {
177 tags: ["provider:cloudflare", "provider:cloudflare:worker", "live"],
178 timeout: 240_000,
179 },
180);
181
182test.provider(
183 "a version worker carries its parent script's workerId",
184 (stack) =>
185 Effect.gen(function* () {
186 yield* stack.destroy();
187
188 const { parent, preview } = yield* stack.deploy(
189 Effect.gen(function* () {
190 const parent = yield* Cloudflare.Worker("IdVersionParent", {
191 script: script("parent"),
192 });
193 const preview = yield* Cloudflare.Worker("IdVersionPreview", {
194 script: script("preview"),
195 version: { parent, message: "workerId test" },
196 });
197 return { parent, preview };
198 }),
199 );
200
201 expect(parent.workerId).toMatch(HEX_ID);
202 // The version worker owns no script of its own — it carries the
203 // parent's immutable ID (resolved through the listing lookup), so
204 // Access preview destinations protect its preview URLs.
205 expect(preview.workerId).toEqual(parent.workerId);
206 expect(preview.versionOf).toEqual(parent.workerName);
207
208 yield* stack.destroy();
209 }).pipe(logLevel),
210 {
211 tags: ["provider:cloudflare", "provider:cloudflare:worker", "live"],
212 timeout: 240_000,
213 },
214);