| 1 | import * as Cloudflare from "@/Cloudflare"; |
| 2 | import { STATE_STORE_VERSION } from "@/Cloudflare/StateStore/Api.ts"; |
| 3 | import { State } from "@/State/State.ts"; |
| 4 | import * as Test from "@/Test/Alchemy"; |
| 5 | import { describe, expect } from "alchemy-test"; |
| 6 | import * 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 | |
| 19 | const STACK = "alchemy_tests"; |
| 20 | const STAGE = "alchemy_tests"; |
| 21 | |
| 22 | const { test } = Test.make({ |
| 23 | providers: Cloudflare.providers(), |
| 24 | state: Cloudflare.state(), |
| 25 | }); |
| 26 | |
| 27 | const 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 | |
| 42 | const 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. |
| 75 | describe.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 | ); |