| 1 | import { STATE_STORE_VERSION } from "@/State/HttpStateApi.ts"; |
| 2 | import { makeLocalState } from "@/State/LocalState.ts"; |
| 3 | import type { ResourceState } from "@/State/ResourceState.ts"; |
| 4 | import { initialCwd } from "@/Util/Node.ts"; |
| 5 | import { PlatformServices } from "@/Util/PlatformServices"; |
| 6 | import { describe, expect, it } from "alchemy-test"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as FileSystem from "effect/FileSystem"; |
| 9 | import * as Path from "effect/Path"; |
| 10 | |
| 11 | const resource = ( |
| 12 | fqn: string, |
| 13 | attr: Record<string, unknown>, |
| 14 | overrides?: Partial<ResourceState>, |
| 15 | ): ResourceState => |
| 16 | ({ |
| 17 | resourceType: "test:resource", |
| 18 | namespace: undefined, |
| 19 | fqn, |
| 20 | logicalId: fqn, |
| 21 | instanceId: `instance-${fqn}`, |
| 22 | providerVersion: 1, |
| 23 | status: "created", |
| 24 | downstream: [], |
| 25 | bindings: [], |
| 26 | props: {}, |
| 27 | attr, |
| 28 | ...overrides, |
| 29 | }) as ResourceState; |
| 30 | |
| 31 | /** |
| 32 | * Absolute path into the store's on-disk layout (`.alchemy/state/...`). |
| 33 | * |
| 34 | * Anchored to `initialCwd` — the same anchor `makeLocalState` uses — instead |
| 35 | * of a live `process.cwd()` read: another suite's in-process build (e.g. a |
| 36 | * Waku source build) can transiently chdir the whole test process, and a |
| 37 | * live read racing that window points at an unrelated (missing) tree. |
| 38 | */ |
| 39 | const statePath = (...segments: string[]) => |
| 40 | Effect.gen(function* () { |
| 41 | const path = yield* Path.Path; |
| 42 | return path.join(initialCwd, ".alchemy", "state", ...segments); |
| 43 | }); |
| 44 | |
| 45 | describe("makeLocalState", { tags: ["unit", "local"] }, () => { |
| 46 | it.effect("reads of absent state recover to empty results", () => |
| 47 | Effect.gen(function* () { |
| 48 | const state = yield* makeLocalState(); |
| 49 | const stack = "local-state-test-absent"; |
| 50 | |
| 51 | expect( |
| 52 | yield* state.get({ stack, stage: "test", fqn: "nope" }), |
| 53 | ).toBeUndefined(); |
| 54 | expect(yield* state.list({ stack, stage: "test" })).toEqual([]); |
| 55 | expect(yield* state.listStages(stack)).toEqual([]); |
| 56 | expect(yield* state.getOutput({ stack, stage: "test" })).toBeUndefined(); |
| 57 | expect( |
| 58 | yield* state.getReplacedResources({ stack, stage: "test" }), |
| 59 | ).toEqual([]); |
| 60 | }).pipe(Effect.provide(PlatformServices)), |
| 61 | ); |
| 62 | |
| 63 | it.effect("set/get roundtrip with list, listStacks and listStages", () => |
| 64 | Effect.gen(function* () { |
| 65 | const state = yield* makeLocalState(); |
| 66 | const stack = "local-state-test-crud"; |
| 67 | const stage = "test"; |
| 68 | |
| 69 | const plain = resource("resource-a", { value: "a" }); |
| 70 | // fqns contain `/` separators; the store must encode them into flat |
| 71 | // file names and decode them back on list. |
| 72 | const nested = resource("scope/nested/resource-b", { value: "b" }); |
| 73 | yield* state.set({ stack, stage, fqn: plain.fqn, value: plain }); |
| 74 | yield* state.set({ stack, stage, fqn: nested.fqn, value: nested }); |
| 75 | |
| 76 | expect(yield* state.get({ stack, stage, fqn: plain.fqn })).toEqual(plain); |
| 77 | expect(yield* state.get({ stack, stage, fqn: nested.fqn })).toEqual( |
| 78 | nested, |
| 79 | ); |
| 80 | expect((yield* state.list({ stack, stage })).toSorted()).toEqual([ |
| 81 | "resource-a", |
| 82 | "scope/nested/resource-b", |
| 83 | ]); |
| 84 | expect(yield* state.listStacks()).toContain(stack); |
| 85 | expect(yield* state.listStages(stack)).toEqual([stage]); |
| 86 | |
| 87 | yield* state.deleteStack({ stack }); |
| 88 | }).pipe(Effect.provide(PlatformServices)), |
| 89 | ); |
| 90 | |
| 91 | it.effect("delete removes one resource without touching its siblings", () => |
| 92 | Effect.gen(function* () { |
| 93 | const state = yield* makeLocalState(); |
| 94 | const stack = "local-state-test-delete"; |
| 95 | const stage = "test"; |
| 96 | const a = resource("resource-a", { value: "a" }); |
| 97 | const b = resource("resource-b", { value: "b" }); |
| 98 | yield* state.set({ stack, stage, fqn: a.fqn, value: a }); |
| 99 | yield* state.set({ stack, stage, fqn: b.fqn, value: b }); |
| 100 | |
| 101 | yield* state.delete({ stack, stage, fqn: a.fqn }); |
| 102 | // deleting an already-deleted resource is not an error |
| 103 | yield* state.delete({ stack, stage, fqn: a.fqn }); |
| 104 | |
| 105 | expect(yield* state.get({ stack, stage, fqn: a.fqn })).toBeUndefined(); |
| 106 | expect(yield* state.get({ stack, stage, fqn: b.fqn })).toEqual(b); |
| 107 | |
| 108 | // `delete` only removes files, so the directory cache stays valid |
| 109 | // and a re-set must persist. |
| 110 | yield* state.set({ stack, stage, fqn: a.fqn, value: a }); |
| 111 | expect(yield* state.get({ stack, stage, fqn: a.fqn })).toEqual(a); |
| 112 | |
| 113 | yield* state.deleteStack({ stack }); |
| 114 | }).pipe(Effect.provide(PlatformServices)), |
| 115 | ); |
| 116 | |
| 117 | it.effect("list excludes bookkeeping, in-flight and non-json files", () => |
| 118 | Effect.gen(function* () { |
| 119 | const fs = yield* FileSystem.FileSystem; |
| 120 | const state = yield* makeLocalState(); |
| 121 | const stack = "local-state-test-list-filter"; |
| 122 | const stage = "test"; |
| 123 | const a = resource("resource-a", { value: "a" }); |
| 124 | yield* state.set({ stack, stage, fqn: a.fqn, value: a }); |
| 125 | // writes `__stack_output__.json`, which must not surface as a resource |
| 126 | yield* state.setOutput({ stack, stage, value: { out: true } }); |
| 127 | // a lingering writeAtomic temp file must not surface either |
| 128 | const tmp = yield* statePath(stack, stage, "resource-a.json.123.tmp"); |
| 129 | yield* fs.writeFileString(tmp, "{}"); |
| 130 | |
| 131 | expect(yield* state.list({ stack, stage })).toEqual(["resource-a"]); |
| 132 | |
| 133 | yield* state.deleteStack({ stack }); |
| 134 | }).pipe(Effect.provide(PlatformServices)), |
| 135 | ); |
| 136 | |
| 137 | it.effect("get treats a zero-length state file as absent", () => |
| 138 | Effect.gen(function* () { |
| 139 | const fs = yield* FileSystem.FileSystem; |
| 140 | const state = yield* makeLocalState(); |
| 141 | const stack = "local-state-test-empty-file"; |
| 142 | const stage = "test"; |
| 143 | const dir = yield* statePath(stack, stage); |
| 144 | yield* fs.makeDirectory(dir, { recursive: true }); |
| 145 | const path = yield* Path.Path; |
| 146 | yield* fs.writeFileString(path.join(dir, "resource-a.json"), ""); |
| 147 | |
| 148 | expect( |
| 149 | yield* state.get({ stack, stage, fqn: "resource-a" }), |
| 150 | ).toBeUndefined(); |
| 151 | |
| 152 | yield* state.deleteStack({ stack }); |
| 153 | }).pipe(Effect.provide(PlatformServices)), |
| 154 | ); |
| 155 | |
| 156 | it.effect("getReplacedResources returns only replaced resources", () => |
| 157 | Effect.gen(function* () { |
| 158 | const state = yield* makeLocalState(); |
| 159 | const stack = "local-state-test-replaced"; |
| 160 | const stage = "test"; |
| 161 | const created = resource("resource-a", { value: "a" }); |
| 162 | const replaced = resource("resource-b", { value: "b" }, { |
| 163 | status: "replaced", |
| 164 | old: resource("resource-b", { value: "old" }), |
| 165 | deleteFirst: false, |
| 166 | } as Partial<ResourceState>); |
| 167 | yield* state.set({ stack, stage, fqn: created.fqn, value: created }); |
| 168 | yield* state.set({ stack, stage, fqn: replaced.fqn, value: replaced }); |
| 169 | |
| 170 | expect(yield* state.getReplacedResources({ stack, stage })).toEqual([ |
| 171 | replaced, |
| 172 | ]); |
| 173 | |
| 174 | yield* state.deleteStack({ stack }); |
| 175 | }).pipe(Effect.provide(PlatformServices)), |
| 176 | ); |
| 177 | |
| 178 | it.effect("setOutput/getOutput roundtrip", () => |
| 179 | Effect.gen(function* () { |
| 180 | const state = yield* makeLocalState(); |
| 181 | const stack = "local-state-test-output"; |
| 182 | const stage = "test"; |
| 183 | const value = { url: "https://example.com", nested: { n: 1 } }; |
| 184 | yield* state.setOutput({ stack, stage, value }); |
| 185 | |
| 186 | expect(yield* state.getOutput({ stack, stage })).toEqual(value); |
| 187 | |
| 188 | yield* state.deleteStack({ stack }); |
| 189 | }).pipe(Effect.provide(PlatformServices)), |
| 190 | ); |
| 191 | |
| 192 | it.effect("set persists again after deleteStack removes the stage dir", () => |
| 193 | Effect.gen(function* () { |
| 194 | const state = yield* makeLocalState(); |
| 195 | const key = { |
| 196 | stack: "local-state-created-cache-test", |
| 197 | stage: "test", |
| 198 | fqn: "resource-a", |
| 199 | }; |
| 200 | |
| 201 | yield* state.set({ ...key, value: resource(key.fqn, { round: 1 }) }); |
| 202 | expect(yield* state.get(key)).toMatchObject({ attr: { round: 1 } }); |
| 203 | |
| 204 | yield* state.deleteStack({ stack: key.stack }); |
| 205 | expect(yield* state.get(key)).toBeUndefined(); |
| 206 | |
| 207 | // Before the `created`-cache invalidation, this write skipped |
| 208 | // makeDirectory, failed with NotFound, and silently no-op'd. |
| 209 | yield* state.set({ ...key, value: resource(key.fqn, { round: 2 }) }); |
| 210 | expect(yield* state.get(key)).toMatchObject({ attr: { round: 2 } }); |
| 211 | |
| 212 | yield* state.deleteStack({ stack: key.stack }); |
| 213 | }).pipe(Effect.provide(PlatformServices)), |
| 214 | ); |
| 215 | |
| 216 | it.effect("set persists again after a stage-scoped deleteStack", () => |
| 217 | Effect.gen(function* () { |
| 218 | const state = yield* makeLocalState(); |
| 219 | const key = { |
| 220 | stack: "local-state-created-cache-test-stage", |
| 221 | stage: "test", |
| 222 | fqn: "resource-a", |
| 223 | }; |
| 224 | |
| 225 | yield* state.set({ ...key, value: resource(key.fqn, { round: 1 }) }); |
| 226 | yield* state.deleteStack({ stack: key.stack, stage: key.stage }); |
| 227 | expect(yield* state.get(key)).toBeUndefined(); |
| 228 | |
| 229 | yield* state.set({ ...key, value: resource(key.fqn, { round: 2 }) }); |
| 230 | expect(yield* state.get(key)).toMatchObject({ attr: { round: 2 } }); |
| 231 | |
| 232 | yield* state.deleteStack({ stack: key.stack }); |
| 233 | }).pipe(Effect.provide(PlatformServices)), |
| 234 | ); |
| 235 | |
| 236 | it.effect("whole-stack deleteStack invalidates every stage's cache", () => |
| 237 | Effect.gen(function* () { |
| 238 | const state = yield* makeLocalState(); |
| 239 | const stack = "local-state-created-cache-test-multi"; |
| 240 | const fqn = "resource-a"; |
| 241 | |
| 242 | // ensure both stage dirs are cached before the wholesale delete — |
| 243 | // this exercises the prefix (not exact) match in the cache pruning |
| 244 | yield* state.set({ |
| 245 | stack, |
| 246 | stage: "a", |
| 247 | fqn, |
| 248 | value: resource(fqn, { round: 1 }), |
| 249 | }); |
| 250 | yield* state.set({ |
| 251 | stack, |
| 252 | stage: "b", |
| 253 | fqn, |
| 254 | value: resource(fqn, { round: 1 }), |
| 255 | }); |
| 256 | yield* state.deleteStack({ stack }); |
| 257 | |
| 258 | yield* state.set({ |
| 259 | stack, |
| 260 | stage: "a", |
| 261 | fqn, |
| 262 | value: resource(fqn, { round: 2 }), |
| 263 | }); |
| 264 | yield* state.set({ |
| 265 | stack, |
| 266 | stage: "b", |
| 267 | fqn, |
| 268 | value: resource(fqn, { round: 2 }), |
| 269 | }); |
| 270 | expect(yield* state.get({ stack, stage: "a", fqn })).toMatchObject({ |
| 271 | attr: { round: 2 }, |
| 272 | }); |
| 273 | expect(yield* state.get({ stack, stage: "b", fqn })).toMatchObject({ |
| 274 | attr: { round: 2 }, |
| 275 | }); |
| 276 | |
| 277 | yield* state.deleteStack({ stack }); |
| 278 | }).pipe(Effect.provide(PlatformServices)), |
| 279 | ); |
| 280 | |
| 281 | it.effect("stage-scoped deleteStack leaves the sibling stage intact", () => |
| 282 | Effect.gen(function* () { |
| 283 | const state = yield* makeLocalState(); |
| 284 | const stack = "local-state-created-cache-test-sibling"; |
| 285 | const fqn = "resource-a"; |
| 286 | |
| 287 | yield* state.set({ |
| 288 | stack, |
| 289 | stage: "a", |
| 290 | fqn, |
| 291 | value: resource(fqn, { round: 1 }), |
| 292 | }); |
| 293 | yield* state.set({ |
| 294 | stack, |
| 295 | stage: "b", |
| 296 | fqn, |
| 297 | value: resource(fqn, { round: 1 }), |
| 298 | }); |
| 299 | yield* state.deleteStack({ stack, stage: "a" }); |
| 300 | |
| 301 | // sibling stage keeps its files and its still-valid cache entry |
| 302 | expect(yield* state.get({ stack, stage: "b", fqn })).toMatchObject({ |
| 303 | attr: { round: 1 }, |
| 304 | }); |
| 305 | yield* state.set({ |
| 306 | stack, |
| 307 | stage: "b", |
| 308 | fqn, |
| 309 | value: resource(fqn, { round: 2 }), |
| 310 | }); |
| 311 | expect(yield* state.get({ stack, stage: "b", fqn })).toMatchObject({ |
| 312 | attr: { round: 2 }, |
| 313 | }); |
| 314 | |
| 315 | // deleted stage is gone and re-settable |
| 316 | expect(yield* state.get({ stack, stage: "a", fqn })).toBeUndefined(); |
| 317 | yield* state.set({ |
| 318 | stack, |
| 319 | stage: "a", |
| 320 | fqn, |
| 321 | value: resource(fqn, { round: 2 }), |
| 322 | }); |
| 323 | expect(yield* state.get({ stack, stage: "a", fqn })).toMatchObject({ |
| 324 | attr: { round: 2 }, |
| 325 | }); |
| 326 | |
| 327 | yield* state.deleteStack({ stack }); |
| 328 | }).pipe(Effect.provide(PlatformServices)), |
| 329 | ); |
| 330 | |
| 331 | it.effect("setOutput persists again after deleteStack", () => |
| 332 | Effect.gen(function* () { |
| 333 | const state = yield* makeLocalState(); |
| 334 | const stack = "local-state-created-cache-test-output"; |
| 335 | const stage = "test"; |
| 336 | |
| 337 | // setOutput goes through the same `ensure`/`created` cache as `set` |
| 338 | yield* state.setOutput({ stack, stage, value: { round: 1 } }); |
| 339 | yield* state.deleteStack({ stack }); |
| 340 | expect(yield* state.getOutput({ stack, stage })).toBeUndefined(); |
| 341 | |
| 342 | yield* state.setOutput({ stack, stage, value: { round: 2 } }); |
| 343 | expect(yield* state.getOutput({ stack, stage })).toEqual({ round: 2 }); |
| 344 | |
| 345 | yield* state.deleteStack({ stack }); |
| 346 | }).pipe(Effect.provide(PlatformServices)), |
| 347 | ); |
| 348 | |
| 349 | it.effect("getVersion returns the state store version", () => |
| 350 | Effect.gen(function* () { |
| 351 | const state = yield* makeLocalState(); |
| 352 | expect(yield* state.getVersion()).toBe(STATE_STORE_VERSION); |
| 353 | }).pipe(Effect.provide(PlatformServices)), |
| 354 | ); |
| 355 | |
| 356 | it.effect("deleteStack removes the stack from listings", () => |
| 357 | Effect.gen(function* () { |
| 358 | const state = yield* makeLocalState(); |
| 359 | const stack = "local-state-test-delete-listing"; |
| 360 | yield* state.set({ |
| 361 | stack, |
| 362 | stage: "test", |
| 363 | fqn: "resource-a", |
| 364 | value: resource("resource-a", {}), |
| 365 | }); |
| 366 | |
| 367 | yield* state.deleteStack({ stack }); |
| 368 | |
| 369 | expect(yield* state.listStacks()).not.toContain(stack); |
| 370 | expect(yield* state.listStages(stack)).toEqual([]); |
| 371 | }).pipe(Effect.provide(PlatformServices)), |
| 372 | ); |
| 373 | |
| 374 | it.effect("concurrent writes to the same resource never corrupt it", () => |
| 375 | Effect.gen(function* () { |
| 376 | const state = yield* makeLocalState(); |
| 377 | const stack = "local-state-test-concurrent"; |
| 378 | const stage = "test"; |
| 379 | const fqn = "resource-a"; |
| 380 | |
| 381 | const rounds = Array.from({ length: 20 }, (_, i) => i + 1); |
| 382 | yield* Effect.all( |
| 383 | rounds.map((round) => |
| 384 | state.set({ stack, stage, fqn, value: resource(fqn, { round }) }), |
| 385 | ), |
| 386 | { concurrency: "unbounded" }, |
| 387 | ); |
| 388 | |
| 389 | // whichever write won, the file must parse as one complete state — |
| 390 | // never a truncated interleaving (the writeAtomic guarantee) |
| 391 | const final = (yield* state.get({ stack, stage, fqn })) as ResourceState; |
| 392 | expect(rounds).toContain((final.attr as { round: number }).round); |
| 393 | |
| 394 | yield* state.deleteStack({ stack }); |
| 395 | }).pipe(Effect.provide(PlatformServices)), |
| 396 | ); |
| 397 | |
| 398 | describe("weird names", () => { |
| 399 | it.effect("unicode, spaces and punctuation in every name position", () => |
| 400 | Effect.gen(function* () { |
| 401 | const state = yield* makeLocalState(); |
| 402 | const stack = "local wéird 🚀..stack"; |
| 403 | const stage = "stage v1.2.3 ünïcode"; |
| 404 | const fqn = "my resource (α) #1 100%"; |
| 405 | const value = resource(fqn, { value: "weird" }); |
| 406 | |
| 407 | yield* state.set({ stack, stage, fqn, value }); |
| 408 | expect(yield* state.get({ stack, stage, fqn })).toEqual(value); |
| 409 | expect(yield* state.list({ stack, stage })).toEqual([fqn]); |
| 410 | expect(yield* state.listStages(stack)).toEqual([stage]); |
| 411 | |
| 412 | yield* state.delete({ stack, stage, fqn }); |
| 413 | expect(yield* state.get({ stack, stage, fqn })).toBeUndefined(); |
| 414 | |
| 415 | yield* state.deleteStack({ stack }); |
| 416 | }).pipe(Effect.provide(PlatformServices)), |
| 417 | ); |
| 418 | |
| 419 | it.effect("empty-string fqn roundtrips", () => |
| 420 | Effect.gen(function* () { |
| 421 | const state = yield* makeLocalState(); |
| 422 | const stack = "local-state-test-empty-fqn"; |
| 423 | const stage = "test"; |
| 424 | const value = resource("", { empty: true }); |
| 425 | |
| 426 | yield* state.set({ stack, stage, fqn: "", value }); |
| 427 | expect(yield* state.get({ stack, stage, fqn: "" })).toEqual(value); |
| 428 | expect(yield* state.list({ stack, stage })).toEqual([""]); |
| 429 | |
| 430 | yield* state.deleteStack({ stack }); |
| 431 | }).pipe(Effect.provide(PlatformServices)), |
| 432 | ); |
| 433 | |
| 434 | it.effect("literal __ in a logical id: get roundtrips, list decodes", () => |
| 435 | Effect.gen(function* () { |
| 436 | const state = yield* makeLocalState(); |
| 437 | const stack = "local-state-test-underscores"; |
| 438 | const stage = "test"; |
| 439 | const value = resource("foo__bar", { value: "u" }); |
| 440 | |
| 441 | yield* state.set({ stack, stage, fqn: "foo__bar", value }); |
| 442 | expect(yield* state.get({ stack, stage, fqn: "foo__bar" })).toEqual( |
| 443 | value, |
| 444 | ); |
| 445 | // Known encoding collision: `/` is stored as `__`, so decodeFqn |
| 446 | // cannot distinguish a literal `__` in a logical id from a |
| 447 | // namespace separator. `list` reports this resource as "foo/bar". |
| 448 | expect(yield* state.list({ stack, stage })).toEqual(["foo/bar"]); |
| 449 | |
| 450 | yield* state.deleteStack({ stack }); |
| 451 | }).pipe(Effect.provide(PlatformServices)), |
| 452 | ); |
| 453 | |
| 454 | it.effect("a resource named __stack_output__ never surfaces in list", () => |
| 455 | Effect.gen(function* () { |
| 456 | const state = yield* makeLocalState(); |
| 457 | const stack = "local-state-test-output-clash"; |
| 458 | const stage = "test"; |
| 459 | const value = resource("__stack_output__", { value: "clash" }); |
| 460 | |
| 461 | yield* state.set({ stack, stage, fqn: "__stack_output__", value }); |
| 462 | expect(yield* state.list({ stack, stage })).toEqual([]); |
| 463 | |
| 464 | yield* state.deleteStack({ stack }); |
| 465 | }).pipe(Effect.provide(PlatformServices)), |
| 466 | ); |
| 467 | |
| 468 | it.effect("overlong names fail with a typed StateStoreError", () => |
| 469 | Effect.gen(function* () { |
| 470 | const state = yield* makeLocalState(); |
| 471 | const stack = "local-state-test-overlong"; |
| 472 | const stage = "test"; |
| 473 | // > 255 bytes exceeds the filename limit; only NotFound recovers, |
| 474 | // so the platform error must surface as a typed StateStoreError |
| 475 | // (never a silent no-op). |
| 476 | const fqn = "x".repeat(300); |
| 477 | |
| 478 | const setError = yield* state |
| 479 | .set({ stack, stage, fqn, value: resource(fqn, {}) }) |
| 480 | .pipe(Effect.flip); |
| 481 | expect(setError._tag).toBe("StateStoreError"); |
| 482 | |
| 483 | const getError = yield* state |
| 484 | .get({ stack, stage, fqn }) |
| 485 | .pipe(Effect.flip); |
| 486 | expect(getError._tag).toBe("StateStoreError"); |
| 487 | |
| 488 | yield* state.deleteStack({ stack }); |
| 489 | }).pipe(Effect.provide(PlatformServices)), |
| 490 | ); |
| 491 | }); |
| 492 | }); |