1import {
2 exportState,
3 InMemoryService,
4 State,
5 type ResourceState,
6 type StateService,
7} from "@/State";
8import { describe, expect, it } from "alchemy-test";
9import * as Effect from "effect/Effect";
10
11/** The store under test is provided as the `State` service for one call. */
12const withState = <A, E, R>(
13 state: StateService,
14 effect: Effect.Effect<A, E, R>,
15) => Effect.provideService(effect, State, Effect.succeed(state));
16
17describe("exportState", { tags: ["unit", "local"] }, () => {
18 it.effect("exports every stack/stage/resource as one document", () =>
19 Effect.gen(function* () {
20 const state = yield* InMemoryService({
21 "app-east": {
22 dev: {
23 WebServer: resource("WebServer", { instanceId: "i-1" }),
24 Database: resource("Database", { arn: "arn:db" }),
25 },
26 prod: {
27 WebServer: resource("WebServer", { instanceId: "i-2" }),
28 },
29 },
30 "app-west": {
31 dev: {
32 Bucket: resource("Bucket", { bucketName: "b-1" }),
33 },
34 },
35 });
36
37 const exported = yield* withState(state, exportState());
38
39 // Deterministic order: stack, then stage, then FQN.
40 expect(
41 exported.resources.map((r) => `${r.stack}/${r.stage}/${r.fqn}`),
42 ).toEqual([
43 "app-east/dev/Database",
44 "app-east/dev/WebServer",
45 "app-east/prod/WebServer",
46 "app-west/dev/Bucket",
47 ]);
48
49 // Records are the same values `state.get` returns — props/attr intact.
50 const webServer = exported.resources.find(
51 (r) =>
52 r.stack === "app-east" && r.stage === "dev" && r.fqn === "WebServer",
53 );
54 expect(webServer?.state).toEqual(
55 yield* state.get({ stack: "app-east", stage: "dev", fqn: "WebServer" }),
56 );
57 }),
58 );
59
60 it.effect("filters by stack", () =>
61 Effect.gen(function* () {
62 const state = yield* InMemoryService({
63 "app-east": { dev: { A: resource("A", {}) } },
64 "app-west": { dev: { B: resource("B", {}) } },
65 });
66
67 const exported = yield* withState(
68 state,
69 exportState({ stack: "app-west" }),
70 );
71
72 expect(exported.resources.map((r) => `${r.stack}/${r.fqn}`)).toEqual([
73 "app-west/B",
74 ]);
75 }),
76 );
77
78 it.effect("filters by stack and stage", () =>
79 Effect.gen(function* () {
80 const state = yield* InMemoryService({
81 app: {
82 dev: { A: resource("A", {}) },
83 prod: { B: resource("B", {}) },
84 },
85 });
86
87 const exported = yield* withState(
88 state,
89 exportState({
90 stack: "app",
91 stage: "prod",
92 }),
93 );
94
95 expect(
96 exported.resources.map((r) => `${r.stack}/${r.stage}/${r.fqn}`),
97 ).toEqual(["app/prod/B"]);
98 }),
99 );
100
101 it.effect("returns an empty document for an empty store", () =>
102 Effect.gen(function* () {
103 const state = yield* InMemoryService({});
104 expect(yield* withState(state, exportState())).toEqual({ resources: [] });
105 }),
106 );
107
108 it.effect("tolerates a missing stack/stage filter target", () =>
109 Effect.gen(function* () {
110 const state = yield* InMemoryService({
111 app: { dev: { A: resource("A", {}) } },
112 });
113 expect(yield* withState(state, exportState({ stack: "nope" }))).toEqual({
114 resources: [],
115 });
116 expect(
117 yield* withState(state, exportState({ stack: "app", stage: "nope" })),
118 ).toEqual({ resources: [] });
119 }),
120 );
121
122 it.effect("skips records deleted between list and get", () =>
123 Effect.gen(function* () {
124 const inner = yield* InMemoryService({
125 app: {
126 dev: {
127 A: resource("A", {}),
128 B: resource("B", {}),
129 },
130 },
131 });
132 // Simulate a concurrent destroy racing the export: `list` still
133 // returns the FQN but `get` comes back empty.
134 const racy = {
135 ...inner,
136 get: (request: { stack: string; stage: string; fqn: string }) =>
137 request.fqn === "A" ? Effect.succeed(undefined) : inner.get(request),
138 };
139
140 const exported = yield* withState(racy, exportState());
141
142 expect(exported.resources.map((r) => r.fqn)).toEqual(["B"]);
143 }),
144 );
145});
146
147const resource = (
148 fqn: string,
149 attr: Record<string, unknown>,
150): ResourceState => ({
151 resourceType: "test:resource",
152 namespace: undefined,
153 fqn,
154 logicalId: fqn,
155 instanceId: `instance-${fqn}`,
156 providerVersion: 1,
157 status: "created",
158 downstream: [],
159 bindings: [],
160 props: {},
161 attr,
162});