1/**
2 * Destroy/state consistency: a destroy that cannot see the stack's
3 * persisted state must FAIL LOUDLY — never report "no changes" and leak
4 * the stack's cloud resources.
5 *
6 * Regression coverage for the 2026-08-05 full-suite leak: two Cloudflare
7 * test stacks' `afterAll destroy(Stack)` planned "no changes" while their
8 * committed localState rows sat on disk, silently leaking 3 real workers.
9 * The destroy session behaved as if pointed at an empty state tree for its
10 * entire lifetime (plan list, row gets, and the final `deleteStack` all
11 * missed), so the engine now:
12 *
13 * 1. anchors the local state tree to the process's INITIAL working
14 * directory (captured at module load) so no session can be built
15 * against a different tree,
16 * 2. cross-checks directory-level NotFound answers in LocalState with a
17 * synchronous `existsSync` before degrading to "empty",
18 * 3. verifies after every destroy that the stage is actually empty, and
19 * fails with `StateStoreError` when rows survive.
20 */
21import { apply } from "@/Apply.ts";
22import { provideFreshArtifactStore } from "@/Artifacts";
23import * as Plan from "@/Plan.ts";
24import { Stack } from "@/Stack";
25import * as State from "@/State/index";
26import { makeLocalState } from "@/State/LocalState.ts";
27import { Stage } from "@/Stage.ts";
28import * as Test from "@/Test/Alchemy";
29import { expect } from "alchemy-test";
30import * as Cause from "effect/Cause";
31import * as Effect from "effect/Effect";
32import * as Exit from "effect/Exit";
33import * as FileSystem from "effect/FileSystem";
34import * as Layer from "effect/Layer";
35import { TestLayers, TestResourceHooks } from "../test.resources.ts";
36
37const { test } = Test.make({ providers: TestLayers() });
38
39const STAGE = "test";
40const instanceId = "852f6ec2e19b66589825efe14dca2971";
41
42const row = (fqn: string): State.ResourceState =>
43 ({
44 instanceId,
45 providerVersion: 0,
46 logicalId: fqn,
47 fqn,
48 namespace: undefined,
49 resourceType: "Test.TestResource",
50 status: "created",
51 props: { string: "a" },
52 attr: {
53 string: "a",
54 stringArray: [],
55 stableString: fqn,
56 stableArray: [fqn],
57 replaceString: undefined,
58 redacted: undefined,
59 redactedArray: undefined,
60 },
61 bindings: [],
62 downstream: [],
63 }) as State.ResourceState;
64
65/** Run `Plan.destroy -> apply` against an explicit state layer. */
66const runDestroy = (
67 stackName: string,
68 stateLayer: Layer.Layer<State.State, never, never>,
69 deleted: string[],
70) =>
71 Plan.destroy({ name: stackName, stage: STAGE }).pipe(
72 Effect.flatMap(apply),
73 Effect.asVoid,
74 Effect.provide(stateLayer),
75 Effect.provide(TestLayers() as Layer.Layer<any, never, any>),
76 Effect.provide(
77 Layer.succeed(TestResourceHooks, {
78 delete: (id) => Effect.sync(() => void deleted.push(id)),
79 }),
80 ),
81 Effect.provide(
82 Layer.succeed(Stack, {
83 name: stackName,
84 stage: STAGE,
85 resources: {},
86 bindings: {},
87 actions: {},
88 }),
89 ),
90 Effect.provide(Layer.succeed(Stage, STAGE)),
91 provideFreshArtifactStore,
92 ) as Effect.Effect<void, any, never>;
93
94const stateStoreErrorOf = (
95 exit: Exit.Exit<unknown, unknown>,
96): State.StateStoreError | undefined => {
97 if (!Exit.isFailure(exit)) return undefined;
98 const error = Cause.squash(exit.cause);
99 return error instanceof State.StateStoreError ? error : undefined;
100};
101
102test(
103 "a destroy that cannot see committed state fails loudly instead of reporting success",
104 Effect.gen(function* () {
105 const stackName = "destroy-consistency-blind-session";
106 const inner = yield* State.InMemoryService({
107 [stackName]: { [STAGE]: { A: row("A") } },
108 });
109
110 // Simulate the observed failure: the store answers "no state" for the
111 // whole destroy session (plan lists nothing, deleteStack silently
112 // deletes nothing) and only recovers afterwards — the shape of a
113 // transient state outage or a mispointed session.
114 let blind = true;
115 const flaky: State.StateService = {
116 ...inner,
117 list: (req) => (blind ? Effect.succeed([]) : inner.list(req)),
118 deleteStack: (req) =>
119 blind
120 ? Effect.sync(() => {
121 // The outage "ends" right after the swallowed deleteStack, so
122 // the engine's post-destroy verification reads honest state.
123 blind = false;
124 })
125 : inner.deleteStack(req),
126 };
127 const flakyLayer = Layer.succeed(State.State, Effect.succeed(flaky));
128
129 const deleted: string[] = [];
130 const exit = yield* runDestroy(stackName, flakyLayer, deleted).pipe(
131 Effect.exit,
132 );
133
134 // The destroy must NOT report success: its plan saw no rows, so the
135 // provider never deleted the cloud resource — reporting success here
136 // is exactly the silent-leak bug.
137 const error = stateStoreErrorOf(exit);
138 expect(error?._tag).toBe("StateStoreError");
139 expect(error?.message).toContain("state row(s) remain");
140 expect(deleted).toEqual([]);
141 // The row survives for the next (healthy) destroy to reclaim.
142 expect(
143 yield* inner.get({ stack: stackName, stage: STAGE, fqn: "A" }),
144 ).toBeDefined();
145
146 // A healthy session then destroys normally: provider delete runs, the
147 // row is dropped, and the post-destroy verification passes.
148 yield* runDestroy(stackName, flakyLayer, deleted);
149 expect(deleted).toEqual(["A"]);
150 expect(
151 yield* inner.get({ stack: stackName, stage: STAGE, fqn: "A" }),
152 ).toBeUndefined();
153 }),
154 { tags: ["unit", "local"] },
155);
156
157test(
158 "local state is anchored to the process's initial cwd, not the cwd at store build",
159 Effect.gen(function* () {
160 const fs = yield* FileSystem.FileSystem;
161 const stackName = "destroy-consistency-cwd-anchor";
162 const writer = yield* makeLocalState();
163 yield* writer.set({
164 stack: stackName,
165 stage: STAGE,
166 fqn: "A",
167 value: row("A"),
168 });
169
170 const tmp = yield* fs.makeTempDirectory({ prefix: "alchemy-cwd-anchor" });
171 const original = yield* Effect.sync(() => process.cwd());
172 yield* Effect.gen(function* () {
173 yield* Effect.sync(() => process.chdir(tmp));
174 // A store built while the process cwd points elsewhere must still
175 // resolve the same state tree — a destroy building its store during
176 // a transient cwd excursion would otherwise see an empty tree and
177 // plan "no changes" over live state.
178 const reader = yield* makeLocalState();
179 expect(yield* reader.list({ stack: stackName, stage: STAGE })).toEqual([
180 "A",
181 ]);
182 }).pipe(Effect.ensuring(Effect.sync(() => process.chdir(original))));
183
184 yield* writer.deleteStack({ stack: stackName });
185 }),
186 // mutates process-global cwd
187 { tags: ["unit", "local"], exclusive: true },
188);