1/**
2 * `--force` must refresh stale bound attributes.
3 *
4 * A forced deploy upgrades every noop to an update, so each upstream's
5 * `reconcile` re-runs and may produce FRESH attributes (that is the point of
6 * `--force`: re-converge against real cloud state). Consumers referencing an
7 * upstream's attrs — through props or binding data — must therefore observe
8 * the forced reconcile's output, not the attribute snapshot persisted in
9 * state. Previously `resolveResource` still treated forced-noop upstreams as
10 * stable and baked their persisted attrs into consumers' plan props, so a
11 * consumer kept stale values even under `--force`.
12 */
13import { apply } from "@/Apply";
14import { provideFreshArtifactStore } from "@/Artifacts";
15import * as Plan from "@/Plan";
16import * as Stack from "@/Stack";
17import { Stage } from "@/Stage";
18import { InMemoryService, State, type ResourceState } from "@/State";
19import * as Test from "@/Test/Alchemy";
20import { describe, expect } from "alchemy-test";
21import * as Effect from "effect/Effect";
22import * as Layer from "effect/Layer";
23import { BindingTarget, TestLayers, TestResource } from "./test.resources.ts";
24
25const { test } = Test.make({
26 providers: TestLayers(),
27 state: Layer.effect(
28 State,
29 Effect.sync(() => InMemoryService({})),
30 ),
31});
32
33const STAGE = "test";
34
35const makeHarness = (name: string) => {
36 const store: Record<string, Record<string, Record<string, any>>> = {};
37 const stateLayer = Layer.effect(
38 State,
39 Effect.sync(() => InMemoryService(store)),
40 );
41 const compile = (effect: Effect.Effect<any, any, any>) =>
42 (effect as Effect.Effect<any, any, never>).pipe(
43 Stack.make({
44 name,
45 providers: TestLayers() as Layer.Layer<any, never, any>,
46 state: stateLayer,
47 }),
48 );
49 const deploy = (
50 effect: Effect.Effect<any, any, any>,
51 options?: Plan.MakePlanOptions,
52 ): Effect.Effect<any, any, never> =>
53 compile(effect).pipe(
54 Effect.flatMap((compiled: any) =>
55 Plan.make(compiled, options).pipe(
56 Effect.flatMap(apply),
57 Effect.provide(compiled.services),
58 ),
59 ),
60 Effect.provide(Layer.succeed(Stage, STAGE)),
61 provideFreshArtifactStore,
62 ) as unknown as Effect.Effect<any, any, never>;
63 const plan = (
64 effect: Effect.Effect<any, any, any>,
65 options?: Plan.MakePlanOptions,
66 ): Effect.Effect<any, any, never> =>
67 compile(effect).pipe(
68 Effect.flatMap((compiled: any) =>
69 Plan.make(compiled, options).pipe(Effect.provide(compiled.services)),
70 ),
71 Effect.provide(Layer.succeed(Stage, STAGE)),
72 provideFreshArtifactStore,
73 ) as unknown as Effect.Effect<any, any, never>;
74 const getRow = (fqn: string) =>
75 Effect.sync(() => store[name]?.[STAGE]?.[fqn] as ResourceState);
76 const setRow = (fqn: string, value: ResourceState) =>
77 Effect.sync(() => void (store[name][STAGE][fqn] = value));
78 return { deploy, plan, getRow, setRow };
79};
80
81describe(
82 "--force refreshes stale bound attrs",
83 { tags: ["unit", "local"] },
84 () => {
85 test(
86 "a consumer's props observe the forced upstream reconcile, not stale state",
87 Effect.gen(function* () {
88 const { deploy, getRow, setRow } = makeHarness("force-props");
89 const program = Effect.gen(function* () {
90 const upstream = yield* TestResource("Upstream", { string: "fresh" });
91 const consumer = yield* TestResource("Consumer", {
92 string: upstream.string,
93 });
94 return consumer;
95 });
96
97 yield* deploy(program);
98
99 // Simulate drift: the persisted attr snapshot is stale relative to
100 // what the provider's reconcile would produce today.
101 const row = yield* getRow("Upstream");
102 yield* setRow("Upstream", {
103 ...row,
104 attr: { ...(row.attr as any), string: "stale" },
105 } as ResourceState);
106
107 yield* deploy(program, { force: true });
108
109 // The forced upstream reconcile produced "fresh" again...
110 expect(((yield* getRow("Upstream")).attr as any).string).toBe("fresh");
111 // ...and the consumer observed IT — not the stale persisted snapshot.
112 expect(((yield* getRow("Consumer")).attr as any).string).toBe("fresh");
113 expect(((yield* getRow("Consumer")).props as any).string).toBe("fresh");
114 }),
115 );
116
117 test(
118 "binding data observes the forced upstream reconcile, not stale state",
119 Effect.gen(function* () {
120 const { deploy, getRow, setRow } = makeHarness("force-bindings");
121 const program = Effect.gen(function* () {
122 const upstream = yield* TestResource("Upstream", { string: "fresh" });
123 const host = yield* BindingTarget("Host", { name: "host" });
124 yield* host.bind("Cap", { env: { UP: upstream.string } });
125 return host;
126 });
127
128 yield* deploy(program);
129
130 const row = yield* getRow("Upstream");
131 yield* setRow("Upstream", {
132 ...row,
133 attr: { ...(row.attr as any), string: "stale" },
134 } as ResourceState);
135
136 yield* deploy(program, { force: true });
137
138 // BindingTarget's reconcile merges binding env into its attrs — the
139 // bound value must come from the forced reconcile's fresh output.
140 expect(((yield* getRow("Host")).attr as any).env).toEqual({
141 UP: "fresh",
142 });
143 // The persisted binding row carries the fresh payload too.
144 expect((yield* getRow("Host")).bindings).toEqual([
145 { sid: "Cap", data: { env: { UP: "fresh" } } },
146 ]);
147 }),
148 );
149
150 test(
151 "an unforced no-change redeploy stays all-noop (control)",
152 Effect.gen(function* () {
153 const { deploy, plan } = makeHarness("force-control");
154 const program = Effect.gen(function* () {
155 const upstream = yield* TestResource("Upstream", { string: "fresh" });
156 const host = yield* BindingTarget("Host", { name: "host" });
157 yield* host.bind("Cap", { env: { UP: upstream.string } });
158 return host;
159 });
160 yield* deploy(program);
161
162 // The force fix must not destabilize ordinary planning.
163 const p: any = yield* plan(program);
164 expect(p.resources.Upstream.action).toBe("noop");
165 expect(p.resources.Host.action).toBe("noop");
166
167 // And a forced plan upgrades everything to an update.
168 const forced: any = yield* plan(program, { force: true });
169 expect(forced.resources.Upstream.action).toBe("update");
170 expect(forced.resources.Host.action).toBe("update");
171 }),
172 );
173 },
174);