1import * as Alchemy from "@/index.ts";
2import { Stage } from "@/Stage";
3import { Stack } from "@/Stack";
4import { InMemoryService, State, type ResourceState } from "@/State";
5import * as Test from "@/Test/Alchemy";
6import { describe, expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import { TestLayers, TestResource } from "./test.resources.ts";
10
11// Regression coverage for https://github.com/alchemy-run/alchemy/issues/961:
12// destroying a stack used to re-persist an empty `{}` output record for the
13// stage. `state.getOutput` then returned a non-null, field-less husk while
14// `state stages` no longer reported the stage, and cross-stack
15// `Output.stackRef` consumers crashed reading fields off the husk at plan
16// time. After destroy, the persisted output record must be gone entirely.
17
18const { test } = Test.make({ providers: TestLayers() });
19
20describe(
21 "destroy clears the persisted stack output",
22 { tags: ["unit", "local"] },
23 () => {
24 test.provider("scratch stack destroy removes the output record", (stack) =>
25 Effect.gen(function* () {
26 const state = yield* yield* State;
27 const stk = yield* Stack;
28
29 const deployed = yield* Effect.gen(function* () {
30 const A = yield* TestResource("A", { string: "test-string" });
31 return { url: A.string };
32 }).pipe(stack.deploy);
33 expect(deployed).toEqual({ url: "test-string" });
34
35 expect(
36 yield* state.getOutput({ stack: stk.name, stage: stk.stage }),
37 ).toEqual({ url: "test-string" });
38
39 yield* stack.destroy();
40
41 // The output record must be removed, not overwritten with `{}`.
42 expect(
43 yield* state.getOutput({ stack: stk.name, stage: stk.stage }),
44 ).toBeUndefined();
45 // ... and `listStages` must agree the stage is gone.
46 expect(yield* state.listStages(stk.name)).not.toContain(stk.stage);
47 }),
48 );
49 },
50);
51
52// The same regression through the real `deploy`/`destroy` entry points
53// (`Destroy.ts`), which is what `alchemy destroy` and the test harness's
54// `destroy(Stack)` run.
55const store: Record<string, Record<string, Record<string, ResourceState>>> = {};
56const outputs: Record<string, Record<string, unknown>> = {};
57const stateLayer = Layer.succeed(State, InMemoryService(store, outputs));
58
59const harness = Test.make({ providers: TestLayers(), state: stateLayer });
60
61const DestroyOutputStack = Alchemy.Stack(
62 "DestroyOutputStack",
63 { providers: TestLayers(), state: stateLayer },
64 Effect.gen(function* () {
65 const A = yield* TestResource("A", { string: "test-string" });
66 return { url: A.string, stage: yield* Stage };
67 }),
68);
69
70harness.test(
71 "stack destroy removes the persisted stack output",
72 Effect.gen(function* () {
73 const { stage } = yield* harness.deploy(DestroyOutputStack);
74 expect(outputs.DestroyOutputStack?.[stage]).toEqual({
75 url: "test-string",
76 stage,
77 });
78
79 yield* harness.destroy(DestroyOutputStack);
80 expect(outputs.DestroyOutputStack?.[stage]).toBeUndefined();
81 expect(store.DestroyOutputStack?.[stage]).toBeUndefined();
82 }),
83 { tags: ["unit", "local"] },
84);