1/**
2 * Binding-diff stability: a deploy with NO changes must produce an all-noop
3 * plan for resources that carry bindings — regardless of the binding data's
4 * shape and regardless of the state store kind.
5 *
6 * The engine's default diff marks a resource "update" whenever any of its
7 * binding rows compares non-noop (`diffBindings` in Diff.ts), so any
8 * asymmetry between what apply persists and what the next plan resolves
9 * re-updates every bound resource on every deploy, forever.
10 *
11 * Two store kinds are exercised:
12 * - the in-memory store used by the engine tests (retains live values), and
13 * - a JSON round-tripping store mirroring every durable store (local file,
14 * S3/R2, HTTP), which serializes state through
15 * `JSON.stringify(encodeState(...))` + `reviveState`.
16 */
17import { apply } from "@/Apply";
18import { provideFreshArtifactStore } from "@/Artifacts";
19import * as Output from "@/Output";
20import * as Plan from "@/Plan";
21import type { ResourceBinding } from "@/Resource";
22import * as Stack from "@/Stack";
23import { Stage } from "@/Stage";
24import { encodeState, InMemoryService, reviveState, State } from "@/State";
25import * as Test from "@/Test/Alchemy";
26import { describe, expect } from "alchemy-test";
27import * as Duration from "effect/Duration";
28import * as Effect from "effect/Effect";
29import * as Layer from "effect/Layer";
30import * as Redacted from "effect/Redacted";
31import {
32 BindingTarget,
33 TestLayers,
34 TestResource,
35 TestResourceHooks,
36} from "./test.resources.ts";
37
38const { test } = Test.make({
39 providers: TestLayers(),
40 state: Layer.effect(
41 State,
42 Effect.sync(() => InMemoryService({})),
43 ),
44});
45
46/**
47 * In-memory state that JSON round-trips every write, mirroring what durable
48 * state stores do to persisted state (function-valued leaves are dropped,
49 * `toJSON`-carrying objects — e.g. Effects — are replaced by their JSON
50 * form, `undefined` keys vanish).
51 */
52const jsonRoundTripState = () => {
53 const roundTrip = <T>(v: T): T =>
54 JSON.parse(JSON.stringify(encodeState(v)), reviveState);
55 const store: Record<string, Record<string, Record<string, any>>> = {};
56 return Layer.effect(
57 State,
58 Effect.sync(() =>
59 State.of(
60 (InMemoryService(store) as Effect.Effect<any>).pipe(
61 Effect.map((svc: any) => ({
62 ...svc,
63 set: (req: any) => svc.set({ ...req, value: roundTrip(req.value) }),
64 })),
65 ) as any,
66 ),
67 ),
68 );
69};
70
71type StoreKind = "in-memory" | "json";
72
73/** Minimal deploy/plan harness over a private state store. */
74const makeHarness = (name: string, kind: StoreKind) => {
75 // One store per harness, shared across the deploy and the follow-up plan
76 // (the layer itself is rebuilt for every Stack.make).
77 const store: Record<string, Record<string, Record<string, any>>> = {};
78 const stateLayer =
79 kind === "json"
80 ? jsonRoundTripState()
81 : Layer.effect(
82 State,
83 Effect.sync(() => InMemoryService(store)),
84 );
85 const compile = (effect: Effect.Effect<any, any, any>) =>
86 (effect as Effect.Effect<any, any, never>).pipe(
87 Stack.make({
88 name,
89 providers: TestLayers() as Layer.Layer<any, never, any>,
90 state: stateLayer,
91 }),
92 );
93 const deploy = (
94 effect: Effect.Effect<any, any, any>,
95 ): Effect.Effect<any, any, never> =>
96 compile(effect).pipe(
97 Effect.flatMap((compiled: any) =>
98 Plan.make(compiled).pipe(
99 Effect.flatMap(apply),
100 Effect.provide(compiled.services),
101 ),
102 ),
103 Effect.provide(Layer.succeed(Stage, "test")),
104 provideFreshArtifactStore,
105 ) as unknown as Effect.Effect<any, any, never>;
106 const plan = (
107 effect: Effect.Effect<any, any, any>,
108 ): Effect.Effect<any, any, never> =>
109 compile(effect).pipe(
110 Effect.flatMap((compiled: any) =>
111 Plan.make(compiled).pipe(Effect.provide(compiled.services)),
112 ),
113 Effect.provide(Layer.succeed(Stage, "test")),
114 provideFreshArtifactStore,
115 ) as unknown as Effect.Effect<any, any, never>;
116 return { deploy, plan };
117};
118
119const expectAllNoop = (plan: any) => {
120 for (const [fqn, node] of Object.entries<any>(plan.resources)) {
121 expect(`${fqn}:${node.action}`).toBe(`${fqn}:noop`);
122 for (const row of node.bindings ?? []) {
123 expect(`${fqn}:${row.sid}:${row.action}`).toBe(`${fqn}:${row.sid}:noop`);
124 }
125 }
126};
127
128/** Each shape deploys once, then asserts the second plan is entirely noop. */
129const shapes: Record<string, () => Effect.Effect<any, any, any>> = {
130 "PropExpr data": () =>
131 Effect.gen(function* () {
132 const upstream = yield* TestResource("Upstream", { string: "up-value" });
133 const target = yield* BindingTarget("Host", { name: "host" });
134 yield* target.bind("Cap", { env: { UPSTREAM: upstream.string } });
135 return target;
136 }),
137 "whole-resource data": () =>
138 Effect.gen(function* () {
139 const upstream = yield* TestResource("Upstream", { string: "up-value" });
140 const target = yield* BindingTarget("Host", { name: "host" });
141 yield* target.bind("Cap", {
142 env: { NAME: upstream.string },
143 resource: upstream,
144 } as any);
145 return target;
146 }),
147 "self-referential data": () =>
148 Effect.gen(function* () {
149 const target = yield* BindingTarget("Host", { name: "host" });
150 yield* target.bind("Self", { env: { ME: target.string } });
151 return target;
152 }),
153 "mapped Output data": () =>
154 Effect.gen(function* () {
155 const upstream = yield* TestResource("Upstream", { string: "up-value" });
156 const target = yield* BindingTarget("Host", { name: "host" });
157 yield* target.bind("Cap", {
158 env: { MAPPED: upstream.string.pipe(Output.map((s) => `${s}-sfx`)) },
159 });
160 return target;
161 }),
162 "Effect-valued data": () =>
163 Effect.gen(function* () {
164 const target = yield* BindingTarget("Host", { name: "host" });
165 yield* target.bind("Cap", {
166 env: { NAME: Effect.succeed("x") },
167 } as any);
168 return target;
169 }),
170 // A tagged Resource class is a function-typed Effect — the standard
171 // circular-binding pattern (`env: { WORKER: WorkerClass }`). It passes
172 // through plan/apply resolution untouched, and a JSON store persists it
173 // via its `toJSON` as an `{"_id":"Effect",...}` relic. Without stripping
174 // at the commit boundary, `diffBindings` compares that relic against the
175 // live class (stripped to `undefined`) — a phantom "update" on every
176 // deploy, forever.
177 "class-valued (Effectable) data": () =>
178 Effect.gen(function* () {
179 const target = yield* BindingTarget("Host", { name: "host" });
180 yield* target.bind("Cap", { env: { PEER: TestResource } } as any);
181 return target;
182 }),
183 "Redacted data": () =>
184 Effect.gen(function* () {
185 const target = yield* BindingTarget("Host", { name: "host" });
186 yield* target.bind("Cap", {
187 env: { SECRET: Redacted.make("shh") },
188 } as any);
189 return target;
190 }),
191 "Duration data": () =>
192 Effect.gen(function* () {
193 const target = yield* BindingTarget("Host", { name: "host" });
194 yield* target.bind("Cap", {
195 env: { TIMEOUT: Duration.seconds(30) },
196 } as any);
197 return target;
198 }),
199 "mutual bindings": () =>
200 Effect.gen(function* () {
201 const A = yield* BindingTarget("A", { name: "a" });
202 const B = yield* BindingTarget("B", { name: "b" });
203 yield* A.bind("FromB", { env: { PEER: B.string } });
204 yield* B.bind("FromA", { env: { PEER: A.string } });
205 return { A, B };
206 }),
207};
208
209for (const kind of ["in-memory", "json"] as StoreKind[]) {
210 describe(
211 `no-change redeploy is all-noop (${kind} state)`,
212 { tags: ["unit", "local"] },
213 () => {
214 for (const [shape, program] of Object.entries(shapes)) {
215 test(
216 shape,
217 Effect.gen(function* () {
218 const { deploy, plan } = makeHarness(
219 `bind-${kind}-${shape.replaceAll(/[^a-zA-Z0-9]/g, "-")}`,
220 kind,
221 );
222 yield* deploy(program());
223 expectAllNoop(yield* plan(program()));
224 }),
225 );
226 }
227 },
228 );
229}
230
231describe(
232 "binding rows are deterministically ordered",
233 { tags: ["unit", "local"] },
234 () => {
235 // Bindings are registered by concurrently-built layers doing real IO before
236 // `host.bind`, so registration order is not stable across deploys. The
237 // engine sorts rows by sid at every boundary (dedupeBindings/diffBindings)
238 // so provider diff/reconcile inputs and persisted state never churn on a
239 // registration-order flip.
240 test(
241 "a registration-order flip stays noop and providers observe sorted rows",
242 Effect.gen(function* () {
243 const { deploy, plan } = makeHarness("bind-order", "in-memory");
244
245 const program = (flipped: boolean) =>
246 Effect.gen(function* () {
247 const target = yield* BindingTarget("Host", { name: "host" });
248 const bindA = target.bind("CapA", { env: { A: "1" } });
249 const bindB = target.bind("CapB", { env: { B: "2" } });
250 if (flipped) {
251 yield* bindB;
252 yield* bindA;
253 } else {
254 yield* bindA;
255 yield* bindB;
256 }
257 return target;
258 });
259
260 yield* deploy(program(false));
261
262 const observed: ResourceBinding[][] = [];
263 const p: any = yield* plan(program(true)).pipe(
264 Effect.provide(
265 Layer.succeed(TestResourceHooks, {
266 diff: (_id, newBindings) =>
267 Effect.sync(() => void observed.push(newBindings)),
268 }),
269 ),
270 );
271
272 expectAllNoop(p);
273 // The provider's diff observed the sid-sorted row order even though
274 // registration order was flipped between deploys.
275 expect(observed.length).toBeGreaterThan(0);
276 for (const rows of observed) {
277 expect(rows.map((r) => r.sid)).toEqual(["CapA", "CapB"]);
278 }
279 // The plan node's rows are sorted too.
280 expect(p.resources.Host.bindings.map((r: any) => r.sid)).toEqual([
281 "CapA",
282 "CapB",
283 ]);
284 }),
285 );
286 },
287);
288
289describe(
290 "persisted binding rows are plain data",
291 { tags: ["unit", "local"] },
292 () => {
293 // Mirror of the "interrupted create persists no unresolved Output exprs"
294 // apply test, for bindings: even non-terminal commits must not persist live
295 // Output proxies or Effect leaves into the state store.
296 test(
297 "terminal state holds no live Effect leaves in binding data",
298 Effect.gen(function* () {
299 const store: Record<string, any> = {};
300 const stateLayer = Layer.effect(
301 State,
302 Effect.sync(() => InMemoryService(store)),
303 );
304 const program = Effect.gen(function* () {
305 const target = yield* BindingTarget("Host", { name: "host" });
306 yield* target.bind("Cap", {
307 env: { PEER: TestResource, NAME: Effect.succeed("x") },
308 } as any);
309 return target;
310 });
311 yield* (program as unknown as Effect.Effect<any, any, never>).pipe(
312 Stack.make({
313 name: "bind-plain",
314 providers: TestLayers() as Layer.Layer<any, never, any>,
315 state: stateLayer,
316 }),
317 Effect.flatMap((compiled: any) =>
318 Plan.make(compiled).pipe(
319 Effect.flatMap(apply),
320 Effect.provide(compiled.services),
321 ),
322 ),
323 Effect.provide(Layer.succeed(Stage, "test")),
324 provideFreshArtifactStore,
325 ) as unknown as Effect.Effect<any, any, never>;
326
327 const persisted = store["bind-plain"].test.Host;
328 const hasLiveEffect = (value: unknown): boolean => {
329 if (Effect.isEffect(value)) return true;
330 if (Array.isArray(value)) return value.some(hasLiveEffect);
331 if (value && typeof value === "object") {
332 return Object.values(value).some(hasLiveEffect);
333 }
334 return false;
335 };
336 expect(hasLiveEffect(persisted.bindings)).toBe(false);
337 }),
338 );
339 },
340);