1import { STATE_STORE_VERSION } from "@/State/HttpStateApi.ts";
2import { makeLocalState } from "@/State/LocalState.ts";
3import type { ResourceState } from "@/State/ResourceState.ts";
4import { initialCwd } from "@/Util/Node.ts";
5import { PlatformServices } from "@/Util/PlatformServices";
6import { describe, expect, it } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Path from "effect/Path";
10
11const 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 */
39const statePath = (...segments: string[]) =>
40 Effect.gen(function* () {
41 const path = yield* Path.Path;
42 return path.join(initialCwd, ".alchemy", "state", ...segments);
43 });
44
45describe("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});