1import * as Cloudflare from "@/Cloudflare";
2import { STATE_STORE_VERSION } from "@/Cloudflare/StateStore/Api.ts";
3import { State } from "@/State/State.ts";
4import * as Test from "@/Test/Alchemy";
5import { describe, expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7/**
8 * Live-API tests for the deployed Cloudflare State Store at
9 * `alchemy-state-store`. The State service is wired up via
10 * `Cloudflare.state()` so each test body just `yield* yield* State` and
11 * exercises the typed interface — same code path users hit in
12 * production.
13 *
14 * Tests share a single (stack, stage) pair so they can be reasoned
15 * about as a sequence: the first test wipes the namespace, the
16 * remainder build state up and tear it down.
17 */
18
19const STACK = "alchemy_tests";
20const STAGE = "alchemy_tests";
21
22const { test } = Test.make({
23 providers: Cloudflare.providers(),
24 state: Cloudflare.state(),
25});
26
27const sampleState = (fqn: string, instanceId: string) => ({
28 kind: "resource" as const,
29 resourceType: "Test.Resource",
30 namespace: undefined,
31 fqn,
32 logicalId: fqn.split("/").pop()!,
33 instanceId,
34 providerVersion: 1,
35 status: "created" as const,
36 downstream: [],
37 bindings: [],
38 props: { hello: "world" },
39 attr: { id: instanceId },
40});
41
42const replacedState = (fqn: string) => ({
43 kind: "resource" as const,
44 resourceType: "Test.Resource",
45 namespace: undefined,
46 fqn,
47 logicalId: fqn.split("/").pop()!,
48 instanceId: "replaced-new",
49 providerVersion: 1,
50 status: "replaced" as const,
51 downstream: [],
52 bindings: [],
53 deleteFirst: false,
54 props: { hello: "world-new" },
55 attr: { id: "replaced-new" },
56 old: {
57 kind: "resource",
58 resourceType: "Test.Resource",
59 namespace: undefined,
60 fqn,
61 logicalId: fqn.split("/").pop()!,
62 instanceId: "replaced-old",
63 providerVersion: 1,
64 status: "created",
65 downstream: [],
66 bindings: [],
67 props: { hello: "world-old" },
68 attr: { id: "replaced-old" },
69 },
70});
71
72// These cases share a single (stack, stage) namespace and are written to be
73// read as one sequence (first case wipes it, the rest build it up and tear it
74// down). They must run serially under the global concurrent test config.
75describe.sequential(
76 "State",
77 { tags: ["provider:cloudflare", "provider:cloudflare:statestore", "live"] },
78 () => {
79 test(
80 "getVersion returns the current STATE_STORE_VERSION",
81 Effect.gen(function* () {
82 const store = yield* yield* State;
83 const version = yield* store.getVersion();
84 expect(version).toBe(STATE_STORE_VERSION);
85 }),
86 { timeout: 60_000 },
87 );
88
89 test(
90 "DELETE /state/stacks/:stack wipes the test namespace (cleanup)",
91 Effect.gen(function* () {
92 const store = yield* yield* State;
93 yield* store.deleteStack({ stack: STACK });
94 const fqns = yield* store.list({ stack: STACK, stage: STAGE });
95 expect(fqns).toEqual([]);
96 }),
97 { timeout: 60_000 },
98 );
99
100 test(
101 "PUT /resources/:fqn (setState) persists a resource",
102 Effect.gen(function* () {
103 const store = yield* yield* State;
104 const fqn = "stack/scope/resource-a";
105 const value = sampleState(fqn, "inst-a");
106 const echoed = yield* store.set({
107 stack: STACK,
108 stage: STAGE,
109 fqn,
110 value,
111 });
112 expect(echoed.fqn).toBe(fqn);
113 expect(echoed.instanceId).toBe("inst-a");
114 }),
115 { timeout: 60_000 },
116 );
117
118 test(
119 "GET /resources/:fqn (getState) reads back the persisted value",
120 Effect.gen(function* () {
121 const store = yield* yield* State;
122 const fqn = "stack/scope/resource-a";
123 const got = yield* store.get({ stack: STACK, stage: STAGE, fqn });
124 expect(got).toBeDefined();
125 expect(got!.fqn).toBe(fqn);
126 expect((got as any).props).toEqual({ hello: "world" });
127 }),
128 { timeout: 60_000 },
129 );
130
131 test(
132 "GET /resources/:fqn returns undefined for a missing fqn",
133 Effect.gen(function* () {
134 const store = yield* yield* State;
135 const got = yield* store.get({
136 stack: STACK,
137 stage: STAGE,
138 fqn: "stack/scope/does-not-exist",
139 });
140 expect(got).toBeUndefined();
141 }),
142 { timeout: 60_000 },
143 );
144
145 test(
146 "GET /resources (listResources) returns the FQNs in the stage",
147 Effect.gen(function* () {
148 const store = yield* yield* State;
149 const fqnB = "stack/scope/resource-b";
150 yield* store.set({
151 stack: STACK,
152 stage: STAGE,
153 fqn: fqnB,
154 value: sampleState(fqnB, "inst-b"),
155 });
156 const fqns = yield* store.list({ stack: STACK, stage: STAGE });
157 expect([...fqns].sort()).toEqual([
158 "stack/scope/resource-a",
159 "stack/scope/resource-b",
160 ]);
161 }),
162 { timeout: 60_000 },
163 );
164
165 test(
166 "GET /stacks (listStacks) includes the registered test stack",
167 Effect.gen(function* () {
168 const store = yield* yield* State;
169 const stacks = yield* store.listStacks();
170 expect(stacks).toContain(STACK);
171 }),
172 { timeout: 60_000 },
173 );
174
175 test(
176 "GET /stacks/:stack/stages (listStages) includes the test stage",
177 Effect.gen(function* () {
178 const store = yield* yield* State;
179 const stages = yield* store.listStages(STACK);
180 expect([...stages]).toContain(STAGE);
181 }),
182 { timeout: 60_000 },
183 );
184
185 test(
186 "PUT /output (setStackOutput) persists a stack output",
187 Effect.gen(function* () {
188 const store = yield* yield* State;
189 const out = yield* store.setOutput({
190 stack: STACK,
191 stage: STAGE,
192 value: { url: "https://example.com", count: 42 },
193 });
194 expect(out).toEqual({ url: "https://example.com", count: 42 });
195 }),
196 { timeout: 60_000 },
197 );
198
199 test(
200 "GET /output (getStackOutput) reads back the persisted output",
201 Effect.gen(function* () {
202 const store = yield* yield* State;
203 const out = yield* store.getOutput({ stack: STACK, stage: STAGE });
204 expect(out).toEqual({ url: "https://example.com", count: 42 });
205 }),
206 { timeout: 60_000 },
207 );
208
209 test(
210 "GET /output returns undefined for an un-deployed stage",
211 Effect.gen(function* () {
212 const store = yield* yield* State;
213 const out = yield* store.getOutput({
214 stack: STACK,
215 stage: "never-deployed",
216 });
217 expect(out).toBeUndefined();
218 }),
219 { timeout: 60_000 },
220 );
221
222 test(
223 "GET /replaced-resources (getReplacedResources) returns status===replaced rows",
224 Effect.gen(function* () {
225 const store = yield* yield* State;
226 const fqn = "stack/scope/resource-replaced";
227 yield* store.set({
228 stack: STACK,
229 stage: STAGE,
230 fqn,
231 value: replacedState(fqn) as any,
232 });
233 const replaced = yield* store.getReplacedResources({
234 stack: STACK,
235 stage: STAGE,
236 });
237 const fqns = replaced.map((r) => (r as any).fqn);
238 expect(fqns).toContain(fqn);
239 }),
240 { timeout: 60_000 },
241 );
242
243 test(
244 "DELETE /resources/:fqn (deleteState) removes a single resource",
245 Effect.gen(function* () {
246 const store = yield* yield* State;
247 const fqn = "stack/scope/resource-a";
248 yield* store.delete({ stack: STACK, stage: STAGE, fqn });
249 const got = yield* store.get({ stack: STACK, stage: STAGE, fqn });
250 expect(got).toBeUndefined();
251 const fqns = yield* store.list({ stack: STACK, stage: STAGE });
252 expect([...fqns]).not.toContain(fqn);
253 }),
254 { timeout: 60_000 },
255 );
256
257 test(
258 "DELETE /stacks/:stack?stage=... clears one stage but leaves the stack registered",
259 Effect.gen(function* () {
260 const store = yield* yield* State;
261 yield* store.deleteStack({ stack: STACK, stage: STAGE });
262 const fqns = yield* store.list({ stack: STACK, stage: STAGE });
263 expect(fqns).toEqual([]);
264 const out = yield* store.getOutput({ stack: STACK, stage: STAGE });
265 expect(out).toBeUndefined();
266 // Per Api.ts: stage-scoped deletes do NOT unregister the stack.
267 const stacks = yield* store.listStacks();
268 expect(stacks).toContain(STACK);
269 }),
270 { timeout: 60_000 },
271 );
272
273 test(
274 "DELETE /stacks/:stack (no stage) removes the stack from listStacks",
275 Effect.gen(function* () {
276 const store = yield* yield* State;
277 yield* store.deleteStack({ stack: STACK });
278 const stacks = yield* store.listStacks();
279 expect(stacks).not.toContain(STACK);
280 }),
281 { timeout: 60_000 },
282 );
283
284 /**
285 * Stress test for transient `setState` failures (the reported 415 / etc.
286 * symptoms). 100 sequential PUTs against the same `(stack, stage, fqn)`
287 * — if the worker is racy under repeated writes (cold-start layer
288 * memoization, edge propagation, intermediary content-type munging)
289 * one of these calls should surface it.
290 */
291 test(
292 "setState 100x sequential — surfaces transient failures",
293 Effect.gen(function* () {
294 const store = yield* yield* State;
295 const stack = STACK;
296 const stage = `${STAGE}-stress-seq`;
297 const fqn = "stack/scope/stress-seq";
298
299 yield* store.deleteStack({ stack, stage });
300
301 yield* Effect.forEach(
302 Array.from({ length: 100 }, (_, i) => i),
303 (i) =>
304 store
305 .set({
306 stack,
307 stage,
308 fqn,
309 value: sampleState(fqn, `inst-${i}`),
310 })
311 .pipe(Effect.asVoid),
312 { discard: true },
313 );
314
315 const got = yield* store.get({ stack, stage, fqn });
316 expect(got).toBeDefined();
317 expect((got as any).instanceId).toBe("inst-99");
318
319 yield* store.deleteStack({ stack, stage });
320 }),
321 { timeout: 300_000 },
322 );
323
324 /**
325 * Same shape as the sequential stress test, but fires all 100 PUTs
326 * concurrently. Designed to catch races inside the Durable Object
327 * (`Store.getByName(stack)`) and the HttpApiBuilder layer
328 * initialization that wouldn't show up serially.
329 */
330 test(
331 "setState 100x concurrent — surfaces racy transient failures",
332 Effect.gen(function* () {
333 const store = yield* yield* State;
334 const stack = STACK;
335 const stage = `${STAGE}-stress-par`;
336
337 yield* store.deleteStack({ stack, stage });
338
339 yield* Effect.forEach(
340 Array.from({ length: 100 }, (_, i) => i),
341 (i) => {
342 const fqn = `stack/scope/stress-par-${i}`;
343 return store
344 .set({
345 stack,
346 stage,
347 fqn,
348 value: sampleState(fqn, `inst-${i}`),
349 })
350 .pipe(Effect.asVoid);
351 },
352 { concurrency: "unbounded", discard: true },
353 );
354
355 const fqns = yield* store.list({ stack, stage });
356 expect(fqns.length).toBe(100);
357
358 yield* store.deleteStack({ stack, stage });
359 }),
360 { timeout: 300_000 },
361 );
362
363 /**
364 * Engine-shape traffic pattern: for each resource, the apply loop reads
365 * the previous state (`GET /resources/:fqn`) and then writes the new
366 * state (`PUT /resources/:fqn`). When the engine deploys many
367 * resources in parallel, the worker sees interleaved GET+PUT pairs
368 * sharing the same connection / isolate.
369 *
370 * The 30×5 shape (30 sequential batches, 5 GET+PUT pairs in flight per
371 * batch) reproduces a transient 415 the engine surfaces under that
372 * pattern. The test fails on the first non-2xx so any regression of
373 * the underlying issue is caught immediately.
374 */
375 test(
376 "GET+PUT interleaved 30x5 — engine traffic pattern",
377 Effect.gen(function* () {
378 const store = yield* yield* State;
379 const stack = STACK;
380 const stage = `${STAGE}-interleaved`;
381
382 yield* store.deleteStack({ stack, stage });
383
384 yield* Effect.forEach(
385 Array.from({ length: 30 }, (_, b) => b),
386 (b) =>
387 Effect.forEach(
388 Array.from({ length: 5 }, (_, i) => `M${b}_${i}`),
389 (fqn) =>
390 Effect.all(
391 [
392 store.get({ stack, stage, fqn }).pipe(Effect.asVoid),
393 store
394 .set({
395 stack,
396 stage,
397 fqn,
398 value: sampleState(fqn, `inst-${fqn}`),
399 })
400 .pipe(Effect.asVoid),
401 ],
402 { concurrency: "unbounded", discard: true },
403 ),
404 { concurrency: "unbounded", discard: true },
405 ),
406 { discard: true },
407 );
408
409 const fqns = yield* store.list({ stack, stage });
410 expect(fqns.length).toBe(30 * 5);
411
412 yield* store.deleteStack({ stack, stage });
413 }),
414 { timeout: 300_000 },
415 );
416 },
417);