1import { Action } from "@/Action";
2import { adopt, AdoptPolicy, Unowned } from "@/AdoptPolicy";
3import { dedupeBindings } from "@/Diff";
4import type { Input, InputProps } from "@/Input";
5import * as Namespace from "@/Namespace.ts";
6import * as Output from "@/Output";
7import * as Plan from "@/Plan";
8import * as Provider from "@/Provider";
9import { UnsatisfiedResourceCycle } from "@/Plan";
10import { remote } from "@/ProviderMode.ts";
11import { renamedFrom } from "@/Rename.ts";
12import { Progress, type ProgressEvent } from "@/Report.ts";
13import { Resource, type ResourceBinding } from "@/Resource";
14import { packEnvValue } from "@/RuntimeContext.ts";
15import { AuthProviders } from "@/Auth/AuthProvider.ts";
16import * as Stack from "@/Stack";
17import { Stage } from "@/Stage";
18import { hashInput } from "@/Util/sha256";
19import {
20 InMemoryService,
21 inMemoryState,
22 State,
23 type ResourceState,
24 type ResourceStatus,
25} from "@/State";
26import * as Test from "@/Test/Alchemy";
27import { describe, expect } from "alchemy-test";
28import * as Cause from "effect/Cause";
29import * as Config from "effect/Config";
30import * as Effect from "effect/Effect";
31import * as Exit from "effect/Exit";
32import * as Layer from "effect/Layer";
33import * as Logger from "effect/Logger";
34import * as Option from "effect/Option";
35import * as Redacted from "effect/Redacted";
36import {
37 AliasedWidget,
38 aliasedWidgetProvider,
39 ArtifactProbe,
40 BindingTarget,
41 Bucket,
42 capturedConfigHost,
43 Function,
44 inDev,
45 KindStablesResource,
46 ModalResource,
47 NoPrecreateBindingTarget,
48 OverrideStablesResource,
49 ProbeBinding,
50 Queue,
51 TestLayers,
52 TestResource,
53 TestResourceHooks,
54 type TestResourceProps,
55} from "./test.resources";
56
57const TEST_STACK = "test";
58const TEST_STAGE = "test";
59
60// Fresh in-memory state per test run so seeded resources from one test
61// don't leak into another in the same file.
62const freshState = Layer.effect(
63 State,
64 Effect.sync(() => InMemoryService({})),
65);
66
67const { test } = Test.make({
68 providers: TestLayers(),
69 state: freshState,
70});
71
72// Resolve stack name/stage from ambient Stack if present (for test.provider)
73// otherwise fall back to the file-level defaults (for plain test()).
74const resolveStackId = Effect.gen(function* () {
75 const ambient = yield* Effect.serviceOption(Stack.Stack);
76 return Option.match(ambient, {
77 onNone: () => ({ name: TEST_STACK, stage: TEST_STAGE }),
78 onSome: (s) => ({ name: s.name, stage: s.stage }),
79 });
80});
81
82const seed = (resources: Record<string, ResourceState>) =>
83 Effect.gen(function* () {
84 const { name, stage } = yield* resolveStackId;
85 const state = yield* yield* State;
86 for (const [fqn, value] of Object.entries(resources)) {
87 yield* state.set({ stack: name, stage, fqn, value });
88 }
89 });
90
91const instanceId = "852f6ec2e19b66589825efe14dca2971";
92
93const makePlan = <A, Err = never, Req = never>(
94 effect: Effect.Effect<A, Err, Req>,
95 options?: Plan.FilteredPlanOptions,
96): Effect.Effect<Plan.Plan<A | undefined>, Err, State> =>
97 // @ts-expect-error - Stack.make's typing erases R unsoundly here
98 Effect.gen(function* () {
99 const { name, stage } = yield* resolveStackId;
100 // @ts-expect-error
101 return yield* effect.pipe(
102 // @ts-expect-error
103 Stack.make({
104 name,
105 providers: Layer.empty,
106 state: inMemoryState(),
107 }),
108 Effect.provideService(Stage, stage),
109 Effect.flatMap((stackSpec: any) => Plan.make(stackSpec, options)),
110 Effect.provide(TestLayers()),
111 );
112 });
113
114const makePlanWithCustomStack =
115 (stackSpec: any) =>
116 <A, Err = never, Req = never>(
117 effect: Effect.Effect<A, Err, Req>,
118 ): Effect.Effect<Plan.Plan<A>, Err, State> =>
119 // @ts-expect-error
120 Effect.gen(function* () {
121 const { name, stage } = yield* resolveStackId;
122 // @ts-expect-error
123 return yield* effect.pipe(
124 // @ts-expect-error
125 Stack.make({
126 name,
127 providers: Layer.empty,
128 state: inMemoryState(),
129 stack: stackSpec,
130 }),
131 Effect.provideService(Stage, stage),
132 Effect.flatMap(Plan.make),
133 Effect.provide(TestLayers()),
134 );
135 });
136
137describe("Action output convergence", { tags: ["unit", "local"] }, () => {
138 const seedAction = (
139 id: string,
140 input: Record<string, unknown>,
141 output: unknown,
142 status: "ran" | "running" = "ran",
143 ) =>
144 Effect.gen(function* () {
145 const { name, stage } = yield* resolveStackId;
146 const state = yield* yield* State;
147 const row = {
148 kind: "action" as const,
149 fqn: id,
150 logicalId: id,
151 namespace: undefined,
152 actionType: "Compute",
153 inputHash: yield* hashInput(input),
154 input,
155 downstream: ["Host"],
156 };
157 yield* state.set({
158 stack: name,
159 stage,
160 fqn: id,
161 value:
162 status === "ran" ? { ...row, status, output } : { ...row, status },
163 });
164 });
165
166 const seedHost = (value: string) =>
167 seed({
168 Host: {
169 instanceId,
170 providerVersion: 0,
171 logicalId: "Host",
172 fqn: "Host",
173 namespace: undefined,
174 resourceType: "Test.Function",
175 status: "created",
176 props: { name: "host", env: { RESULT: value } },
177 attr: {
178 name: "host",
179 env: { RESULT: value },
180 functionArn: "arn:test:host",
181 },
182 bindings: [],
183 downstream: [],
184 },
185 });
186
187 test(
188 "unchanged Action output in host env produces repeated noop plans without running the body",
189 Effect.gen(function* () {
190 let calls = 0;
191 const Compute = Action("Compute", (_: { revision: string }) =>
192 Effect.sync(() => {
193 calls++;
194 return { value: "v1" };
195 }),
196 );
197 yield* seedAction("Compute", { revision: "one" }, { value: "v1" });
198 yield* seedHost("v1");
199 const program = Effect.gen(function* () {
200 const result = yield* Compute({ revision: "one" });
201 return yield* Function("Host", {
202 name: "host",
203 env: { RESULT: result.value },
204 });
205 });
206 for (const _ of [1, 2]) {
207 const plan = yield* program.pipe(makePlan);
208 expect(plan.actions.Compute.action).toBe("noop");
209 expect(plan.actions.Compute.downstream).toContain("Host");
210 expect(plan.resources.Host.action).toBe("noop");
211 expect(calls).toBe(0);
212 }
213 }),
214 );
215
216 test(
217 "a new consumer receives the persisted output of a noop Action and retains its dependency edge",
218 Effect.gen(function* () {
219 const Compute = Action("Compute", (_: {}) =>
220 Effect.succeed({ value: "v1" }),
221 );
222 yield* seedAction("Compute", {}, { value: "v1" });
223 const plan = yield* Effect.gen(function* () {
224 const result = yield* Compute({});
225 return yield* Function("Host", { env: { RESULT: result.value } });
226 }).pipe(makePlan);
227 expect(plan.actions.Compute.action).toBe("noop");
228 expect(plan.actions.Compute.downstream).toContain("Host");
229 expect(plan.resources.Host).toMatchObject({
230 action: "create",
231 props: { env: { RESULT: "v1" } },
232 });
233 }),
234 );
235
236 test(
237 "unchanged Action output in binding data does not dirty the host",
238 Effect.gen(function* () {
239 const Compute = Action("Compute", (_: {}) =>
240 Effect.succeed({ value: "v1" }),
241 );
242 yield* seedAction("Compute", {}, { value: "v1" });
243 yield* seed({
244 Host: {
245 instanceId,
246 providerVersion: 0,
247 logicalId: "Host",
248 fqn: "Host",
249 namespace: undefined,
250 resourceType: "Test.BindingTarget",
251 status: "created",
252 props: { name: "host" },
253 attr: {
254 name: "host",
255 string: "",
256 env: { RESULT: "v1" },
257 replaceString: undefined,
258 },
259 bindings: [{ sid: "Result", data: { env: { RESULT: "v1" } } }],
260 downstream: [],
261 },
262 });
263 const plan = yield* Effect.gen(function* () {
264 const result = yield* Compute({});
265 const host = yield* BindingTarget("Host", { name: "host" });
266 yield* host.bind("Result", { env: { RESULT: result.value } });
267 return host;
268 }).pipe(makePlan);
269 expect(plan.actions.Compute.action).toBe("noop");
270 expect(plan.actions.Compute.downstream).toContain("Host");
271 expect(plan.resources.Host.action).toBe("noop");
272 expect(plan.resources.Host.bindings).toEqual([
273 { sid: "Result", action: "noop", data: { env: { RESULT: "v1" } } },
274 ]);
275 }),
276 );
277
278 for (const scenario of [
279 "changed input",
280 "force",
281 "unfinished prior run",
282 ] as const) {
283 test(
284 `${scenario} keeps the Action output unresolved instead of substituting stale state`,
285 Effect.gen(function* () {
286 const Compute = Action("Compute", (input: { revision: string }) =>
287 Effect.succeed({ value: input.revision }),
288 );
289 yield* seedAction(
290 "Compute",
291 { revision: "one" },
292 { value: "old" },
293 scenario === "unfinished prior run" ? "running" : "ran",
294 );
295 yield* seedHost("old");
296 const plan = yield* Effect.gen(function* () {
297 const result = yield* Compute({
298 revision: scenario === "changed input" ? "two" : "one",
299 });
300 return yield* Function("Host", {
301 name: "host",
302 env: { RESULT: result.value },
303 });
304 }).pipe((program) =>
305 makePlan(program, { force: scenario === "force" }),
306 );
307 expect(plan.actions.Compute.action).toBe("run");
308 expect(plan.resources.Host.action).toBe("update");
309 const host = plan.resources.Host;
310 if (host.action !== "update") throw new Error("Expected a host update");
311 expect(Output.hasOutputs(host.props.env.RESULT)).toBe(true);
312 expect(plan.actions.Compute.downstream).toContain("Host");
313 }),
314 );
315 }
316
317 for (const value of [undefined, null, false, 0, ""] as const) {
318 test(
319 `persisted ${String(value)} is a valid Action result, not a missing output`,
320 Effect.gen(function* () {
321 const Compute = Action("Compute", (_: {}) => Effect.succeed(value));
322 yield* seedAction("Compute", {}, value);
323 yield* seedHost(String(value));
324 const plan = yield* Effect.gen(function* () {
325 const result = yield* Compute({});
326 return yield* Function("Host", {
327 name: "host",
328 env: { RESULT: Output.map(result, (value) => String(value)) },
329 });
330 }).pipe(makePlan);
331 expect(plan.actions.Compute.action).toBe("noop");
332 expect(plan.resources.Host.action).toBe("noop");
333 }),
334 );
335 }
336
337 for (const changed of [false, true]) {
338 test(
339 `Action chains ${changed ? "invalidate downstream consumers when root input changes" : "converge when all inputs are unchanged"}`,
340 Effect.gen(function* () {
341 const Compute = Action("Compute", (input: { value: string }) =>
342 Effect.succeed({ value: input.value }),
343 );
344 yield* seedAction("First", { value: "v1" }, { value: "v1" });
345 yield* seedAction("Second", { value: "v1" }, { value: "v1" });
346 yield* seedHost("v1");
347 const plan = yield* Effect.gen(function* () {
348 const first = yield* Compute("First", {
349 value: changed ? "v2" : "v1",
350 });
351 const second = yield* Compute("Second", { value: first.value });
352 return yield* Function("Host", {
353 name: "host",
354 env: { RESULT: second.value },
355 });
356 }).pipe(makePlan);
357 expect(plan.actions.First.action).toBe(changed ? "run" : "noop");
358 expect(plan.actions.Second.action).toBe(changed ? "run" : "noop");
359 expect(plan.resources.Host.action).toBe(changed ? "update" : "noop");
360 expect(plan.actions.First.downstream).toContain("Second");
361 expect(plan.actions.Second.downstream).toContain("Host");
362 }),
363 );
364 }
365
366 test(
367 "changing only the Action body preserves the existing input-based invalidation contract",
368 Effect.gen(function* () {
369 const Compute = Action("Compute", (_: {}) =>
370 Effect.succeed({ value: "new-body" }),
371 );
372 yield* seedAction("Compute", {}, { value: "old-body" });
373 const plan = yield* Effect.gen(function* () {
374 const result = yield* Compute({});
375 return yield* Function("Host", { env: { RESULT: result.value } });
376 }).pipe(makePlan);
377 expect(plan.actions.Compute.action).toBe("noop");
378 expect(plan.resources.Host).toMatchObject({
379 action: "create",
380 props: { env: { RESULT: "old-body" } },
381 });
382 }),
383 );
384
385 test(
386 "a literal environment converges while an unrelated Action remains noop",
387 Effect.gen(function* () {
388 const Compute = Action("Compute", (_: {}) =>
389 Effect.succeed({ value: "v1" }),
390 );
391 yield* seedAction("Compute", {}, { value: "v1" });
392 yield* seedHost("v1");
393 const plan = yield* Effect.gen(function* () {
394 yield* Compute({});
395 return yield* Function("Host", { name: "host", env: { RESULT: "v1" } });
396 }).pipe(makePlan);
397 expect(plan.actions.Compute.action).toBe("noop");
398 expect(plan.resources.Host.action).toBe("noop");
399 }),
400 );
401
402 test(
403 "a noop Action does not suppress changes to other consumer props",
404 Effect.gen(function* () {
405 const Compute = Action("Compute", (_: {}) =>
406 Effect.succeed({ value: "v1" }),
407 );
408 yield* seedAction("Compute", {}, { value: "v1" });
409 yield* seedHost("v1");
410 const plan = yield* Effect.gen(function* () {
411 const result = yield* Compute({});
412 return yield* Function("Host", {
413 name: "changed",
414 env: { RESULT: result.value },
415 });
416 }).pipe(makePlan);
417 expect(plan.actions.Compute.action).toBe("noop");
418 expect(plan.resources.Host.action).toBe("update");
419 }),
420 );
421
422 test(
423 "an unchanged Action output does not hide a replacement-sensitive prop change",
424 Effect.gen(function* () {
425 const Compute = Action("Compute", (_: {}) =>
426 Effect.succeed({ value: "v1" }),
427 );
428 yield* seedAction("Compute", {}, { value: "v1" });
429 yield* seed({
430 Host: {
431 instanceId,
432 providerVersion: 0,
433 logicalId: "Host",
434 fqn: "Host",
435 namespace: undefined,
436 resourceType: "Test.BindingTarget",
437 status: "created",
438 props: { name: "host", string: "v1", replaceString: "old" },
439 attr: { name: "host", string: "v1", env: {}, replaceString: "old" },
440 bindings: [],
441 downstream: [],
442 },
443 });
444 const plan = yield* Effect.gen(function* () {
445 const result = yield* Compute({});
446 return yield* BindingTarget("Host", {
447 name: "host",
448 string: result.value,
449 replaceString: "new",
450 });
451 }).pipe(makePlan);
452 expect(plan.actions.Compute.action).toBe("noop");
453 expect(plan.resources.Host.action).toBe("replace");
454 }),
455 );
456
457 for (const stable of [false, true]) {
458 test.provider(
459 `resource-backed Action inputs ${stable ? "reuse a stable property" : "invalidate a changing property"}`,
460 (stack) =>
461 Effect.gen(function* () {
462 const Compute = Action("Compute", (input: { value: string }) =>
463 Effect.succeed(input),
464 );
465 const program = (value: string) =>
466 Effect.gen(function* () {
467 const source = yield* TestResource("Source", { string: value });
468 const result = yield* Compute({
469 value: stable ? source.stableString : source.string,
470 });
471 return yield* Function("Host", { env: { RESULT: result.value } });
472 });
473 yield* stack.deploy(program("v1"));
474 const plan = yield* stack.plan(program("v2"));
475 expect(plan.resources.Source.action).toBe("update");
476 expect(plan.actions.Compute.action).toBe(stable ? "noop" : "run");
477 expect(plan.resources.Host.action).toBe(stable ? "noop" : "update");
478 expect(plan.resources.Source.downstream).toContain("Compute");
479 expect(plan.actions.Compute.downstream).toContain("Host");
480 }),
481 );
482 }
483
484 test(
485 "partial stable attributes cannot prove that a whole-resource Action input is unchanged",
486 Effect.gen(function* () {
487 const source = { stableString: "Source", stableArray: ["Source"] };
488 yield* seed({
489 Source: {
490 instanceId,
491 providerVersion: 0,
492 logicalId: "Source",
493 fqn: "Source",
494 namespace: undefined,
495 resourceType: "Test.TestResource",
496 status: "created",
497 props: { string: "v1" },
498 attr: source,
499 bindings: [],
500 downstream: ["Compute"],
501 },
502 });
503 yield* seedAction("Compute", { source }, { value: "v1" });
504 yield* seedHost("v1");
505 const Compute = Action(
506 "Compute",
507 (input: { source: { string?: string } }) =>
508 Effect.succeed({ value: input.source.string ?? "v1" }),
509 );
510 const plan = yield* Effect.gen(function* () {
511 const source = yield* TestResource("Source", { string: "v2" });
512 const result = yield* Compute({ source: Output.of(source) });
513 return yield* Function("Host", {
514 name: "host",
515 env: { RESULT: result.value },
516 });
517 }).pipe(makePlan);
518 expect(plan.resources.Source.action).toBe("update");
519 expect(plan.actions.Compute.action).toBe("run");
520 expect(plan.resources.Host.action).toBe("update");
521 }),
522 );
523
524 test(
525 "cyclic Action inputs remain unresolved instead of waiting on their own cached plans",
526 Effect.gen(function* () {
527 const Compute = Action("Compute", (input: { value: string }) =>
528 Effect.succeed(input),
529 );
530 yield* seedAction("First", { value: "v1" }, { value: "v1" });
531 yield* seedAction("Second", { value: "v1" }, { value: "v1" });
532 const plan = yield* Effect.gen(function* () {
533 const input: { value: Input<string> } = { value: "v1" };
534 const first = yield* Compute("First", input);
535 const second = yield* Compute("Second", { value: first.value });
536 input.value = second.value;
537 return second;
538 }).pipe(makePlan);
539 expect(plan.actions.First.action).toBe("run");
540 expect(plan.actions.Second.action).toBe("run");
541 }),
542 { timeout: 1000 },
543 );
544
545 test(
546 "a resource prop and Action input cycle does not deadlock output resolution",
547 Effect.gen(function* () {
548 const Compute = Action("Compute", (input: { value: string }) =>
549 Effect.succeed(input),
550 );
551 yield* seedAction("Compute", { value: "v1" }, { value: "v1" });
552 yield* seedHost("v1");
553 const plan = yield* Effect.gen(function* () {
554 const input: { value: Input<string> } = { value: "v1" };
555 const result = yield* Compute(input);
556 const host = yield* Function("Host", {
557 name: "host",
558 env: { RESULT: result.value },
559 });
560 input.value = host.env.RESULT;
561 return host;
562 }).pipe(makePlan);
563 expect(plan.actions.Compute.action).toBe("run");
564 expect(plan.resources.Host.action).toBe("update");
565 }),
566 { timeout: 1000 },
567 );
568
569 test(
570 "a new Action retains its binding dependency without changing host props",
571 Effect.gen(function* () {
572 const Compute = Action("Compute", (_: {}) =>
573 Effect.succeed({ value: "v1" }),
574 );
575 const plan = yield* Effect.gen(function* () {
576 const result = yield* Compute({});
577 const host = yield* BindingTarget("Host", { name: "host" });
578 yield* host.bind("Result", { env: { RESULT: result.value } });
579 return host;
580 }).pipe(makePlan);
581 expect(plan.actions.Compute.downstream).toContain("Host");
582 expect(plan.resources.Host).toMatchObject({
583 action: "create",
584 props: { name: "host" },
585 });
586 }),
587 );
588});
589
590test(
591 "artifacts are isolated by FQN during plan diff for namespaced resources",
592 Effect.gen(function* () {
593 yield* seed({
594 "Left/Shared": {
595 instanceId: "left-instance",
596 providerVersion: 0,
597 logicalId: "Shared",
598 fqn: "Left/Shared",
599 namespace: { Id: "Left" },
600 resourceType: "Test.ArtifactProbe",
601 status: "created",
602 props: {
603 value: "left-v1",
604 },
605 attr: {
606 value: "left-v1",
607 artifactValue: undefined,
608 },
609 bindings: [],
610 downstream: [],
611 },
612 "Right/Shared": {
613 instanceId: "right-instance",
614 providerVersion: 0,
615 logicalId: "Shared",
616 fqn: "Right/Shared",
617 namespace: { Id: "Right" },
618 resourceType: "Test.ArtifactProbe",
619 status: "created",
620 props: {
621 value: "right-v1",
622 },
623 attr: {
624 value: "right-v1",
625 artifactValue: undefined,
626 },
627 bindings: [],
628 downstream: [],
629 },
630 });
631 const Site = (id: string, props: { value: string }) =>
632 Effect.gen(function* () {
633 return yield* ArtifactProbe("Shared", { value: props.value });
634 }).pipe(Namespace.push(id));
635
636 const plan = yield* Effect.gen(function* () {
637 const left = yield* Site("Left", { value: "left-v2" });
638 const right = yield* Site("Right", { value: "right-v2" });
639 return { left, right };
640 }).pipe(makePlan);
641
642 expect(plan.resources["Left/Shared"]?.action).toEqual("update");
643 expect(plan.resources["Right/Shared"]?.action).toEqual("update");
644 }),
645 { tags: ["unit", "local"] },
646);
647
648test(
649 "create all resources when plan is empty",
650 Effect.gen(function* () {
651 expect(
652 yield* Effect.gen(function* () {
653 const bucket = yield* Bucket("MyBucket", {
654 name: "test-bucket",
655 });
656 const queue = yield* Queue("MyQueue", {
657 name: "test-queue",
658 });
659
660 return {
661 queueUrl: queue.queueUrl,
662 bucketArn: bucket.bucketArn,
663 };
664 }).pipe(makePlan),
665 ).toMatchObject({
666 resources: {
667 MyBucket: {
668 action: "create",
669 bindings: [],
670 props: {
671 name: "test-bucket",
672 },
673 state: undefined,
674 },
675 MyQueue: {
676 action: "create",
677 bindings: [],
678 props: {
679 name: "test-queue",
680 },
681 state: undefined,
682 },
683 },
684 deletions: expect.toSatisfy(
685 (d: any) => Object.keys(d).length === 0,
686 "empty object",
687 ),
688 });
689 }),
690 { tags: ["unit", "local"] },
691);
692
693test(
694 "reports each diffed resource and action through the ambient Progress reporter",
695 Effect.gen(function* () {
696 yield* seed({
697 A: {
698 instanceId,
699 providerVersion: 0,
700 logicalId: "A",
701 fqn: "A",
702 namespace: undefined,
703 resourceType: "Test.BindingTarget",
704 status: "created",
705 props: {
706 name: "target",
707 },
708 attr: {
709 name: "target",
710 env: {},
711 },
712 bindings: [],
713 downstream: [],
714 },
715 });
716 const Announce = Action("Announce", (_: { table: string }) =>
717 Effect.succeed(1),
718 );
719 const events: Array<ProgressEvent> = [];
720 yield* Effect.gen(function* () {
721 const target = yield* BindingTarget("A", { name: "target" });
722 yield* target.bind("TestBinding", { env: { FEATURE_FLAG: "on" } });
723 yield* Queue("MyQueue", { name: "test-queue" });
724 yield* Announce({ table: "users" });
725 }).pipe(
726 makePlan,
727 Effect.provideService(Progress, (event) =>
728 Effect.sync(() => {
729 events.push(event);
730 }),
731 ),
732 );
733
734 // Phase markers land before any node event: state loads, then diffing.
735 expect(
736 events
737 .filter((event) => event._tag === "plan.phase")
738 .map(({ phase }) => phase),
739 ).toEqual(["loading-state", "computing-plan"]);
740 expect(events[0]).toMatchObject({
741 _tag: "plan.phase",
742 phase: "loading-state",
743 });
744 expect(events[1]).toMatchObject({
745 _tag: "plan.phase",
746 phase: "computing-plan",
747 });
748
749 // Each resource diff announces its start before the planned completion.
750 expect(
751 events
752 .filter((event) => event._tag === "plan.resource.started")
753 .map(({ logicalId }) => logicalId)
754 .sort(),
755 ).toEqual(["A", "MyQueue"]);
756
757 const nodes = events.filter(
758 (event) =>
759 event._tag === "plan.resource.completed" ||
760 event._tag === "plan.action.completed",
761 );
762 const byId = Object.fromEntries(
763 nodes.map((event) => [event.logicalId, event]),
764 );
765 // resource rows carry their binding rows across the wire
766 expect(byId.A).toMatchObject({
767 _tag: "plan.resource.completed",
768 action: "update",
769 bindings: [{ sid: "TestBinding", action: "create" }],
770 total: 2,
771 });
772 expect(byId.MyQueue).toMatchObject({
773 _tag: "plan.resource.completed",
774 action: "create",
775 bindings: [],
776 total: 2,
777 });
778 // stack actions get their own events
779 expect(byId.Announce).toMatchObject({
780 _tag: "plan.action.completed",
781 actionType: "Announce",
782 action: "run",
783 completed: 1,
784 total: 1,
785 });
786 expect(
787 events
788 .filter((event) => event._tag === "plan.resource.completed")
789 .map(({ completed }) => completed)
790 .sort(),
791 ).toEqual([1, 2]);
792 }),
793 { tags: ["unit", "local"] },
794);
795
796test(
797 "destroy plans report deletions through the ambient Progress reporter",
798 Effect.gen(function* () {
799 yield* seed({
800 MyBucket: {
801 instanceId,
802 providerVersion: 0,
803 logicalId: "MyBucket",
804 fqn: "MyBucket",
805 namespace: undefined,
806 resourceType: "Test.Bucket",
807 status: "created",
808 props: { name: "test-bucket" },
809 attr: { name: "test-bucket" },
810 bindings: [],
811 downstream: [],
812 },
813 });
814 const events: Array<ProgressEvent> = [];
815 const { name, stage } = yield* resolveStackId;
816 const plan = yield* Plan.destroy({ name, stage }).pipe(
817 Effect.provideService(Progress, (event) =>
818 Effect.sync(() => {
819 events.push(event);
820 }),
821 ),
822 Effect.provide(TestLayers()),
823 );
824
825 expect(plan.deletions.MyBucket).toMatchObject({ action: "delete" });
826 expect(
827 events
828 .filter((event) => event._tag === "plan.phase")
829 .map(({ phase }) => phase),
830 ).toEqual(["loading-state", "computing-plan"]);
831 const nodes = events.filter(
832 (event) => event._tag === "plan.resource.completed",
833 );
834 expect(nodes).toHaveLength(1);
835 expect(nodes[0]).toMatchObject({
836 _tag: "plan.resource.completed",
837 fqn: "MyBucket",
838 logicalId: "MyBucket",
839 resourceType: "Test.Bucket",
840 action: "delete",
841 bindings: [],
842 completed: 1,
843 total: 1,
844 });
845 }),
846 { tags: ["unit", "local"] },
847);
848
849test(
850 "update the changed resources and no-op un-changed resources",
851 Effect.gen(function* () {
852 yield* seed({
853 MyBucket: {
854 instanceId,
855 providerVersion: 0,
856 logicalId: "MyBucket",
857 fqn: "MyBucket",
858 namespace: undefined,
859 resourceType: "Test.Bucket",
860 status: "created",
861 props: {
862 name: "test-bucket",
863 },
864 attr: {
865 name: "test-bucket",
866 },
867 bindings: [],
868 downstream: [],
869 },
870 });
871 expect(
872 yield* makePlan(
873 Effect.gen(function* () {
874 yield* Bucket("MyBucket", {
875 name: "test-bucket",
876 });
877 yield* Queue("MyQueue", {
878 name: "test-queue",
879 });
880 }),
881 ),
882 ).toMatchObject({
883 resources: {
884 MyBucket: {
885 action: "noop",
886 bindings: [],
887 state: {
888 status: "created",
889 },
890 },
891 MyQueue: {
892 action: "create",
893 bindings: [],
894 props: {
895 name: "test-queue",
896 },
897 state: undefined,
898 },
899 },
900 deletions: expect.toSatisfy(
901 (d: any) => Object.keys(d).length === 0,
902 "empty object",
903 ),
904 });
905 }),
906 { tags: ["unit", "local"] },
907);
908
909test(
910 "plan downstream resources when a stable kind shadows an output discriminator",
911 Effect.gen(function* () {
912 yield* seed({
913 Database: {
914 instanceId,
915 providerVersion: 0,
916 logicalId: "Database",
917 fqn: "Database",
918 namespace: undefined,
919 resourceType: "Test.KindStablesResource",
920 status: "created",
921 props: {
922 value: "v1",
923 },
924 attr: {
925 kind: "postgresql",
926 value: "v1",
927 upstreamKind: undefined,
928 },
929 bindings: [],
930 downstream: [],
931 },
932 });
933
934 const plan = yield* Effect.gen(function* () {
935 const database = yield* KindStablesResource("Database", {
936 value: "v2",
937 });
938 yield* KindStablesResource("Role", {
939 value: "role",
940 upstream: database,
941 });
942 }).pipe(makePlan);
943
944 expect(plan.resources.Database!.action).toBe("update");
945 expect(plan.resources.Role!.action).toBe("create");
946 }),
947 { tags: ["unit", "local"] },
948);
949
950test(
951 "force changes noop resources into updates",
952 Effect.gen(function* () {
953 yield* seed({
954 MyBucket: {
955 instanceId,
956 providerVersion: 0,
957 logicalId: "MyBucket",
958 fqn: "MyBucket",
959 namespace: undefined,
960 resourceType: "Test.Bucket",
961 status: "created",
962 props: {
963 name: "test-bucket",
964 },
965 attr: {
966 name: "test-bucket",
967 },
968 bindings: [],
969 downstream: [],
970 },
971 });
972 expect(
973 yield* makePlan(
974 Effect.gen(function* () {
975 yield* Bucket("MyBucket", {
976 name: "test-bucket",
977 });
978 }),
979 { force: true },
980 ),
981 ).toMatchObject({
982 resources: {
983 MyBucket: {
984 action: "update",
985 bindings: [],
986 props: {
987 name: "test-bucket",
988 },
989 state: {
990 status: "created",
991 },
992 },
993 },
994 deletions: expect.toSatisfy(
995 (d: any) => Object.keys(d).length === 0,
996 "empty object",
997 ),
998 });
999 }),
1000 { tags: ["unit", "local"] },
1001);
1002
1003test(
1004 "no-op resources with undefined props",
1005 Effect.gen(function* () {
1006 yield* seed({
1007 MyQueue: {
1008 instanceId,
1009 providerVersion: 0,
1010 logicalId: "MyQueue",
1011 fqn: "MyQueue",
1012 namespace: undefined,
1013 resourceType: "Test.Queue",
1014 status: "created",
1015 props: undefined as any,
1016 attr: {
1017 name: "MyQueue",
1018 queueUrl: "https://test.queue.com/MyQueue",
1019 },
1020 bindings: [],
1021 downstream: [],
1022 },
1023 });
1024 expect(
1025 yield* makePlan(
1026 Effect.gen(function* () {
1027 yield* Queue("MyQueue");
1028 }),
1029 ),
1030 ).toMatchObject({
1031 resources: {
1032 MyQueue: {
1033 action: "noop",
1034 bindings: [],
1035 state: {
1036 status: "created",
1037 },
1038 },
1039 },
1040 deletions: expect.toSatisfy(
1041 (d: any) => Object.keys(d).length === 0,
1042 "empty object",
1043 ),
1044 });
1045 }),
1046 { tags: ["unit", "local"] },
1047);
1048
1049test(
1050 "no-op resources when object prop key order changes",
1051 Effect.gen(function* () {
1052 yield* seed({
1053 MyFunction: {
1054 instanceId,
1055 providerVersion: 0,
1056 logicalId: "MyFunction",
1057 fqn: "MyFunction",
1058 namespace: undefined,
1059 resourceType: "Test.Function",
1060 status: "created",
1061 props: {
1062 name: "test-function",
1063 env: {
1064 A: "1",
1065 B: "2",
1066 },
1067 },
1068 attr: {
1069 name: "test-function",
1070 env: {
1071 A: "1",
1072 B: "2",
1073 },
1074 functionArn: "arn:test:function:MyFunction",
1075 },
1076 bindings: [],
1077 downstream: [],
1078 },
1079 });
1080 expect(
1081 yield* makePlan(
1082 Effect.gen(function* () {
1083 yield* Function("MyFunction", {
1084 name: "test-function",
1085 env: {
1086 B: "2",
1087 A: "1",
1088 },
1089 });
1090 }),
1091 ),
1092 ).toMatchObject({
1093 resources: {
1094 MyFunction: {
1095 action: "noop",
1096 bindings: [],
1097 state: {
1098 status: "created",
1099 },
1100 },
1101 },
1102 deletions: expect.toSatisfy(
1103 (d: any) => Object.keys(d).length === 0,
1104 "empty object",
1105 ),
1106 });
1107 }),
1108 { tags: ["unit", "local"] },
1109);
1110
1111test(
1112 "plan retained resources as orphaned",
1113 Effect.gen(function* () {
1114 yield* seed({
1115 MyBucket: {
1116 instanceId,
1117 providerVersion: 0,
1118 logicalId: "MyBucket",
1119 fqn: "MyBucket",
1120 namespace: undefined,
1121 resourceType: "Test.Bucket",
1122 status: "created",
1123 props: {
1124 name: "test-bucket",
1125 },
1126 attr: {
1127 name: "test-bucket",
1128 },
1129 bindings: [],
1130 downstream: [],
1131 removalPolicy: "retain",
1132 },
1133 MyQueue: {
1134 instanceId,
1135 providerVersion: 0,
1136 logicalId: "MyQueue",
1137 fqn: "MyQueue",
1138 namespace: undefined,
1139 resourceType: "Test.Queue",
1140 status: "created",
1141 props: {
1142 name: "test-queue",
1143 },
1144 attr: {
1145 name: "test-queue",
1146 },
1147 bindings: [],
1148 downstream: [],
1149 },
1150 });
1151 expect(
1152 yield* makePlan(
1153 Effect.gen(function* () {
1154 yield* Queue("MyQueue", {
1155 name: "test-queue",
1156 });
1157 }),
1158 ),
1159 ).toMatchObject({
1160 resources: {
1161 MyQueue: {
1162 action: "noop",
1163 bindings: [],
1164 state: {
1165 status: "created",
1166 },
1167 },
1168 },
1169 deletions: {
1170 MyBucket: {
1171 action: "orphaned",
1172 bindings: [],
1173 state: {
1174 status: "created",
1175 attr: {
1176 name: "test-bucket",
1177 },
1178 },
1179 resource: {
1180 LogicalId: "MyBucket",
1181 Type: "Test.Bucket",
1182 Props: {
1183 name: "test-bucket",
1184 },
1185 },
1186 },
1187 },
1188 });
1189 }),
1190 { tags: ["unit", "local"] },
1191);
1192
1193test(
1194 "allow deleting a resource after a surviving consumer removes the dependency",
1195 Effect.gen(function* () {
1196 yield* seed({
1197 Secret: {
1198 instanceId,
1199 providerVersion: 0,
1200 logicalId: "Secret",
1201 fqn: "Secret",
1202 namespace: undefined,
1203 resourceType: "Test.TestResource",
1204 status: "created",
1205 props: {
1206 string: "secret-value",
1207 },
1208 attr: {
1209 string: "secret-value",
1210 stringArray: [],
1211 stableString: "Secret",
1212 stableArray: ["Secret"],
1213 replaceString: undefined,
1214 },
1215 bindings: [],
1216 downstream: ["Worker"],
1217 },
1218 Worker: {
1219 instanceId,
1220 providerVersion: 0,
1221 logicalId: "Worker",
1222 fqn: "Worker",
1223 namespace: undefined,
1224 resourceType: "Test.Function",
1225 status: "created",
1226 props: {
1227 name: "worker",
1228 env: {
1229 SECRET: "secret-value",
1230 },
1231 },
1232 attr: {
1233 name: "worker",
1234 env: {
1235 SECRET: "secret-value",
1236 },
1237 functionArn: "arn:aws:lambda:us-west-2:084828582823:function:Worker",
1238 },
1239 bindings: [],
1240 downstream: [],
1241 },
1242 });
1243 expect(
1244 yield* makePlan(
1245 Effect.gen(function* () {
1246 yield* Function("Worker", {
1247 name: "worker",
1248 });
1249 }),
1250 ),
1251 ).toMatchObject({
1252 resources: {
1253 Worker: {
1254 action: "update",
1255 props: {
1256 name: "worker",
1257 },
1258 bindings: [],
1259 },
1260 },
1261 deletions: {
1262 Secret: {
1263 action: "delete",
1264 state: {
1265 status: "created",
1266 downstream: ["Worker"],
1267 },
1268 },
1269 },
1270 });
1271 }),
1272 { tags: ["unit", "local"] },
1273);
1274
1275test(
1276 "reject deleting a resource when a surviving consumer still references it",
1277 Effect.gen(function* () {
1278 yield* seed({
1279 Secret: {
1280 instanceId,
1281 providerVersion: 0,
1282 logicalId: "Secret",
1283 fqn: "Secret",
1284 namespace: undefined,
1285 resourceType: "Test.TestResource",
1286 status: "created",
1287 props: {
1288 string: "secret-value",
1289 },
1290 attr: {
1291 string: "secret-value",
1292 stringArray: [],
1293 stableString: "Secret",
1294 stableArray: ["Secret"],
1295 replaceString: undefined,
1296 },
1297 bindings: [],
1298 downstream: ["Worker"],
1299 },
1300 Worker: {
1301 instanceId,
1302 providerVersion: 0,
1303 logicalId: "Worker",
1304 fqn: "Worker",
1305 namespace: undefined,
1306 resourceType: "Test.Function",
1307 status: "created",
1308 props: {
1309 name: "worker",
1310 env: {
1311 SECRET: "secret-value",
1312 },
1313 },
1314 attr: {
1315 name: "worker",
1316 env: {
1317 SECRET: "secret-value",
1318 },
1319 functionArn: "arn:aws:lambda:us-west-2:084828582823:function:Worker",
1320 },
1321 bindings: [],
1322 downstream: [],
1323 },
1324 });
1325 const malformedStack = {
1326 name: TEST_STACK,
1327 stage: TEST_STAGE,
1328 resources: {},
1329 bindings: {},
1330 output: undefined,
1331 };
1332
1333 const exit = yield* Effect.exit(
1334 Effect.gen(function* () {
1335 const secret = yield* TestResource("Secret", {
1336 string: "secret-value",
1337 });
1338 yield* Function("Worker", {
1339 name: "worker",
1340 env: {
1341 SECRET: secret.string,
1342 },
1343 });
1344 const stack = yield* Stack.Stack;
1345 delete stack.resources.Secret;
1346 }).pipe(makePlanWithCustomStack(malformedStack)),
1347 );
1348
1349 expect(Exit.isFailure(exit)).toBe(true);
1350 if (Exit.isFailure(exit)) {
1351 const reason = exit.cause.reasons.find(Cause.isFailReason);
1352 expect(reason).toBeDefined();
1353 expect(reason!.error).toEqual(
1354 new Plan.DeleteResourceHasDownstreamDependencies({
1355 message: "Resource Secret has downstream dependencies",
1356 resourceId: "Secret",
1357 dependencies: ["Worker"],
1358 }),
1359 );
1360 }
1361 }),
1362 { tags: ["unit", "local"] },
1363);
1364
1365// #1831: values a Platform's Init captures reach the provider only through
1366// `props.env`. `CapturedConfigHost`'s diff always returns `noop`, so these
1367// actions come from the engine's own comparison of the captured values.
1368describe("Platform Init-captured config", { tags: ["unit", "local"] }, () => {
1369 const seedHost = (mode: string) =>
1370 seed({
1371 Host: {
1372 instanceId,
1373 providerVersion: 0,
1374 logicalId: "Host",
1375 fqn: "Host",
1376 namespace: undefined,
1377 resourceType: "Test.CapturedConfigHost",
1378 status: "created",
1379 props: {
1380 main: "index.ts",
1381 env: { CAPTURED_MODE: packEnvValue(Redacted.make(mode)) },
1382 },
1383 attr: { mode },
1384 bindings: [],
1385 downstream: [],
1386 },
1387 });
1388
1389 test(
1390 "an unchanged captured value keeps the provider's noop",
1391 Effect.gen(function* () {
1392 yield* seedHost("a");
1393 const plan = yield* capturedConfigHost("a").pipe(makePlan);
1394 expect(plan.resources.Host.action).toBe("noop");
1395 }),
1396 );
1397
1398 test(
1399 "a changed captured value plans an update over the provider's noop",
1400 Effect.gen(function* () {
1401 yield* seedHost("a");
1402 const plan = yield* capturedConfigHost("b").pipe(makePlan);
1403 expect(plan.resources.Host.action).toBe("update");
1404 }),
1405 );
1406});
1407
1408describe(
1409 "replace resource when replaceString changes",
1410 { tags: ["unit", "local"] },
1411 () => {
1412 const stateResources: Record<string, ResourceState> = {
1413 A: {
1414 instanceId,
1415 providerVersion: 0,
1416 logicalId: "A",
1417 fqn: "A",
1418 namespace: undefined,
1419 resourceType: "Test.TestResource",
1420 status: "created",
1421 props: {
1422 replaceString: "A",
1423 },
1424 attr: {},
1425 downstream: [],
1426 bindings: [],
1427 },
1428 };
1429
1430 test(
1431 "noop and replace when replaceString is fully resolved at plan time",
1432 Effect.gen(function* () {
1433 yield* seed(stateResources);
1434 expect(
1435 yield* Effect.gen(function* () {
1436 yield* TestResource("A", {
1437 replaceString: "A",
1438 });
1439 }).pipe(makePlan),
1440 ).toMatchObject({
1441 resources: {
1442 A: {
1443 action: "noop",
1444 },
1445 },
1446 deletions: expect.toSatisfy(
1447 (d: any) => Object.keys(d).length === 0,
1448 "empty object",
1449 ),
1450 });
1451
1452 expect(
1453 yield* Effect.gen(function* () {
1454 yield* TestResource("A", {
1455 replaceString: "B",
1456 });
1457 }).pipe(makePlan),
1458 ).toMatchObject({
1459 resources: {
1460 A: {
1461 action: "replace",
1462 props: {
1463 replaceString: "B",
1464 },
1465 },
1466 },
1467 deletions: expect.toSatisfy(
1468 (d: any) => Object.keys(d).length === 0,
1469 "empty object",
1470 ),
1471 });
1472 }),
1473 );
1474
1475 test(
1476 "force preserves replaces",
1477 Effect.gen(function* () {
1478 yield* seed(stateResources);
1479 expect(
1480 yield* Effect.gen(function* () {
1481 yield* TestResource("A", {
1482 replaceString: "B",
1483 });
1484 }).pipe((effect) => makePlan(effect, { force: true })),
1485 ).toMatchObject({
1486 resources: {
1487 A: {
1488 action: "replace",
1489 props: {
1490 replaceString: "B",
1491 },
1492 },
1493 },
1494 deletions: expect.toSatisfy(
1495 (d: any) => Object.keys(d).length === 0,
1496 "empty object",
1497 ),
1498 });
1499 }),
1500 );
1501
1502 test(
1503 "update when replaceString depends on unresolved output (diff short-circuits)",
1504 Effect.gen(function* () {
1505 yield* seed(stateResources);
1506 let B: TestResource;
1507 expect(
1508 yield* Effect.gen(function* () {
1509 B = yield* TestResource("B", {
1510 string: "A",
1511 });
1512 yield* TestResource("A", {
1513 replaceString: B.string,
1514 });
1515 }).pipe(makePlan),
1516 ).toMatchObject({
1517 resources: {
1518 A: {
1519 action: "update",
1520 props: {
1521 replaceString: expect.objectContaining({
1522 kind: "PropExpr",
1523 identifier: "string",
1524 expr: expect.objectContaining({
1525 kind: "ResourceExpr",
1526 src: B!,
1527 }),
1528 }),
1529 },
1530 },
1531 },
1532 deletions: expect.toSatisfy(
1533 (d: any) => Object.keys(d).length === 0,
1534 "empty object",
1535 ),
1536 });
1537 }),
1538 );
1539 },
1540);
1541
1542test(
1543 "update resource when a binding is added without prop changes",
1544 Effect.gen(function* () {
1545 yield* seed({
1546 A: {
1547 instanceId,
1548 providerVersion: 0,
1549 logicalId: "A",
1550 fqn: "A",
1551 namespace: undefined,
1552 resourceType: "Test.BindingTarget",
1553 status: "created",
1554 props: {
1555 name: "target",
1556 },
1557 attr: {
1558 name: "target",
1559 env: {},
1560 },
1561 bindings: [],
1562 downstream: [],
1563 },
1564 });
1565 expect(
1566 yield* Effect.gen(function* () {
1567 const target = yield* BindingTarget("A", {
1568 name: "target",
1569 });
1570 yield* target.bind("TestBinding", {
1571 env: {
1572 FEATURE_FLAG: "on",
1573 },
1574 });
1575 }).pipe(makePlan),
1576 ).toMatchObject({
1577 resources: {
1578 A: {
1579 action: "update",
1580 bindings: [
1581 {
1582 action: "create",
1583 sid: "TestBinding",
1584 data: {
1585 env: {
1586 FEATURE_FLAG: "on",
1587 },
1588 },
1589 },
1590 ],
1591 state: {
1592 status: "created",
1593 },
1594 },
1595 },
1596 deletions: expect.toSatisfy(
1597 (d: any) => Object.keys(d).length === 0,
1598 "empty object",
1599 ),
1600 });
1601 }),
1602 { tags: ["unit", "local"] },
1603);
1604
1605test(
1606 "update resource when a binding is removed without prop changes",
1607 Effect.gen(function* () {
1608 yield* seed({
1609 A: {
1610 instanceId,
1611 providerVersion: 0,
1612 logicalId: "A",
1613 fqn: "A",
1614 namespace: undefined,
1615 resourceType: "Test.BindingTarget",
1616 status: "created",
1617 props: {
1618 name: "target",
1619 },
1620 attr: {
1621 name: "target",
1622 env: {
1623 FEATURE_FLAG: "on",
1624 },
1625 },
1626 bindings: [
1627 {
1628 sid: "TestBinding",
1629 data: {
1630 env: {
1631 FEATURE_FLAG: "on",
1632 },
1633 },
1634 },
1635 ],
1636 downstream: [],
1637 },
1638 });
1639 expect(
1640 yield* Effect.gen(function* () {
1641 yield* BindingTarget("A", {
1642 name: "target",
1643 });
1644 }).pipe(makePlan),
1645 ).toMatchObject({
1646 resources: {
1647 A: {
1648 action: "update",
1649 bindings: [
1650 {
1651 action: "delete",
1652 sid: "TestBinding",
1653 data: {
1654 env: {
1655 FEATURE_FLAG: "on",
1656 },
1657 },
1658 },
1659 ],
1660 state: {
1661 status: "created",
1662 },
1663 },
1664 },
1665 deletions: expect.toSatisfy(
1666 (d: any) => Object.keys(d).length === 0,
1667 "empty object",
1668 ),
1669 });
1670 }),
1671 { tags: ["unit", "local"] },
1672);
1673
1674test.provider(
1675 "binding removals do not keep reappearing after apply",
1676 (scratch) =>
1677 Effect.gen(function* () {
1678 const state = yield* yield* State;
1679 yield* state.set({
1680 stack: scratch.name,
1681 stage: scratch.stage,
1682 fqn: "A",
1683 value: {
1684 instanceId,
1685 providerVersion: 0,
1686 logicalId: "A",
1687 fqn: "A",
1688 namespace: undefined,
1689 resourceType: "Test.BindingTarget",
1690 status: "created",
1691 props: {
1692 name: "target",
1693 },
1694 attr: {
1695 name: "target",
1696 env: {
1697 FEATURE_FLAG: "on",
1698 },
1699 },
1700 bindings: [
1701 {
1702 sid: "TestBinding",
1703 data: {
1704 env: {
1705 FEATURE_FLAG: "on",
1706 },
1707 },
1708 },
1709 ],
1710 downstream: [],
1711 },
1712 });
1713
1714 yield* scratch.deploy(
1715 Effect.gen(function* () {
1716 yield* BindingTarget("A", {
1717 name: "target",
1718 });
1719 }),
1720 );
1721
1722 expect(
1723 yield* state.get({
1724 stack: scratch.name,
1725 stage: scratch.stage,
1726 fqn: "A",
1727 }),
1728 ).toMatchObject({
1729 bindings: [],
1730 });
1731
1732 expect(
1733 yield* Effect.gen(function* () {
1734 yield* BindingTarget("A", {
1735 name: "target",
1736 });
1737 }).pipe(makePlan),
1738 ).toMatchObject({
1739 resources: {
1740 A: {
1741 action: "noop",
1742 bindings: [],
1743 },
1744 },
1745 deletions: expect.toSatisfy(
1746 (d: any) => Object.keys(d).length === 0,
1747 "empty object",
1748 ),
1749 });
1750 }),
1751 { tags: ["unit", "local"] },
1752);
1753
1754describe(
1755 "duplicate bindings are collapsed by sid before diff",
1756 { tags: ["unit", "local"] },
1757 () => {
1758 test(
1759 "dedupeBindings keeps the last occurrence of each sid",
1760 Effect.sync(() => {
1761 const deduped = dedupeBindings([
1762 { sid: "Shared", data: { env: { K: "first" } } },
1763 { sid: "Other", data: { env: { K: "x" } } },
1764 { sid: "Shared", data: { env: { K: "last" } } },
1765 ]);
1766
1767 // The duplicated sid takes the last value (matching `diffBindings`'
1768 // `Map`-based collapse) and the result is sid-sorted so binding rows
1769 // are deterministic regardless of registration order.
1770 expect(deduped).toEqual([
1771 { sid: "Other", data: { env: { K: "x" } } },
1772 { sid: "Shared", data: { env: { K: "last" } } },
1773 ]);
1774 }),
1775 );
1776
1777 test(
1778 "diff observes a single binding when the same sid is bound twice",
1779 Effect.gen(function* () {
1780 yield* seed({
1781 A: {
1782 instanceId,
1783 providerVersion: 0,
1784 logicalId: "A",
1785 fqn: "A",
1786 namespace: undefined,
1787 resourceType: "Test.BindingTarget",
1788 status: "created",
1789 props: {
1790 name: "target",
1791 },
1792 attr: {
1793 name: "target",
1794 env: {},
1795 },
1796 bindings: [],
1797 downstream: [],
1798 },
1799 });
1800
1801 // Capture the exact binding list the provider's `diff` receives.
1802 const observed: ResourceBinding[][] = [];
1803
1804 const plan = yield* Effect.gen(function* () {
1805 const target = yield* BindingTarget("A", {
1806 name: "target",
1807 });
1808 // The same sid is recorded twice — mirrors a single KV namespace
1809 // bound to two consumers that both attach it to the same target,
1810 // which pushes a duplicate into `stack.bindings[fqn]`.
1811 yield* target.bind("Shared", { env: { FEATURE_FLAG: "on" } });
1812 yield* target.bind("Shared", { env: { FEATURE_FLAG: "on" } });
1813 }).pipe(
1814 makePlan,
1815 Effect.provideService(TestResourceHooks, {
1816 diff: (_id, newBindings) =>
1817 Effect.sync(() => {
1818 observed.push(newBindings);
1819 }),
1820 }),
1821 );
1822
1823 // Before the fix, `diff` saw the raw duplicate pair (length 2) while
1824 // `reconcile` saw a deduped list — an inconsistency that made hashing
1825 // unstable. Every diff invocation must now see the collapsed list.
1826 expect(observed.length).toBeGreaterThan(0);
1827 for (const seen of observed) {
1828 expect(seen).toHaveLength(1);
1829 expect(seen[0]).toMatchObject({
1830 sid: "Shared",
1831 data: { env: { FEATURE_FLAG: "on" } },
1832 });
1833 }
1834
1835 // The plan node likewise collapses to a single create binding.
1836 expect(plan.resources.A).toMatchObject({
1837 action: "update",
1838 bindings: [
1839 {
1840 action: "create",
1841 sid: "Shared",
1842 data: { env: { FEATURE_FLAG: "on" } },
1843 },
1844 ],
1845 });
1846 }),
1847 );
1848 },
1849);
1850
1851describe("construct namespaces", { tags: ["unit", "local"] }, () => {
1852 test(
1853 "namespaced construct bindings resolve into the plan graph",
1854 Effect.gen(function* () {
1855 const Site = (id: string, _props: {}) =>
1856 Effect.gen(function* () {
1857 const bucket = yield* BindingTarget("Bucket", {
1858 name: "bucket",
1859 });
1860 const distribution = yield* BindingTarget("Distribution", {
1861 name: "distribution",
1862 });
1863 yield* bucket.bind("Policy", {
1864 env: {
1865 BUCKET: bucket.string,
1866 DISTRIBUTION: distribution.string,
1867 },
1868 });
1869 return { bucket, distribution };
1870 }).pipe(Namespace.push(id));
1871
1872 const plan = yield* Effect.gen(function* () {
1873 yield* Site("MarketingSite", {});
1874 }).pipe(makePlan);
1875
1876 expect(plan).toMatchObject({
1877 resources: {
1878 "MarketingSite/Bucket": {
1879 action: "create",
1880 bindings: [
1881 {
1882 action: "create",
1883 sid: "Policy",
1884 data: {
1885 env: {
1886 BUCKET: expect.objectContaining({
1887 kind: "PropExpr",
1888 identifier: "string",
1889 expr: expect.objectContaining({
1890 kind: "ResourceExpr",
1891 src: plan.resources["MarketingSite/Bucket"]!.resource,
1892 }),
1893 }),
1894 DISTRIBUTION: expect.objectContaining({
1895 kind: "PropExpr",
1896 identifier: "string",
1897 expr: expect.objectContaining({
1898 kind: "ResourceExpr",
1899 src: plan.resources["MarketingSite/Distribution"]!
1900 .resource,
1901 }),
1902 }),
1903 },
1904 },
1905 },
1906 ],
1907 },
1908 "MarketingSite/Distribution": {
1909 action: "create",
1910 bindings: [],
1911 },
1912 },
1913 deletions: expect.toSatisfy(
1914 (d: any) => Object.keys(d).length === 0,
1915 "empty object",
1916 ),
1917 });
1918 }),
1919 );
1920
1921 test(
1922 "same child logical ids in different constructs do not collide",
1923 Effect.gen(function* () {
1924 const Site = (id: string, props: { name: string }) =>
1925 Effect.gen(function* () {
1926 return yield* Bucket("Bucket", {
1927 name: props.name,
1928 });
1929 }).pipe(Namespace.push(id));
1930
1931 const plan = yield* Effect.gen(function* () {
1932 yield* Site("MarketingSite", {
1933 name: "marketing-bucket",
1934 });
1935 yield* Site("DocsSite", {
1936 name: "docs-bucket",
1937 });
1938 }).pipe(makePlan);
1939
1940 expect(plan).toMatchObject({
1941 resources: {
1942 "MarketingSite/Bucket": {
1943 action: "create",
1944 props: {
1945 name: "marketing-bucket",
1946 },
1947 },
1948 "DocsSite/Bucket": {
1949 action: "create",
1950 props: {
1951 name: "docs-bucket",
1952 },
1953 },
1954 },
1955 deletions: expect.toSatisfy(
1956 (d: any) => Object.keys(d).length === 0,
1957 "empty object",
1958 ),
1959 });
1960 }),
1961 );
1962
1963 test(
1964 "binding-only cycles inside a construct do not become downstream edges",
1965 Effect.gen(function* () {
1966 const Site = (id: string, _props: {}) =>
1967 Effect.gen(function* () {
1968 const A = yield* BindingTarget("A", {
1969 string: "a-value",
1970 });
1971 const B = yield* BindingTarget("B", {
1972 string: "b-value",
1973 });
1974
1975 yield* A.bind("FromB", {
1976 env: {
1977 PEER: B.string,
1978 },
1979 });
1980 yield* B.bind("FromA", {
1981 env: {
1982 PEER: A.string,
1983 },
1984 });
1985
1986 return { A, B };
1987 }).pipe(Namespace.push(id));
1988
1989 const plan = yield* Effect.gen(function* () {
1990 yield* Site("MarketingSite", {});
1991 }).pipe(makePlan);
1992
1993 expect(plan.resources["MarketingSite/A"]?.downstream).toEqual([]);
1994 expect(plan.resources["MarketingSite/B"]?.downstream).toEqual([]);
1995 expect(plan.deletions).toEqual({});
1996 }),
1997 );
1998});
1999
2000const createTestResourceState = (options: {
2001 logicalId: string;
2002 status: ResourceStatus;
2003 props: TestResourceProps;
2004 attr?: {};
2005}) =>
2006 ({
2007 instanceId,
2008 providerVersion: 0,
2009 ...options,
2010 resourceType: "Test.TestResource",
2011 attr: options.attr ?? {},
2012 downstream: [],
2013 bindings: [],
2014 fqn: options.logicalId,
2015 namespace: undefined,
2016 }) as ResourceState;
2017
2018const createReplacingState = (options: {
2019 logicalId: string;
2020 props: TestResourceProps;
2021 old: ResourceState;
2022 attr?: {};
2023}) =>
2024 ({
2025 ...createTestResourceState({
2026 logicalId: options.logicalId,
2027 status: "replacing",
2028 props: options.props,
2029 attr: options.attr,
2030 }),
2031 old: options.old,
2032 deleteFirst: false,
2033 }) as Extract<ResourceState, { status: "replacing" }>;
2034
2035const createReplacedState = (options: {
2036 logicalId: string;
2037 props: TestResourceProps;
2038 old: ResourceState;
2039 attr?: {};
2040}) =>
2041 ({
2042 ...createTestResourceState({
2043 logicalId: options.logicalId,
2044 status: "replaced",
2045 props: options.props,
2046 attr: options.attr,
2047 }),
2048 old: options.old,
2049 deleteFirst: false,
2050 }) as Extract<ResourceState, { status: "replaced" }>;
2051
2052const testSimple = (
2053 title: string,
2054 testCase: {
2055 state: {
2056 status: ResourceStatus;
2057 props: TestResourceProps;
2058 attr?: {};
2059 old?: Partial<ResourceState>;
2060 };
2061 props: TestResourceProps;
2062 plan?: any;
2063 fail?: string;
2064 },
2065) =>
2066 test(
2067 title,
2068 Effect.gen(function* () {
2069 yield* seed({
2070 A: createTestResourceState({
2071 ...testCase.state,
2072 logicalId: "A",
2073 }),
2074 });
2075 {
2076 const plan = Effect.gen(function* () {
2077 yield* TestResource("A", testCase.props);
2078 }).pipe(makePlan);
2079
2080 if (testCase.fail) {
2081 const result = plan.pipe(
2082 Effect.map(() => false),
2083 // @ts-expect-error
2084 Effect.catchTag(testCase.fail, () => Effect.succeed(true)),
2085 Effect.catch(() => Effect.succeed(false)),
2086 ) as Effect.Effect<boolean>;
2087 if (!result) {
2088 expect.fail(`Expected error '${testCase.fail}`);
2089 }
2090 } else {
2091 expect(yield* plan).toMatchObject({
2092 resources: {
2093 A: testCase.plan,
2094 },
2095 deletions: expect.toSatisfy(
2096 (d: any) => Object.keys(d).length === 0,
2097 "empty object",
2098 ),
2099 });
2100 }
2101 }
2102 }),
2103 { tags: ["unit", "local"] },
2104 );
2105
2106describe("prior crash in 'creating' state", () => {
2107 testSimple("create if props unchanged", {
2108 state: {
2109 status: "creating",
2110 props: {
2111 string: "A",
2112 },
2113 },
2114 props: {
2115 string: "A",
2116 },
2117 plan: {
2118 action: "create",
2119 props: {
2120 string: "A",
2121 },
2122 },
2123 });
2124
2125 testSimple("create if changed props can be updated", {
2126 state: {
2127 status: "creating",
2128 props: {
2129 string: "A",
2130 },
2131 },
2132 props: {
2133 string: "B",
2134 },
2135 plan: {
2136 action: "create",
2137 props: {
2138 string: "B",
2139 },
2140 },
2141 });
2142
2143 testSimple("replace if changed props cannot be updated", {
2144 state: {
2145 status: "creating",
2146 props: {
2147 replaceString: "A",
2148 },
2149 },
2150 props: {
2151 replaceString: "B",
2152 },
2153 plan: {
2154 action: "replace",
2155 props: {
2156 replaceString: "B",
2157 },
2158 state: {
2159 status: "creating",
2160 props: {
2161 replaceString: "A",
2162 },
2163 },
2164 },
2165 });
2166});
2167
2168describe("prior crash in 'updating' state", () => {
2169 testSimple("update if props unchanged", {
2170 state: {
2171 status: "updating",
2172 props: {
2173 string: "A",
2174 },
2175 },
2176 props: {
2177 string: "A",
2178 },
2179 plan: {
2180 action: "update",
2181 props: {
2182 string: "A",
2183 },
2184 state: {
2185 status: "updating",
2186 props: {
2187 string: "A",
2188 },
2189 },
2190 },
2191 });
2192
2193 testSimple("update if changed props can be updated", {
2194 state: {
2195 status: "updating",
2196 props: {
2197 string: "A",
2198 },
2199 },
2200 props: {
2201 string: "B",
2202 },
2203 plan: {
2204 action: "update",
2205 props: {
2206 string: "B",
2207 },
2208 state: {
2209 status: "updating",
2210 props: {
2211 string: "A",
2212 },
2213 },
2214 },
2215 });
2216
2217 testSimple("replace if changed props can not be updated", {
2218 state: {
2219 status: "updating",
2220 props: {
2221 replaceString: "A",
2222 },
2223 },
2224 props: {
2225 replaceString: "B",
2226 },
2227 plan: {
2228 action: "replace",
2229 props: {
2230 replaceString: "B",
2231 },
2232 state: {
2233 status: "updating",
2234 props: {
2235 replaceString: "A",
2236 },
2237 },
2238 },
2239 });
2240});
2241
2242describe(
2243 "pending replacement deletion plans",
2244 { tags: ["unit", "local"] },
2245 () => {
2246 for (const status of ["replacing", "replaced"] as const) {
2247 test(
2248 `preserves the complete ${status} chain and all generation dependencies`,
2249 Effect.gen(function* () {
2250 const oldest: ResourceState = {
2251 status: "created",
2252 fqn: "R",
2253 logicalId: "R",
2254 namespace: undefined,
2255 instanceId: "oldest",
2256 resourceType: "Test.ModalResource",
2257 providerVersion: 0,
2258 props: { value: "one" },
2259 attr: { value: "one", runtime: "live" },
2260 providerMode: "live",
2261 downstream: ["OldDependent"],
2262 bindings: [],
2263 };
2264 const middle: ResourceState = {
2265 ...oldest,
2266 status: "replacing",
2267 instanceId: "middle",
2268 props: { value: "two" },
2269 attr: undefined,
2270 providerMode: "local",
2271 downstream: ["MiddleDependent"],
2272 deleteFirst: false,
2273 old: oldest,
2274 };
2275 const pending: ResourceState = {
2276 ...oldest,
2277 status,
2278 instanceId: "newest",
2279 props: { value: "three" },
2280 attr: { value: "three", runtime: "live" },
2281 downstream: ["NewDependent"],
2282 deleteFirst: false,
2283 old: middle,
2284 };
2285 yield* seed({ R: pending });
2286 const plan = yield* makePlan(Effect.void);
2287 expect(plan.deletions.R?.action).toBe("delete");
2288 expect(plan.deletions.R?.mode).toBe("live");
2289 expect(plan.deletions.R?.state).toEqual(pending);
2290 expect(plan.deletions.R?.downstream).toEqual([
2291 "NewDependent",
2292 "MiddleDependent",
2293 "OldDependent",
2294 ]);
2295 const state = yield* yield* State;
2296 expect(
2297 yield* state.get({
2298 stack: TEST_STACK,
2299 stage: TEST_STAGE,
2300 fqn: "R",
2301 }),
2302 ).toEqual(pending);
2303 }),
2304 );
2305 }
2306 },
2307);
2308
2309describe("prior crash in 'replacing' state", () => {
2310 const priorStates = ["created", "creating", "updated", "updating"] as const;
2311
2312 const testUnchanged = ({
2313 old,
2314 }: {
2315 old: {
2316 status: ResourceStatus;
2317 };
2318 }) =>
2319 testSimple(
2320 `"continue 'replace' if props are unchanged and previous state is '${old.status}'"`,
2321 {
2322 state: {
2323 status: "replacing",
2324 props: {
2325 string: "A",
2326 },
2327 old,
2328 },
2329 props: {
2330 string: "A",
2331 },
2332 plan: {
2333 action: "replace",
2334 props: {
2335 string: "A",
2336 },
2337 state: {
2338 status: "replacing",
2339 props: {
2340 string: "A",
2341 },
2342 old,
2343 },
2344 },
2345 },
2346 );
2347
2348 priorStates.forEach((status) =>
2349 testUnchanged({
2350 old: {
2351 status,
2352 },
2353 }),
2354 );
2355
2356 const testMinorChange = ({
2357 old,
2358 }: {
2359 old: {
2360 status: ResourceStatus;
2361 };
2362 }) =>
2363 testSimple(
2364 `"continue 'replace' if props can be updated and previous state is '${old.status}'"`,
2365 {
2366 state: {
2367 status: "replacing",
2368 props: {
2369 string: "A",
2370 },
2371 old,
2372 },
2373 props: {
2374 string: "B",
2375 },
2376 plan: {
2377 action: "replace",
2378 props: {
2379 string: "B",
2380 },
2381 state: {
2382 status: "replacing",
2383 props: {
2384 string: "A",
2385 },
2386 old,
2387 },
2388 },
2389 },
2390 );
2391
2392 priorStates.forEach((status) =>
2393 testMinorChange({
2394 old: {
2395 status,
2396 },
2397 }),
2398 );
2399
2400 const testReplacement = (
2401 title: string,
2402 {
2403 old,
2404 plan,
2405 }: {
2406 old: ResourceState;
2407 plan: any;
2408 },
2409 ) =>
2410 testSimple(title, {
2411 state: {
2412 status: "replacing",
2413 props: {
2414 replaceString: "A",
2415 },
2416 old,
2417 },
2418 props: {
2419 replaceString: "B",
2420 },
2421 plan,
2422 });
2423
2424 (["replaced", "replacing"] as const).forEach((status) =>
2425 testReplacement(
2426 `continue 'replace' if trying to replace a partially replaced resource in state '${status}'`,
2427 {
2428 old:
2429 status === "replaced"
2430 ? createReplacedState({
2431 logicalId: "A_old1",
2432 props: {
2433 replaceString: "A1",
2434 },
2435 old: createTestResourceState({
2436 logicalId: "A_old0",
2437 status: "created",
2438 props: {
2439 replaceString: "A0",
2440 },
2441 }),
2442 })
2443 : createReplacingState({
2444 logicalId: "A_old1",
2445 props: {
2446 replaceString: "A1",
2447 },
2448 old: createTestResourceState({
2449 logicalId: "A_old0",
2450 status: "created",
2451 props: {
2452 replaceString: "A0",
2453 },
2454 }),
2455 }),
2456 plan: {
2457 action: "replace",
2458 props: {
2459 replaceString: "B",
2460 },
2461 state: {
2462 status: "replacing",
2463 props: {
2464 replaceString: "A",
2465 },
2466 old: expect.objectContaining({
2467 status,
2468 props: {
2469 replaceString: "A1",
2470 },
2471 old: expect.objectContaining({
2472 status: "created",
2473 props: {
2474 replaceString: "A0",
2475 },
2476 }),
2477 }),
2478 },
2479 },
2480 },
2481 ),
2482 );
2483});
2484
2485describe("prior crash in 'replaced' state", () => {
2486 (["replaced", "replacing"] as const).forEach((status) =>
2487 testSimple(
2488 `continue 'replace' if a replaced resource must be replaced again and previous state is '${status}'`,
2489 {
2490 state: {
2491 status: "replaced",
2492 props: {
2493 replaceString: "A1",
2494 },
2495 old:
2496 status === "replaced"
2497 ? createReplacedState({
2498 logicalId: "A_old0",
2499 props: {
2500 replaceString: "A0",
2501 },
2502 old: createTestResourceState({
2503 logicalId: "A_old-1",
2504 status: "created",
2505 props: {
2506 replaceString: "A-1",
2507 },
2508 }),
2509 })
2510 : createReplacingState({
2511 logicalId: "A_old0",
2512 props: {
2513 replaceString: "A0",
2514 },
2515 old: createTestResourceState({
2516 logicalId: "A_old-1",
2517 status: "created",
2518 props: {
2519 replaceString: "A-1",
2520 },
2521 }),
2522 }),
2523 },
2524 props: {
2525 replaceString: "B",
2526 },
2527 plan: {
2528 action: "replace",
2529 props: {
2530 replaceString: "B",
2531 },
2532 state: {
2533 status: "replaced",
2534 props: {
2535 replaceString: "A1",
2536 },
2537 old: expect.objectContaining({
2538 status,
2539 props: {
2540 replaceString: "A0",
2541 },
2542 old: expect.objectContaining({
2543 status: "created",
2544 props: {
2545 replaceString: "A-1",
2546 },
2547 }),
2548 }),
2549 },
2550 },
2551 },
2552 ),
2553 );
2554});
2555
2556describe("prior crash in 'deleting' state", () => {
2557 testSimple(
2558 "create the resource if props are unchanged and the previous state is 'deleting'",
2559 {
2560 state: {
2561 status: "deleting",
2562 props: {
2563 string: "A",
2564 },
2565 },
2566 props: {
2567 string: "A",
2568 },
2569 plan: {
2570 action: "create",
2571 props: {
2572 string: "A",
2573 },
2574 },
2575 },
2576 );
2577});
2578
2579test(
2580 "lazy Output queue.queueUrl to Function.env",
2581 Effect.gen(function* () {
2582 let MyQueue: Queue;
2583 let MyFunction: Function;
2584 const plan = yield* Effect.gen(function* () {
2585 MyQueue = yield* Queue("MyQueue");
2586 MyFunction = yield* Function("MyFunction", {
2587 name: "test-function",
2588 env: {
2589 QUEUE_URL: MyQueue.queueUrl,
2590 },
2591 });
2592 }).pipe(makePlan);
2593 expect(plan).toMatchObject({
2594 resources: {
2595 MyFunction: {
2596 action: "create",
2597 bindings: [],
2598 resource: MyFunction!,
2599 props: {
2600 name: "test-function",
2601 env: {
2602 QUEUE_URL: expect.objectContaining({
2603 kind: "PropExpr",
2604 identifier: "queueUrl",
2605 expr: expect.objectContaining({
2606 kind: "ResourceExpr",
2607 src: MyQueue!,
2608 }),
2609 }),
2610 },
2611 },
2612 state: undefined,
2613 },
2614 },
2615 deletions: expect.toSatisfy(
2616 (d: any) => Object.keys(d).length === 0,
2617 "empty object",
2618 ),
2619 });
2620 }),
2621 { tags: ["unit", "local"] },
2622);
2623
2624test(
2625 "detect that queueUrl will change and pass through the PropExpr instead of old output",
2626 Effect.gen(function* () {
2627 yield* seed({
2628 MyQueue: {
2629 instanceId,
2630 providerVersion: 0,
2631 logicalId: "MyQueue",
2632 fqn: "MyQueue",
2633 namespace: undefined,
2634 resourceType: "Test.Queue",
2635 status: "created",
2636 props: {
2637 name: "test-queue-old",
2638 },
2639 attr: {
2640 queueUrl: "https://test.queue.com/test-queue-old",
2641 },
2642 downstream: [],
2643 bindings: [],
2644 },
2645 });
2646 let MyQueue: Queue;
2647 let MyFunction: Function;
2648 const plan = yield* Effect.gen(function* () {
2649 MyQueue = yield* Queue("MyQueue");
2650 MyFunction = yield* Function("MyFunction", {
2651 name: "test-function",
2652 env: {
2653 QUEUE_URL: MyQueue.queueUrl,
2654 },
2655 });
2656 }).pipe(makePlan);
2657 expect(plan).toMatchObject({
2658 resources: {
2659 MyFunction: {
2660 action: "create",
2661 bindings: [],
2662 resource: MyFunction!,
2663 props: {
2664 name: "test-function",
2665 env: {
2666 QUEUE_URL: expect.objectContaining({
2667 kind: "PropExpr",
2668 identifier: "queueUrl",
2669 expr: expect.objectContaining({
2670 kind: "ResourceExpr",
2671 src: MyQueue!,
2672 }),
2673 }),
2674 },
2675 },
2676 state: undefined,
2677 },
2678 },
2679 deletions: expect.toSatisfy(
2680 (d: any) => Object.keys(d).length === 0,
2681 "empty object",
2682 ),
2683 });
2684 }),
2685 { tags: ["unit", "local"] },
2686);
2687
2688describe(
2689 "Outputs should resolve to old values",
2690 { tags: ["unit", "local"] },
2691 () => {
2692 const stateResources: Record<string, ResourceState> = {
2693 A: {
2694 instanceId,
2695 providerVersion: 0,
2696 logicalId: "A",
2697 fqn: "A",
2698 namespace: undefined,
2699 resourceType: "Test.TestResource",
2700 status: "created",
2701 props: {
2702 string: "test-string",
2703 stringArray: ["test-string"],
2704 },
2705 attr: {
2706 string: "test-string",
2707 stringArray: ["test-string"],
2708 },
2709 downstream: [],
2710 bindings: [],
2711 },
2712 };
2713
2714 const expected = (props: Input.Resolve<InputProps<TestResourceProps>>) => ({
2715 resources: {
2716 A: {
2717 action: "noop",
2718 bindings: [],
2719 },
2720 B: {
2721 action: "create",
2722 bindings: [],
2723 props: props,
2724 },
2725 },
2726 deletions: expect.toSatisfy(
2727 (d: any) => Object.keys(d).length === 0,
2728 "empty object",
2729 ),
2730 });
2731
2732 const subtest = <const I extends InputProps<TestResourceProps>>(
2733 description: string,
2734 input: (resource: TestResource) => I,
2735 attr: Input.Resolve<I>,
2736 ) =>
2737 test(
2738 description,
2739 Effect.gen(function* () {
2740 yield* seed(stateResources);
2741 expect(
2742 yield* Effect.gen(function* () {
2743 const A = yield* TestResource("A", {
2744 string: "test-string",
2745 stringArray: ["test-string"],
2746 });
2747 yield* TestResource("B", input(A));
2748 }).pipe(makePlan),
2749 ).toMatchObject(expected(attr));
2750 }),
2751 );
2752
2753 subtest(
2754 "string",
2755 (A) => ({
2756 string: A.string,
2757 }),
2758 {
2759 string: "test-string",
2760 },
2761 );
2762
2763 subtest(
2764 "string.apply(string => undefined)",
2765 (A) => ({
2766 string: A.string.pipe(Output.map(() => undefined)),
2767 }),
2768 {
2769 string: undefined,
2770 },
2771 );
2772
2773 subtest(
2774 "string.effect(string => Effect.succeed(undefined))",
2775 (A) => ({
2776 string: A.string.pipe(
2777 Output.mapEffect(() => Effect.succeed(undefined)),
2778 ),
2779 }),
2780 {
2781 string: undefined,
2782 },
2783 );
2784
2785 subtest(
2786 "string.flatMap(() => Output.literal(undefined))",
2787 (A) => ({
2788 string: A.string.pipe(Output.flatMap(() => Output.literal(undefined))),
2789 }),
2790 {
2791 string: undefined,
2792 },
2793 );
2794
2795 subtest(
2796 "string.flatMap(string => A.stringArray.map(([first]) => first))",
2797 (A) => ({
2798 string: A.string.pipe(
2799 Output.flatMap(() =>
2800 A.stringArray.pipe(
2801 Output.map((stringArray) => stringArray[0]!.toUpperCase()),
2802 ),
2803 ),
2804 ),
2805 }),
2806 {
2807 string: "TEST-STRING",
2808 },
2809 );
2810
2811 subtest(
2812 "stringArray[0].toUpperCase()",
2813 (A) => ({
2814 string: A.stringArray.pipe(
2815 Output.map((stringArray) => stringArray[0]!.toUpperCase()),
2816 ),
2817 }),
2818 {
2819 string: "TEST-STRING",
2820 },
2821 );
2822
2823 subtest(
2824 "resource object",
2825 (A) => ({
2826 object: A as any,
2827 }),
2828 {
2829 object: {
2830 string: "test-string",
2831 },
2832 } as any,
2833 );
2834 },
2835);
2836
2837describe(
2838 "raw Resource refs in props are tracked as upstream dependencies",
2839 { tags: ["unit", "local"] },
2840 () => {
2841 test(
2842 "raw Resource passed directly as a prop value populates the upstream's downstream",
2843 Effect.gen(function* () {
2844 const plan = yield* Effect.gen(function* () {
2845 const A = yield* TestResource("A", { string: "a-value" });
2846 yield* TestResource("B", {
2847 object: A as any,
2848 });
2849 }).pipe(makePlan);
2850
2851 expect(plan.resources.A!.downstream).toEqual(["B"]);
2852 expect(plan.resources.B!.downstream).toEqual([]);
2853 }),
2854 );
2855
2856 test(
2857 "raw Resources nested in arrays/objects are tracked as upstream dependencies",
2858 Effect.gen(function* () {
2859 const plan = yield* Effect.gen(function* () {
2860 const A = yield* TestResource("A", { string: "a-value" });
2861 const B = yield* TestResource("B", { string: "b-value" });
2862 yield* TestResource("C", {
2863 stringArray: [A] as any,
2864 object: { ref: B } as any,
2865 });
2866 }).pipe(makePlan);
2867
2868 expect(plan.resources.A!.downstream).toEqual(["C"]);
2869 expect(plan.resources.B!.downstream).toEqual(["C"]);
2870 expect(plan.resources.C!.downstream).toEqual([]);
2871 }),
2872 );
2873 },
2874);
2875
2876describe(
2877 "stable properties should not cause downstream changes",
2878 { tags: ["unit", "local"] },
2879 () => {
2880 const subtest = (
2881 description: string,
2882 input: (A: TestResource) => InputProps<TestResourceProps>,
2883 ) => {
2884 // @ts-expect-error - get the keys
2885 const props = input(Output.of({}));
2886 test(
2887 description,
2888 Effect.gen(function* () {
2889 yield* seed({
2890 A: {
2891 instanceId,
2892 providerVersion: 0,
2893 logicalId: "A",
2894 fqn: "A",
2895 namespace: undefined,
2896 resourceType: "Test.TestResource",
2897 status: "created",
2898 props: {
2899 string: "test-string-old",
2900 },
2901 attr: {
2902 string: "test-string-old",
2903 stableString: "A",
2904 stableArray: ["A"],
2905 },
2906 downstream: [],
2907 bindings: [],
2908 },
2909 B: {
2910 instanceId,
2911 providerVersion: 0,
2912 logicalId: "B",
2913 fqn: "B",
2914 namespace: undefined,
2915 resourceType: "Test.TestResource",
2916 status: "created",
2917 props: Object.fromEntries(
2918 Object.entries({
2919 string: "A",
2920 stringArray: ["A"],
2921 }).filter(([key]) => key in props),
2922 ),
2923 attr: {
2924 stableString: "A",
2925 },
2926 downstream: [],
2927 bindings: [],
2928 },
2929 });
2930 expect(
2931 yield* Effect.gen(function* () {
2932 const A = yield* TestResource("A", {
2933 string: "test-string",
2934 });
2935 yield* TestResource("B", input(A));
2936 }).pipe(makePlan),
2937 ).toMatchObject({
2938 resources: {
2939 A: {
2940 action: "update",
2941 props: {
2942 string: "test-string",
2943 },
2944 },
2945 B: {
2946 action: "noop",
2947 },
2948 },
2949 deletions: expect.toSatisfy(
2950 (d: any) => Object.keys(d).length === 0,
2951 "empty object",
2952 ),
2953 });
2954 }),
2955 );
2956 };
2957
2958 subtest("A.stableString", (A) => ({
2959 string: A.stableString,
2960 }));
2961
2962 subtest("A.stableString.apply((string) => string.toUpperCase())", (A) => ({
2963 string: A.stableString.pipe(Output.map((string) => string.toUpperCase())),
2964 }));
2965
2966 subtest(
2967 "A.stableString.effect((string) => Effect.succeed(string.toUpperCase()))",
2968 (A) => ({
2969 string: A.stableString.pipe(
2970 Output.mapEffect((string) => Effect.succeed(string.toUpperCase())),
2971 ),
2972 }),
2973 );
2974
2975 subtest(
2976 "A.stableString.flatMap((string) => Output.literal(string.toUpperCase()))",
2977 (A) => ({
2978 string: A.stableString.pipe(
2979 Output.flatMap((string) => Output.literal(string.toUpperCase())),
2980 ),
2981 }),
2982 );
2983
2984 subtest("A.stableArray", (A) => ({
2985 stringArray: A.stableArray,
2986 }));
2987
2988 subtest("A.stableArray[0]", (A) => ({
2989 string: A.stableArray.pipe(Output.map((stableArray) => stableArray[0]!)),
2990 }));
2991
2992 subtest(
2993 "A.stableArray[0].apply((string) => string.toUpperCase())",
2994 (A) => ({
2995 string: A.stableArray.pipe(
2996 Output.map((stableArray) => stableArray[0]!.toUpperCase()),
2997 ),
2998 }),
2999 );
3000
3001 subtest(
3002 "A.stableArray[0].effect((string) => Effect.succeed(string.toUpperCase()))",
3003 (A) => ({
3004 string: A.stableArray.pipe(
3005 Output.mapEffect((stableArray) =>
3006 Effect.succeed(stableArray[0]!.toUpperCase()),
3007 ),
3008 ),
3009 }),
3010 );
3011 },
3012);
3013
3014describe(
3015 "whole-resource refs resolve to the upstream's stable attributes",
3016 { tags: ["unit", "local"] },
3017 () => {
3018 // Regression: when a resource is referenced *whole* (e.g. `object: A`)
3019 // rather than via a single prop (`A.stableString`), and the upstream is
3020 // being updated in place, `resolveResource` returns a `ResourceExpr`
3021 // carrying only the stable attributes. Previously `resolveInput` handed
3022 // that `ResourceExpr` to the downstream verbatim, so its `news` looked
3023 // unresolved (`isResolved(news) === false`) and the stable values never
3024 // reached the downstream `diff`. This forced the Neon `Branch` to manually
3025 // extract `project.projectId` as a workaround. The engine materializes the
3026 // known stable attributes into a plain object for the DIFF-facing `news`
3027 // so the stable values flow into the diff and the downstream can no-op.
3028 //
3029 // The plan node's `props`, however, must keep the reference as an
3030 // evaluable `ResourceExpr`: Apply re-resolves `node.props` against the
3031 // upstream's fresh post-reconcile attributes, and a materialized
3032 // stables-only snapshot would permanently hide every non-stable attribute
3033 // from the downstream's `reconcile` (e.g. a Lambda Alias promoting a
3034 // freshly-published Lambda Version would never see the new version
3035 // number — #993's alias promotion bug).
3036 const seedUpdatingUpstream = () =>
3037 seed({
3038 A: {
3039 instanceId,
3040 providerVersion: 0,
3041 logicalId: "A",
3042 fqn: "A",
3043 namespace: undefined,
3044 resourceType: "Test.TestResource",
3045 status: "created",
3046 props: {
3047 string: "old-value",
3048 },
3049 attr: {
3050 string: "old-value",
3051 stableString: "A",
3052 stableArray: ["A"],
3053 },
3054 downstream: [],
3055 bindings: [],
3056 },
3057 });
3058
3059 test(
3060 "the node's whole-resource ref stays an evaluable Expr carrying the stable attributes",
3061 Effect.gen(function* () {
3062 yield* seedUpdatingUpstream();
3063
3064 let A: TestResource;
3065 const plan = yield* Effect.gen(function* () {
3066 // A is updated in place: `string` changes, but `stableString` /
3067 // `stableArray` are declared stable by its diff.
3068 A = yield* TestResource("A", { string: "new-value" });
3069 // B (created fresh) references the WHOLE upstream resource, not a
3070 // single prop — so its plan node carries the resolved `props`.
3071 yield* TestResource("B", { object: A as any });
3072 }).pipe(makePlan);
3073
3074 expect(plan.resources.A!.action).toBe("update");
3075
3076 const bProps = (plan.resources.B as any).props as TestResourceProps;
3077 // The node's props keep the whole-resource ref as an evaluable
3078 // `ResourceExpr` (so Apply resolves the upstream's FRESH attributes
3079 // after its reconcile), with the stable attributes riding along for
3080 // plan-time consumers.
3081 expect(Output.isExpr(bProps.object)).toBe(true);
3082 expect(Output.isResourceExpr(bProps.object)).toBe(true);
3083 expect(
3084 (bProps.object as any as Output.ResourceExpr<any>).stables,
3085 ).toEqual({
3086 stableString: "A",
3087 stableArray: ["A"],
3088 });
3089 }),
3090 );
3091
3092 test(
3093 "a whole-resource ref to an updating upstream does not drag the downstream into an update",
3094 Effect.gen(function* () {
3095 yield* seedUpdatingUpstream();
3096 yield* seed({
3097 B: {
3098 instanceId,
3099 providerVersion: 0,
3100 logicalId: "B",
3101 fqn: "B",
3102 namespace: undefined,
3103 resourceType: "Test.TestResource",
3104 status: "created",
3105 // B's prior props captured the upstream's stable attributes —
3106 // exactly what a materialized whole-resource ref resolves to.
3107 props: {
3108 object: { stableString: "A", stableArray: ["A"] } as any,
3109 },
3110 attr: {
3111 string: "B",
3112 stableString: "B",
3113 stableArray: ["B"],
3114 },
3115 downstream: [],
3116 bindings: [],
3117 },
3118 });
3119
3120 let A: TestResource;
3121 const plan = yield* Effect.gen(function* () {
3122 A = yield* TestResource("A", { string: "new-value" });
3123 yield* TestResource("B", { object: A as any });
3124 }).pipe(makePlan);
3125
3126 expect(plan.resources.A!.action).toBe("update");
3127 // Only stable attributes flow in and they are unchanged, so the
3128 // downstream no-ops instead of being dragged into a needless update.
3129 expect(plan.resources.B!.action).toBe("noop");
3130 }),
3131 );
3132
3133 // The binding path mirrors the props split: `diffBindings` compares the
3134 // materialized (stables-only) view, but the node's binding rows carry the
3135 // apply-faithful payload so `Output.evaluate(node.bindings, outputs)`
3136 // re-resolves the upstream's fresh post-reconcile attributes.
3137 const seedHostWithFullPayload = () =>
3138 seed({
3139 Host: {
3140 instanceId,
3141 providerVersion: 0,
3142 logicalId: "Host",
3143 fqn: "Host",
3144 namespace: undefined,
3145 resourceType: "Test.BindingTarget",
3146 status: "created",
3147 props: { name: "host" },
3148 attr: {
3149 name: "host",
3150 string: "Host",
3151 env: {},
3152 replaceString: undefined,
3153 },
3154 downstream: [],
3155 // Terminal commits persist the payload the provider reconciled
3156 // with — the upstream's FULL attributes (#874), not the plan-time
3157 // stables-only projection.
3158 bindings: [
3159 {
3160 sid: "FromA",
3161 data: {
3162 env: {
3163 A: {
3164 string: "old-value",
3165 stableString: "A",
3166 stableArray: ["A"],
3167 },
3168 },
3169 },
3170 },
3171 ],
3172 },
3173 });
3174
3175 const hostProgram = (upstreamString: string) =>
3176 Effect.gen(function* () {
3177 const A = yield* TestResource("A", { string: upstreamString });
3178 const host = yield* BindingTarget("Host", { name: "host" });
3179 // The binding data embeds the WHOLE upstream resource.
3180 yield* host.bind("FromA", { env: { A } } as any);
3181 });
3182
3183 test(
3184 "the node's binding payload keeps the whole-resource ref as an evaluable Expr carrying the stable attributes",
3185 Effect.gen(function* () {
3186 yield* seedUpdatingUpstream();
3187
3188 const plan = yield* hostProgram("new-value").pipe(makePlan);
3189
3190 expect(plan.resources.A!.action).toBe("update");
3191
3192 const rows = (plan.resources.Host as any).bindings;
3193 expect(rows).toHaveLength(1);
3194 expect(rows[0].sid).toBe("FromA");
3195 const payload = rows[0].data.env.A;
3196 expect(Output.isResourceExpr(payload)).toBe(true);
3197 expect((payload as Output.ResourceExpr<any>).stables).toEqual({
3198 stableString: "A",
3199 stableArray: ["A"],
3200 });
3201 }),
3202 );
3203
3204 test(
3205 "an updating upstream marks the binding row 'update' from the materialized comparison while the payload stays evaluable",
3206 Effect.gen(function* () {
3207 yield* seedUpdatingUpstream();
3208 yield* seedHostWithFullPayload();
3209
3210 const plan = yield* hostProgram("new-value").pipe(makePlan);
3211
3212 expect(plan.resources.A!.action).toBe("update");
3213 // The host's own props are unchanged; the binding drift alone drags
3214 // it into the update that re-delivers A's fresh attributes.
3215 expect(plan.resources.Host!.action).toBe("update");
3216
3217 const rows = (plan.resources.Host as any).bindings;
3218 expect(rows).toHaveLength(1);
3219 // Action from the materialized comparison (persisted full attrs vs
3220 // stables-only projection)...
3221 expect(rows[0].action).toBe("update");
3222 // ...payload from the apply-faithful resolution.
3223 expect(Output.isResourceExpr(rows[0].data.env.A)).toBe(true);
3224 }),
3225 );
3226
3227 test(
3228 "an unchanged upstream's full persisted binding payload no-ops instead of churning",
3229 Effect.gen(function* () {
3230 yield* seedUpdatingUpstream();
3231 yield* seedHostWithFullPayload();
3232
3233 // Same props as seeded — A no-ops, so it resolves to its full
3234 // persisted attrs and the materialized binding payload matches the
3235 // persisted row exactly.
3236 const plan = yield* hostProgram("old-value").pipe(makePlan);
3237
3238 expect(plan.resources.A!.action).toBe("noop");
3239 expect(plan.resources.Host!.action).toBe("noop");
3240 const rows = (plan.resources.Host as any).bindings;
3241 expect(rows[0].action).toBe("noop");
3242 // Nothing left to re-evaluate — the payload is the plain full attrs.
3243 expect(Output.isExpr(rows[0].data.env.A)).toBe(false);
3244 expect(rows[0].data.env.A.string).toBe("old-value");
3245 }),
3246 );
3247 },
3248);
3249
3250describe(
3251 "diff.stables overrides provider.stables",
3252 { tags: ["unit", "local"] },
3253 () => {
3254 // `A` is an OverrideStablesResource: provider `stables` is
3255 // ["providerStable", "sharedStable"], but its `diff` returns
3256 // ["diffStable", "sharedStable"] on a `string` change. The two lists
3257 // disagree, so this exercises the override (not merge) semantics.
3258 const seedUpstreamAndDownstream = (downstreamOldString: string) =>
3259 seed({
3260 A: {
3261 instanceId,
3262 providerVersion: 0,
3263 logicalId: "A",
3264 fqn: "A",
3265 namespace: undefined,
3266 resourceType: "Test.OverrideStablesResource",
3267 status: "created",
3268 props: { string: "old" },
3269 attr: {
3270 string: "old",
3271 providerStable: "provider-A",
3272 diffStable: "diff-A",
3273 sharedStable: "shared-A",
3274 },
3275 downstream: [],
3276 bindings: [],
3277 },
3278 B: {
3279 instanceId,
3280 providerVersion: 0,
3281 logicalId: "B",
3282 fqn: "B",
3283 namespace: undefined,
3284 resourceType: "Test.TestResource",
3285 status: "created",
3286 props: { string: downstreamOldString },
3287 attr: {
3288 string: downstreamOldString,
3289 stableString: "B",
3290 stableArray: ["B"],
3291 },
3292 downstream: [],
3293 bindings: [],
3294 },
3295 });
3296
3297 const subtest = (
3298 description: string,
3299 accessor: (A: OverrideStablesResource) => any,
3300 downstreamOldString: string,
3301 expectedBAction: "update" | "noop",
3302 ) =>
3303 test(
3304 description,
3305 Effect.gen(function* () {
3306 yield* seedUpstreamAndDownstream(downstreamOldString);
3307 const plan = yield* Effect.gen(function* () {
3308 const A = yield* OverrideStablesResource("A", { string: "new" });
3309 yield* TestResource("B", { string: accessor(A) });
3310 }).pipe(makePlan);
3311
3312 // A always updates: its `string` prop changed.
3313 expect(plan.resources.A!.action).toBe("update");
3314 expect(plan.resources.B!.action).toBe(expectedBAction);
3315 }),
3316 );
3317
3318 // `providerStable` is in `provider.stables` but OMITTED from the
3319 // `diff.stables` returned for this update. Because `diff.stables` now
3320 // overrides `provider.stables`, it is treated as changed and the
3321 // downstream re-plans (update). Under the old merge it would wrongly
3322 // stay stable and the downstream would no-op.
3323 subtest(
3324 "provider-only stable omitted by diff is treated as changed downstream",
3325 (A) => A.providerStable,
3326 "provider-A",
3327 "update",
3328 );
3329
3330 // `diffStable` is only in `diff.stables` -> stays stable -> downstream no-op.
3331 subtest(
3332 "diff-only stable keeps downstream stable",
3333 (A) => A.diffStable,
3334 "diff-A",
3335 "noop",
3336 );
3337
3338 // `sharedStable` is in both lists -> stays stable -> downstream no-op.
3339 subtest(
3340 "shared stable keeps downstream stable",
3341 (A) => A.sharedStable,
3342 "shared-A",
3343 "noop",
3344 );
3345 },
3346);
3347
3348describe("unsatisfied cycle detection", { tags: ["unit", "local"] }, () => {
3349 const extractCycleDefect = <A, E>(
3350 exit: Exit.Exit<A, E>,
3351 ): UnsatisfiedResourceCycle | undefined => {
3352 if (!Exit.isFailure(exit)) return undefined;
3353 const die = exit.cause.reasons.find(Cause.isDieReason);
3354 return die?.defect as UnsatisfiedResourceCycle | undefined;
3355 };
3356
3357 test(
3358 "binding cycle between resources without precreate dies",
3359 Effect.gen(function* () {
3360 const exit = yield* makePlan(
3361 Effect.gen(function* () {
3362 const A = yield* NoPrecreateBindingTarget("A", {
3363 string: "a-value",
3364 });
3365 const B = yield* NoPrecreateBindingTarget("B", {
3366 string: "b-value",
3367 });
3368
3369 yield* A.bind("FromB", { env: { PEER: B.string } });
3370 yield* B.bind("FromA", { env: { PEER: A.string } });
3371
3372 return { A, B };
3373 }),
3374 ).pipe(Effect.exit);
3375
3376 const err = extractCycleDefect(exit);
3377 expect(err).toBeDefined();
3378 expect(err!._tag).toBe("UnsatisfiedResourceCycle");
3379 expect(err!.cycle.sort()).toEqual(["A", "B"]);
3380 expect(err!.missingPrecreate.sort()).toEqual(["A", "B"]);
3381 }),
3382 );
3383
3384 test(
3385 "binding cycle with all precreate resources succeeds",
3386 Effect.gen(function* () {
3387 const exit = yield* makePlan(
3388 Effect.gen(function* () {
3389 const A = yield* BindingTarget("A", { string: "a-value" });
3390 const B = yield* BindingTarget("B", { string: "b-value" });
3391
3392 yield* A.bind("FromB", {
3393 env: { PEER: B.string },
3394 });
3395 yield* B.bind("FromA", {
3396 env: { PEER: A.string },
3397 });
3398
3399 return { A, B };
3400 }),
3401 ).pipe(Effect.exit);
3402
3403 expect(Exit.isSuccess(exit)).toBe(true);
3404 }),
3405 );
3406
3407 test(
3408 "mixed cycle succeeds when precreate resource breaks it",
3409 Effect.gen(function* () {
3410 const exit = yield* makePlan(
3411 Effect.gen(function* () {
3412 const A = yield* BindingTarget("A", { string: "a-value" });
3413 const B = yield* NoPrecreateBindingTarget("B", {
3414 string: A.string,
3415 });
3416
3417 yield* A.bind("FromB", {
3418 env: { PEER: B.string },
3419 });
3420
3421 return { A, B };
3422 }),
3423 ).pipe(Effect.exit);
3424
3425 expect(Exit.isSuccess(exit)).toBe(true);
3426 }),
3427 );
3428
3429 test(
3430 "three-node binding cycle dies when none have precreate",
3431 Effect.gen(function* () {
3432 const exit = yield* makePlan(
3433 Effect.gen(function* () {
3434 const A = yield* NoPrecreateBindingTarget("A", { string: "a" });
3435 const B = yield* NoPrecreateBindingTarget("B", { string: "b" });
3436 const C = yield* NoPrecreateBindingTarget("C", { string: "c" });
3437
3438 yield* A.bind("FromC", { env: { PEER: C.string } });
3439 yield* B.bind("FromA", { env: { PEER: A.string } });
3440 yield* C.bind("FromB", { env: { PEER: B.string } });
3441
3442 return { A, B, C };
3443 }),
3444 ).pipe(Effect.exit);
3445
3446 const err = extractCycleDefect(exit);
3447 expect(err).toBeDefined();
3448 expect(err!._tag).toBe("UnsatisfiedResourceCycle");
3449 expect(err!.cycle.sort()).toEqual(["A", "B", "C"]);
3450 expect(err!.missingPrecreate.sort()).toEqual(["A", "B", "C"]);
3451 }),
3452 );
3453
3454 test(
3455 "acyclic binding graph succeeds even without precreate",
3456 Effect.gen(function* () {
3457 const exit = yield* makePlan(
3458 Effect.gen(function* () {
3459 const A = yield* NoPrecreateBindingTarget("A", {
3460 string: "a-value",
3461 });
3462 const B = yield* NoPrecreateBindingTarget("B", {
3463 string: A.string,
3464 });
3465
3466 yield* B.bind("FromA", { env: { PEER: A.string } });
3467
3468 return { A, B };
3469 }),
3470 ).pipe(Effect.exit);
3471
3472 expect(Exit.isSuccess(exit)).toBe(true);
3473 }),
3474 );
3475});
3476
3477describe(
3478 "unresolved plan inputs in diff should conservatively update",
3479 { tags: ["unit", "local"] },
3480 () => {
3481 test(
3482 "update when upstream resource is new and downstream news contains exprs",
3483 Effect.gen(function* () {
3484 yield* seed({
3485 B: {
3486 instanceId,
3487 providerVersion: 0,
3488 logicalId: "B",
3489 fqn: "B",
3490 namespace: undefined,
3491 resourceType: "Test.TestResource",
3492 status: "created",
3493 props: {
3494 string: "old-value",
3495 },
3496 attr: {
3497 string: "old-value",
3498 stableString: "B",
3499 stableArray: ["B"],
3500 },
3501 downstream: [],
3502 bindings: [],
3503 },
3504 });
3505 const plan = yield* Effect.gen(function* () {
3506 const A = yield* TestResource("A", {
3507 string: "hello",
3508 });
3509 yield* TestResource("B", {
3510 string: A.string,
3511 });
3512 }).pipe(makePlan);
3513
3514 expect(plan.resources.A.action).toBe("create");
3515 expect(plan.resources.B.action).toBe("update");
3516 }),
3517 );
3518 },
3519);
3520
3521describe(
3522 "Config props are resolved through plan",
3523 { tags: ["unit", "local"] },
3524 () => {
3525 test(
3526 "a Config prop is resolved to its concrete value in the plan",
3527 Effect.gen(function* () {
3528 const plan = yield* Effect.gen(function* () {
3529 yield* TestResource("A", {
3530 string: Config.succeed("resolved-config-value") as any,
3531 });
3532 }).pipe(makePlan);
3533
3534 const node: any = plan.resources.A!;
3535 expect(node.action).toBe("create");
3536 const props = node.props as TestResourceProps;
3537 expect(Config.isConfig(props.string)).toBe(false);
3538 expect(props.string).toBe("resolved-config-value");
3539 }),
3540 );
3541
3542 test(
3543 "a Config resolving to a Redacted keeps it wrapped in the plan",
3544 Effect.gen(function* () {
3545 const plan = yield* Effect.gen(function* () {
3546 yield* TestResource("A", {
3547 string: "x",
3548 redacted: Config.succeed(Redacted.make("hunter2")) as any,
3549 });
3550 }).pipe(makePlan);
3551
3552 const node: any = plan.resources.A!;
3553 expect(node.action).toBe("create");
3554 const props = node.props as TestResourceProps;
3555 expect(Redacted.isRedacted(props.redacted)).toBe(true);
3556 expect(Redacted.value(props.redacted!)).toBe("hunter2");
3557 }),
3558 );
3559
3560 test(
3561 "a Config nested inside an object prop is resolved in the plan",
3562 Effect.gen(function* () {
3563 const plan = yield* Effect.gen(function* () {
3564 yield* TestResource("A", {
3565 object: { string: Config.succeed("nested") as any },
3566 });
3567 }).pipe(makePlan);
3568
3569 const node: any = plan.resources.A!;
3570 expect(node.action).toBe("create");
3571 const props = node.props as TestResourceProps;
3572 expect(props.object).toEqual({ string: "nested" });
3573 }),
3574 );
3575 },
3576);
3577
3578describe(
3579 "Redacted props/outputs are preserved through plan",
3580 { tags: ["unit", "local"] },
3581 () => {
3582 test(
3583 "Redacted prop on a new resource is preserved as a Redacted in the plan",
3584 Effect.gen(function* () {
3585 const plan = yield* Effect.gen(function* () {
3586 yield* TestResource("A", {
3587 string: "x",
3588 redacted: Redacted.make("hunter2"),
3589 });
3590 }).pipe(makePlan);
3591
3592 const node: any = plan.resources.A!;
3593 expect(node.action).toBe("create");
3594 const props = node.props as TestResourceProps;
3595 expect(Redacted.isRedacted(props.redacted)).toBe(true);
3596 expect(Redacted.value(props.redacted!)).toBe("hunter2");
3597 }),
3598 );
3599
3600 test(
3601 "Redacted prop nested inside an array is preserved through the plan",
3602 Effect.gen(function* () {
3603 const plan = yield* Effect.gen(function* () {
3604 yield* TestResource("A", {
3605 string: "x",
3606 redactedArray: [Redacted.make("a"), Redacted.make("b")],
3607 });
3608 }).pipe(makePlan);
3609
3610 const node: any = plan.resources.A!;
3611 expect(node.action).toBe("create");
3612 const props = node.props as TestResourceProps;
3613 expect(props.redactedArray).toBeDefined();
3614 expect(props.redactedArray!.length).toBe(2);
3615 expect(Redacted.isRedacted(props.redactedArray![0]!)).toBe(true);
3616 expect(Redacted.isRedacted(props.redactedArray![1]!)).toBe(true);
3617 expect(Redacted.value(props.redactedArray![0]!)).toBe("a");
3618 expect(Redacted.value(props.redactedArray![1]!)).toBe("b");
3619 }),
3620 );
3621
3622 test(
3623 "no-op when prior state has the same Redacted value",
3624 Effect.gen(function* () {
3625 yield* seed({
3626 A: {
3627 instanceId,
3628 providerVersion: 0,
3629 logicalId: "A",
3630 fqn: "A",
3631 namespace: undefined,
3632 resourceType: "Test.TestResource",
3633 status: "created",
3634 props: {
3635 string: "x",
3636 redacted: Redacted.make("hunter2"),
3637 },
3638 attr: {
3639 string: "x",
3640 stringArray: [],
3641 stableString: "A",
3642 stableArray: ["A"],
3643 replaceString: undefined,
3644 redacted: Redacted.make("hunter2"),
3645 redactedArray: undefined,
3646 },
3647 downstream: [],
3648 bindings: [],
3649 },
3650 });
3651 const plan = yield* Effect.gen(function* () {
3652 yield* TestResource("A", {
3653 string: "x",
3654 redacted: Redacted.make("hunter2"),
3655 });
3656 }).pipe(makePlan);
3657
3658 expect(plan.resources.A!.action).toBe("noop");
3659 }),
3660 );
3661
3662 test(
3663 "update when Redacted prop value changes",
3664 Effect.gen(function* () {
3665 yield* seed({
3666 A: {
3667 instanceId,
3668 providerVersion: 0,
3669 logicalId: "A",
3670 fqn: "A",
3671 namespace: undefined,
3672 resourceType: "Test.TestResource",
3673 status: "created",
3674 props: {
3675 string: "x",
3676 redacted: Redacted.make("old"),
3677 },
3678 attr: {
3679 string: "x",
3680 stringArray: [],
3681 stableString: "A",
3682 stableArray: ["A"],
3683 replaceString: undefined,
3684 redacted: Redacted.make("old"),
3685 redactedArray: undefined,
3686 },
3687 downstream: [],
3688 bindings: [],
3689 },
3690 });
3691 const plan = yield* Effect.gen(function* () {
3692 yield* TestResource("A", {
3693 string: "x",
3694 redacted: Redacted.make("new"),
3695 });
3696 }).pipe(makePlan);
3697
3698 expect(plan.resources.A!.action).toBe("update");
3699 const node: any = plan.resources.A!;
3700 const props = node.props as TestResourceProps;
3701 expect(Redacted.isRedacted(props.redacted)).toBe(true);
3702 expect(Redacted.value(props.redacted!)).toBe("new");
3703 }),
3704 );
3705
3706 test(
3707 "Redacted output flowing into a downstream resource preserves its redaction",
3708 Effect.gen(function* () {
3709 yield* seed({
3710 A: {
3711 instanceId,
3712 providerVersion: 0,
3713 logicalId: "A",
3714 fqn: "A",
3715 namespace: undefined,
3716 resourceType: "Test.TestResource",
3717 status: "created",
3718 props: {
3719 string: "x",
3720 redacted: Redacted.make("hunter2"),
3721 },
3722 attr: {
3723 string: "x",
3724 stringArray: [],
3725 stableString: "A",
3726 stableArray: ["A"],
3727 replaceString: undefined,
3728 redacted: Redacted.make("hunter2"),
3729 redactedArray: undefined,
3730 },
3731 downstream: [],
3732 bindings: [],
3733 },
3734 });
3735 const plan = yield* Effect.gen(function* () {
3736 const A = yield* TestResource("A", {
3737 string: "x",
3738 redacted: Redacted.make("hunter2"),
3739 });
3740 yield* TestResource("B", {
3741 string: "y",
3742 redacted: A.redacted as any,
3743 });
3744 }).pipe(makePlan);
3745
3746 const bNode: any = plan.resources.B!;
3747 const bProps = bNode.props as TestResourceProps;
3748 expect(Redacted.isRedacted(bProps.redacted)).toBe(true);
3749 expect(Redacted.value(bProps.redacted!)).toBe("hunter2");
3750 }),
3751 );
3752 },
3753);
3754
3755describe("engine-level adoption", { tags: ["unit", "local"] }, () => {
3756 // Build a plan, optionally with an explicit AdoptPolicy and a read hook
3757 // that simulates a pre-existing cloud resource.
3758 const ownedAttrs: TestResource["Attributes"] = {
3759 string: "hello",
3760 stringArray: [],
3761 stableString: "Adopted",
3762 stableArray: ["Adopted"],
3763 replaceString: undefined,
3764 redacted: undefined,
3765 redactedArray: undefined,
3766 };
3767
3768 const makeAdoptPlan = <A>(
3769 effect: Effect.Effect<A, any, any>,
3770 opts: {
3771 adopt?: boolean;
3772 readHook?: (
3773 id: string,
3774 ) => Effect.Effect<TestResource["Attributes"] | undefined, any>;
3775 },
3776 ): Effect.Effect<Plan.Plan<A>, any, State> =>
3777 Effect.gen(function* () {
3778 const { name, stage } = yield* resolveStackId;
3779 const hooksLayer = opts.readHook
3780 ? Layer.succeed(TestResourceHooks, { read: opts.readHook })
3781 : Layer.empty;
3782 const adoptLayer =
3783 opts.adopt === undefined
3784 ? Layer.empty
3785 : Layer.succeed(AdoptPolicy, opts.adopt);
3786 return yield* (effect as Effect.Effect<A, any, any>).pipe(
3787 Stack.make({
3788 name,
3789 providers: Layer.empty,
3790 state: inMemoryState(),
3791 } as any) as any,
3792 Effect.provideService(Stage, stage),
3793 Effect.flatMap((stackSpec: any) => Plan.make(stackSpec)),
3794 Effect.provide(TestLayers()),
3795 Effect.provide(hooksLayer),
3796 Effect.provide(adoptLayer),
3797 ) as Effect.Effect<Plan.Plan<A>, any, State>;
3798 }) as Effect.Effect<Plan.Plan<A>, any, State>;
3799
3800 const creatingWithoutAttrs = {
3801 instanceId,
3802 providerVersion: 0,
3803 logicalId: "Recovering",
3804 fqn: "Recovering",
3805 namespace: undefined,
3806 resourceType: "Test.TestResource",
3807 status: "creating" as const,
3808 props: { string: "hello" },
3809 attr: undefined,
3810 downstream: [],
3811 bindings: [],
3812 } satisfies ResourceState;
3813
3814 test.provider("cold adoption reconciles with olds undefined", (scratch) =>
3815 Effect.gen(function* () {
3816 let creates = 0;
3817 let updates = 0;
3818 const hooks = {
3819 read: () => Effect.succeed(ownedAttrs),
3820 create: () =>
3821 Effect.sync(() => {
3822 creates++;
3823 }),
3824 update: () =>
3825 Effect.sync(() => {
3826 updates++;
3827 }),
3828 };
3829
3830 yield* scratch
3831 .deploy(
3832 Effect.gen(function* () {
3833 yield* TestResource("Adopted", { string: "hello" });
3834 }),
3835 )
3836 .pipe(Effect.provideService(TestResourceHooks, hooks));
3837
3838 expect(creates).toBe(1);
3839 expect(updates).toBe(0);
3840
3841 const state = yield* yield* State;
3842 expect(
3843 yield* state.get({
3844 stack: scratch.name,
3845 stage: scratch.stage,
3846 fqn: "Adopted",
3847 }),
3848 ).toMatchObject({
3849 status: "updated",
3850 props: { string: "hello" },
3851 });
3852 }),
3853 );
3854
3855 test.provider(
3856 "cold adoption keeps olds undefined after a failed first reconcile",
3857 (scratch) =>
3858 Effect.gen(function* () {
3859 let creates = 0;
3860 let updates = 0;
3861 const hooks = {
3862 read: () => Effect.succeed(ownedAttrs),
3863 create: () =>
3864 Effect.suspend(() => {
3865 creates++;
3866 return creates === 1
3867 ? Effect.fail(new Error("first adoption reconcile failed"))
3868 : Effect.void;
3869 }),
3870 update: () =>
3871 Effect.sync(() => {
3872 updates++;
3873 }),
3874 };
3875 const program = () =>
3876 Effect.gen(function* () {
3877 yield* TestResource("Adopted", { string: "hello" });
3878 });
3879
3880 const first = yield* scratch
3881 .deploy(program())
3882 .pipe(Effect.provideService(TestResourceHooks, hooks), Effect.exit);
3883 expect(Exit.isFailure(first)).toBe(true);
3884 expect(creates).toBe(1);
3885 expect(updates).toBe(0);
3886
3887 const state = yield* yield* State;
3888 expect(
3889 yield* state.get({
3890 stack: scratch.name,
3891 stage: scratch.stage,
3892 fqn: "Adopted",
3893 }),
3894 ).toMatchObject({
3895 status: "updating",
3896 adopting: true,
3897 });
3898
3899 yield* scratch
3900 .deploy(program())
3901 .pipe(Effect.provideService(TestResourceHooks, hooks));
3902
3903 expect(creates).toBe(2);
3904 expect(updates).toBe(0);
3905 const completed = yield* state.get({
3906 stack: scratch.name,
3907 stage: scratch.stage,
3908 fqn: "Adopted",
3909 });
3910 expect(completed).toMatchObject({ status: "updated" });
3911 expect((completed as any)?.adopting).toBeUndefined();
3912 }),
3913 );
3914
3915 test(
3916 "recovered Unowned attrs require explicit adoption",
3917 Effect.gen(function* () {
3918 yield* seed({ Recovering: creatingWithoutAttrs });
3919
3920 const exit = yield* makeAdoptPlan(
3921 Effect.gen(function* () {
3922 yield* TestResource("Recovering", { string: "hello" });
3923 }),
3924 {
3925 adopt: false,
3926 readHook: () => Effect.succeed(Unowned(ownedAttrs)),
3927 },
3928 ).pipe(Effect.exit);
3929
3930 expect(Exit.isFailure(exit)).toBe(true);
3931 if (Exit.isFailure(exit)) {
3932 const reason = exit.cause.reasons.find(Cause.isFailReason);
3933 expect((reason?.error as any)?._tag).toBe("OwnedBySomeoneElse");
3934 expect((reason?.error as any)?.resourceType).toBe("Test.TestResource");
3935 }
3936 }),
3937 );
3938
3939 test(
3940 "explicit adoption strips Unowned from recovered attrs",
3941 Effect.gen(function* () {
3942 yield* seed({ Recovering: creatingWithoutAttrs });
3943
3944 const plan = yield* makeAdoptPlan(
3945 Effect.gen(function* () {
3946 yield* TestResource("Recovering", { string: "hello" });
3947 }),
3948 {
3949 adopt: true,
3950 readHook: () => Effect.succeed(Unowned(ownedAttrs)),
3951 },
3952 );
3953
3954 const recovered = plan.resources.Recovering!;
3955 expect(recovered.action).toBe("create");
3956 expect(Unowned.is((recovered.state as any).attr)).toBe(false);
3957 expect(
3958 Object.getOwnPropertySymbols((recovered.state as any).attr),
3959 ).toEqual([]);
3960 }),
3961 );
3962
3963 test(
3964 "recovered attrs still diff immutable desired changes",
3965 Effect.gen(function* () {
3966 yield* seed({
3967 Recovering: {
3968 ...creatingWithoutAttrs,
3969 props: { replaceString: "old" },
3970 },
3971 });
3972
3973 const plan = yield* makeAdoptPlan(
3974 Effect.gen(function* () {
3975 yield* TestResource("Recovering", { replaceString: "new" });
3976 }),
3977 {
3978 readHook: () =>
3979 Effect.succeed({
3980 ...ownedAttrs,
3981 replaceString: "old",
3982 }),
3983 },
3984 );
3985
3986 expect(plan.resources.Recovering).toMatchObject({
3987 action: "replace",
3988 props: { replaceString: "new" },
3989 state: {
3990 status: "creating",
3991 attr: { replaceString: "old" },
3992 },
3993 });
3994 }),
3995 );
3996
3997 test(
3998 "owned read result is silently adopted (no AdoptPolicy needed) and forced to update",
3999 Effect.gen(function* () {
4000 const plan = yield* makeAdoptPlan(
4001 Effect.gen(function* () {
4002 yield* TestResource("Adopted", { string: "hello" });
4003 }),
4004 { readHook: () => Effect.succeed(ownedAttrs) },
4005 );
4006
4007 // Cold-start adoption is surfaced explicitly while the provider re-syncs
4008 // tags / config against `news` — even when read returns plain
4009 // (owned) attrs, the cloud resource may carry drift the engine
4010 // can't detect from `props` alone.
4011 expect(plan.resources.Adopted!.action).toBe("adopted");
4012 expect(plan.resources.Adopted).toMatchObject({
4013 adopting: true,
4014 state: {
4015 status: "created",
4016 attr: { string: "hello" },
4017 },
4018 });
4019
4020 // Planning no longer persists the adopted state (issue #793): it rides
4021 // on the plan node and is only committed to the store at apply time.
4022 const node = plan.resources.Adopted!;
4023 expect(node.state?.status).toBe("created");
4024 expect((node.state as any)?.attr).toMatchObject({ string: "hello" });
4025
4026 const state = yield* yield* State;
4027 expect(
4028 yield* state.get({
4029 stack: TEST_STACK,
4030 stage: TEST_STAGE,
4031 fqn: "Adopted",
4032 }),
4033 ).toBeUndefined();
4034 }),
4035 );
4036
4037 test(
4038 "Unowned read result + adopt enabled -> takeover forces an update",
4039 Effect.gen(function* () {
4040 const plan = yield* makeAdoptPlan(
4041 Effect.gen(function* () {
4042 yield* TestResource("Adopted", { string: "hello" });
4043 }),
4044 {
4045 adopt: true,
4046 readHook: () => Effect.succeed(Unowned(ownedAttrs)),
4047 },
4048 );
4049
4050 // Takeover of an Unowned resource is planned as `adopted` so the provider's
4051 // update path can rewrite ownership tags / config to match this
4052 // logical id (a plain noop would leave the resource looking
4053 // foreign-owned to subsequent deploys).
4054 expect(plan.resources.Adopted!.action).toBe("adopted");
4055 expect(plan.resources.Adopted).toMatchObject({
4056 adopting: true,
4057 state: { status: "created" },
4058 });
4059
4060 // The adopted state rides on the plan node, not the store (issue #793).
4061 const node = plan.resources.Adopted!;
4062 expect(node.state?.status).toBe("created");
4063
4064 // The Unowned brand must be fully scrubbed from anything that
4065 // reaches the plan node (and, at apply, the state store) — both via
4066 // the public `Unowned.is` check *and* via direct symbol inspection
4067 // (in case someone accidentally uses `Symbol.for` rather than
4068 // `Unowned.is`).
4069 const adoptedAttr = (node.state as any)?.attr as object;
4070 expect(Unowned.is(adoptedAttr)).toBe(false);
4071 expect(Object.getOwnPropertySymbols(adoptedAttr).length).toBe(0);
4072 expect(JSON.stringify(adoptedAttr)).not.toContain("Unowned");
4073
4074 // Planning wrote nothing to the store.
4075 const state = yield* yield* State;
4076 expect(
4077 yield* state.get({
4078 stack: TEST_STACK,
4079 stage: TEST_STAGE,
4080 fqn: "Adopted",
4081 }),
4082 ).toBeUndefined();
4083 }),
4084 );
4085
4086 test(
4087 "Unowned read result + adopt disabled -> OwnedBySomeoneElse",
4088 Effect.gen(function* () {
4089 const exit = yield* makeAdoptPlan(
4090 Effect.gen(function* () {
4091 yield* TestResource("Foreign", { string: "hello" });
4092 }),
4093 {
4094 adopt: false,
4095 readHook: () => Effect.succeed(Unowned(ownedAttrs)),
4096 },
4097 ).pipe(Effect.exit);
4098
4099 expect(Exit.isFailure(exit)).toBe(true);
4100 if (Exit.isFailure(exit)) {
4101 const reason = exit.cause.reasons.find(Cause.isFailReason);
4102 expect((reason?.error as any)?._tag).toBe("OwnedBySomeoneElse");
4103 expect((reason?.error as any)?.resourceType).toBe("Test.TestResource");
4104 }
4105 }),
4106 );
4107
4108 test(
4109 "read returns undefined -> ordinary create",
4110 Effect.gen(function* () {
4111 const plan = yield* makeAdoptPlan(
4112 Effect.gen(function* () {
4113 yield* TestResource("Fresh", { string: "hello" });
4114 }),
4115 { readHook: () => Effect.succeed(undefined) },
4116 );
4117
4118 expect(plan.resources.Fresh!.action).toBe("create");
4119 expect(plan.resources.Fresh!.state).toBeUndefined();
4120 }),
4121 );
4122
4123 test(
4124 "Unowned read result + resource-scoped adopt(true) -> takeover even when the stack default is disabled",
4125 Effect.gen(function* () {
4126 const plan = yield* makeAdoptPlan(
4127 Effect.gen(function* () {
4128 yield* TestResource("Adopted", { string: "hello" }).pipe(adopt(true));
4129 }),
4130 {
4131 // Stack/CLI default is OFF — only the per-resource scope opts in.
4132 adopt: false,
4133 readHook: () => Effect.succeed(Unowned(ownedAttrs)),
4134 },
4135 );
4136
4137 expect(plan.resources.Adopted!.action).toBe("adopted");
4138 expect(plan.resources.Adopted).toMatchObject({
4139 adopting: true,
4140 state: { status: "created" },
4141 });
4142
4143 // Adopted state rides on the plan node; planning persists nothing
4144 // (issue #793).
4145 const node = plan.resources.Adopted!;
4146 expect(node.state?.status).toBe("created");
4147
4148 const state = yield* yield* State;
4149 expect(
4150 yield* state.get({
4151 stack: TEST_STACK,
4152 stage: TEST_STAGE,
4153 fqn: "Adopted",
4154 }),
4155 ).toBeUndefined();
4156 }),
4157 );
4158
4159 test(
4160 "Unowned read result + resource-scoped adopt(false) -> OwnedBySomeoneElse even when the stack default is enabled",
4161 Effect.gen(function* () {
4162 const exit = yield* makeAdoptPlan(
4163 Effect.gen(function* () {
4164 yield* TestResource("Foreign", { string: "hello" }).pipe(
4165 adopt(false),
4166 );
4167 }),
4168 {
4169 // Stack/CLI default is ON, but the resource opts out.
4170 adopt: true,
4171 readHook: () => Effect.succeed(Unowned(ownedAttrs)),
4172 },
4173 ).pipe(Effect.exit);
4174
4175 expect(Exit.isFailure(exit)).toBe(true);
4176 if (Exit.isFailure(exit)) {
4177 const reason = exit.cause.reasons.find(Cause.isFailReason);
4178 expect((reason?.error as any)?._tag).toBe("OwnedBySomeoneElse");
4179 expect((reason?.error as any)?.resourceType).toBe("Test.TestResource");
4180 }
4181 }),
4182 );
4183
4184 test(
4185 "providers without a `read` method skip the adoption probe entirely",
4186 Effect.gen(function* () {
4187 // Bucket has no `read` implementation. The engine should fall back
4188 // to a normal `create` action without any side effects.
4189 const plan = yield* makeAdoptPlan(
4190 Effect.gen(function* () {
4191 yield* Bucket("FreshBucket", { name: "fresh" });
4192 }),
4193 { adopt: true },
4194 );
4195
4196 expect(plan.resources.FreshBucket!.action).toBe("create");
4197 }),
4198 );
4199});
4200
4201describe("RefExpr resolution", { tags: ["unit", "local"] }, () => {
4202 const seedAt = (
4203 stack: string,
4204 stage: string,
4205 resources: Record<string, ResourceState>,
4206 ) =>
4207 Effect.gen(function* () {
4208 const state = yield* yield* State;
4209 for (const [fqn, value] of Object.entries(resources)) {
4210 yield* state.set({ stack, stage, fqn, value });
4211 }
4212 });
4213
4214 const sharedAttr = {
4215 string: "shared-string",
4216 stringArray: ["shared"],
4217 stableString: "shared-stable",
4218 stableArray: ["shared-stable"],
4219 replaceString: undefined,
4220 redacted: undefined,
4221 redactedArray: undefined,
4222 };
4223
4224 const sharedResourceState = {
4225 instanceId,
4226 providerVersion: 0,
4227 logicalId: "Shared",
4228 fqn: "Shared",
4229 namespace: undefined,
4230 resourceType: "Test.TestResource",
4231 status: "created" as ResourceStatus,
4232 props: { string: "shared-string" },
4233 attr: sharedAttr,
4234 bindings: [],
4235 downstream: [],
4236 } as ResourceState;
4237
4238 test(
4239 "resolves a cross-stage Ref to the seeded resource's attributes",
4240 Effect.gen(function* () {
4241 yield* seedAt(TEST_STACK, "other", { Shared: sharedResourceState });
4242 const plan = yield* Effect.gen(function* () {
4243 const shared = yield* TestResource.ref("Shared", { stage: "other" });
4244 yield* TestResource("Consumer", { string: shared.string });
4245 }).pipe(makePlan);
4246
4247 expect(plan.resources.Consumer?.action).toBe("create");
4248 expect((plan.resources.Consumer as any)?.props).toMatchObject({
4249 string: "shared-string",
4250 });
4251 }),
4252 );
4253
4254 test(
4255 "resolves a cross-stack Ref using the explicit stack option",
4256 Effect.gen(function* () {
4257 yield* seedAt("other-stack", TEST_STAGE, {
4258 Shared: sharedResourceState,
4259 });
4260 const plan = yield* Effect.gen(function* () {
4261 const shared = yield* TestResource.ref("Shared", {
4262 stack: "other-stack",
4263 });
4264 yield* TestResource("Consumer", {
4265 string: shared.string,
4266 });
4267 }).pipe(makePlan);
4268
4269 expect((plan.resources.Consumer as any)?.props).toMatchObject({
4270 string: "shared-string",
4271 });
4272 }),
4273 );
4274
4275 test(
4276 "Ref to a resource in the current stack/stage is resolved",
4277 Effect.gen(function* () {
4278 yield* seed({ Shared: sharedResourceState });
4279 const plan = yield* Effect.gen(function* () {
4280 const shared = yield* TestResource.ref("Shared");
4281 yield* TestResource("Consumer", { string: shared.string });
4282 }).pipe(makePlan);
4283
4284 expect((plan.resources.Consumer as any)?.props).toMatchObject({
4285 string: "shared-string",
4286 });
4287 }),
4288 );
4289
4290 test(
4291 "missing Ref target dies with InvalidReferenceError",
4292 Effect.gen(function* () {
4293 const exit = yield* Effect.exit(
4294 Effect.gen(function* () {
4295 const shared = yield* TestResource.ref("Ghost", { stage: "other" });
4296 yield* TestResource("Consumer", { string: shared.string });
4297 }).pipe(makePlan),
4298 );
4299 expect(Exit.isFailure(exit)).toBe(true);
4300 if (Exit.isFailure(exit)) {
4301 const err = Cause.squash(exit.cause) as Output.InvalidReferenceError;
4302 expect(err._tag).toBe("InvalidReferenceError");
4303 expect(err.resourceId).toBe("Ghost");
4304 expect(err.stage).toBe("other");
4305 }
4306 }),
4307 );
4308});
4309
4310describe("StackRefExpr resolution", { tags: ["unit", "local"] }, () => {
4311 const setStackOutput = (stack: string, stage: string, value: unknown) =>
4312 Effect.gen(function* () {
4313 const state = yield* yield* State;
4314 yield* state.setOutput({ stack, stage, value });
4315 });
4316
4317 test(
4318 "resolves an Output.stackRef to the persisted stack output",
4319 Effect.gen(function* () {
4320 yield* setStackOutput("Backend", TEST_STAGE, {
4321 url: "https://api.example.com",
4322 });
4323 const plan = yield* Effect.gen(function* () {
4324 const backend = yield* Output.stackRef<{ url: string }>("Backend");
4325 yield* TestResource("Consumer", {
4326 string: (backend as any).url,
4327 });
4328 }).pipe(makePlan);
4329
4330 expect(plan.resources.Consumer?.action).toBe("create");
4331 expect((plan.resources.Consumer as any)?.props).toMatchObject({
4332 string: "https://api.example.com",
4333 });
4334 }),
4335 );
4336
4337 test(
4338 "resolves an explicit stage on the stackRef",
4339 Effect.gen(function* () {
4340 yield* setStackOutput("Backend", "prod", {
4341 url: "https://prod.example.com",
4342 });
4343 const plan = yield* Effect.gen(function* () {
4344 const backend = yield* Output.stackRef<{ url: string }>("Backend", {
4345 stage: "prod",
4346 });
4347 yield* TestResource("Consumer", {
4348 string: (backend as any).url,
4349 });
4350 }).pipe(makePlan);
4351
4352 expect((plan.resources.Consumer as any)?.props).toMatchObject({
4353 string: "https://prod.example.com",
4354 });
4355 }),
4356 );
4357
4358 test(
4359 "missing stack output dies with InvalidReferenceError",
4360 Effect.gen(function* () {
4361 const exit = yield* Effect.exit(
4362 Effect.gen(function* () {
4363 const backend = yield* Output.stackRef<{ url: string }>("Backend", {
4364 stage: "ghost",
4365 });
4366 yield* TestResource("Consumer", {
4367 string: (backend as any).url,
4368 });
4369 }).pipe(makePlan),
4370 );
4371 expect(Exit.isFailure(exit)).toBe(true);
4372 if (Exit.isFailure(exit)) {
4373 const err = Cause.squash(exit.cause) as Output.InvalidReferenceError;
4374 expect(err._tag).toBe("InvalidReferenceError");
4375 expect(err.stack).toBe("Backend");
4376 expect(err.stage).toBe("ghost");
4377 }
4378 }),
4379 );
4380});
4381
4382describe("type aliases", { tags: ["unit", "local"] }, () => {
4383 // State rows persisted before a type rename carry the legacy name
4384 // ("Test.Widget"). Provider lookup must fall back to the canonical type
4385 // ("Test.Widgets.Widget") via the alias declared on the resource.
4386 const legacyWidgetState = (fqn: string): ResourceState => ({
4387 instanceId,
4388 providerVersion: 0,
4389 logicalId: fqn,
4390 fqn,
4391 namespace: undefined,
4392 resourceType: "Test.Widget",
4393 status: "created",
4394 props: {
4395 name: "widget",
4396 },
4397 attr: {
4398 name: "widget",
4399 },
4400 bindings: [],
4401 downstream: [],
4402 });
4403
4404 test(
4405 "orphan persisted under a legacy type name plans a delete via alias",
4406 Effect.gen(function* () {
4407 yield* seed({ LegacyOrphan: legacyWidgetState("LegacyOrphan") });
4408 expect(
4409 yield* makePlan(Effect.void).pipe(
4410 Effect.provide(aliasedWidgetProvider()),
4411 ),
4412 ).toMatchObject({
4413 deletions: {
4414 LegacyOrphan: {
4415 action: "delete",
4416 resource: {
4417 LogicalId: "LegacyOrphan",
4418 Type: "Test.Widget",
4419 },
4420 },
4421 },
4422 });
4423 }),
4424 );
4425
4426 test(
4427 "declared resource with legacy-typed state plans as a noop update",
4428 Effect.gen(function* () {
4429 yield* seed({ MyWidget: legacyWidgetState("MyWidget") });
4430 const plan = yield* makePlan(
4431 Effect.gen(function* () {
4432 yield* AliasedWidget("MyWidget", { name: "widget" });
4433 }),
4434 ).pipe(Effect.provide(aliasedWidgetProvider()));
4435 expect(plan).toMatchObject({
4436 resources: {
4437 MyWidget: {
4438 action: "noop",
4439 state: {
4440 resourceType: "Test.Widget",
4441 },
4442 },
4443 },
4444 });
4445 expect(Object.keys(plan.deletions)).toEqual([]);
4446 }),
4447 );
4448
4449 describe("via provider collection", () => {
4450 class AliasPlanProviders extends Provider.ProviderCollection<AliasPlanProviders>()(
4451 "Test.AliasPlanProviders",
4452 ) {}
4453
4454 // The bare provider layer is consumed while building the collection and
4455 // is NOT exported — lookup can only succeed through the collection.
4456 const widgetCollection = () =>
4457 Layer.effect(
4458 AliasPlanProviders,
4459 Provider.collection([AliasedWidget]),
4460 ).pipe(Layer.provide(aliasedWidgetProvider()));
4461
4462 test(
4463 "orphan persisted under a legacy type name plans a delete via alias",
4464 Effect.gen(function* () {
4465 yield* seed({ LegacyOrphan: legacyWidgetState("LegacyOrphan") });
4466 expect(
4467 yield* makePlan(Effect.void).pipe(Effect.provide(widgetCollection())),
4468 ).toMatchObject({
4469 deletions: {
4470 LegacyOrphan: {
4471 action: "delete",
4472 resource: {
4473 LogicalId: "LegacyOrphan",
4474 Type: "Test.Widget",
4475 },
4476 },
4477 },
4478 });
4479 }),
4480 );
4481 });
4482});
4483
4484describe("zombie rows", { tags: ["unit", "local"] }, () => {
4485 // A state row whose resource type has no registered provider (the type
4486 // was removed from the program, or renamed without an alias) is FATAL:
4487 // the program and state disagree, and without the provider the row's
4488 // physical resource cannot be deleted anyway. Planning dies with a typed
4489 // MissingProviderError naming the row and the remediation (see
4490 // destroy-robustness.test.ts for the deploy/destroy behavior).
4491 test(
4492 "a row whose resource type has no provider fails the plan",
4493 Effect.gen(function* () {
4494 yield* seed({
4495 Ghost: {
4496 instanceId,
4497 providerVersion: 0,
4498 logicalId: "Ghost",
4499 fqn: "Ghost",
4500 namespace: undefined,
4501 resourceType: "Test.Vanished",
4502 status: "created",
4503 props: { name: "ghost" },
4504 attr: { name: "ghost" },
4505 bindings: [],
4506 downstream: [],
4507 },
4508 });
4509 const exit = yield* makePlan(
4510 Effect.gen(function* () {
4511 yield* Bucket("Survivor", { name: "survivor" });
4512 }),
4513 ).pipe(Effect.exit);
4514
4515 expect(Exit.isFailure(exit)).toBe(true);
4516 const defect = Exit.isFailure(exit)
4517 ? exit.cause.reasons.find(
4518 (r) =>
4519 Cause.isDieReason(r) &&
4520 r.defect instanceof Provider.MissingProviderError,
4521 )
4522 : undefined;
4523 expect(defect && Cause.isDieReason(defect)).toBe(true);
4524 if (defect && Cause.isDieReason(defect)) {
4525 const error = defect.defect as Provider.MissingProviderError;
4526 expect(error.resourceType).toBe("Test.Vanished");
4527 expect(error.fqn).toBe("Ghost");
4528 expect(error.message).toContain("aliases");
4529 }
4530 }),
4531 );
4532});
4533
4534describe(
4535 "read is never handed unresolved persisted props",
4536 { tags: ["unit", "local"] },
4537 () => {
4538 // A failed create persists `creating` state carrying the RAW plan-time
4539 // props, which may contain unresolved Output expressions (e.g. a prop
4540 // referencing an upstream resource that was never created). Providers
4541 // derive identity from `olds` inside `read` when `output` is undefined,
4542 // so the engine must skip the read probe entirely rather than hand it
4543 // unresolved exprs (see the isResolved guards in Plan.ts).
4544
4545 const creatingWithUnresolvedProps = (fqn: string): ResourceState => ({
4546 instanceId,
4547 providerVersion: 0,
4548 logicalId: fqn,
4549 fqn,
4550 namespace: undefined,
4551 resourceType: "Test.TestResource",
4552 status: "creating",
4553 props: {
4554 // an unresolved Output expression, exactly as persisted by a create
4555 // that failed before its upstream dependencies resolved
4556 string: Output.literal("unresolved") as any,
4557 },
4558 attr: undefined,
4559 bindings: [],
4560 downstream: [],
4561 });
4562
4563 const trackReads = () => {
4564 const reads: string[] = [];
4565 const layer = Layer.succeed(TestResourceHooks, {
4566 read: (id: string) =>
4567 Effect.sync(() => {
4568 reads.push(id);
4569 return undefined;
4570 }),
4571 });
4572 return { reads, layer };
4573 };
4574
4575 test(
4576 "destroy after failed create with unresolved props skips read and deletes with attr undefined",
4577 Effect.gen(function* () {
4578 yield* seed({ Zombie: creatingWithUnresolvedProps("Zombie") });
4579 const { reads, layer } = trackReads();
4580 const plan = yield* makePlan(Effect.void).pipe(Effect.provide(layer));
4581 expect(reads).not.toContain("Zombie");
4582 expect(plan.deletions.Zombie).toMatchObject({ action: "delete" });
4583 expect((plan.deletions.Zombie as any).state.attr).toBeUndefined();
4584 }),
4585 );
4586
4587 test(
4588 "creating-state recovery with unresolved persisted props skips the read probe and re-drives create",
4589 Effect.gen(function* () {
4590 yield* seed({ Half: creatingWithUnresolvedProps("Half") });
4591 const { reads, layer } = trackReads();
4592 const plan = yield* makePlan(
4593 Effect.gen(function* () {
4594 yield* TestResource("Half", { string: "resolved-now" });
4595 }),
4596 ).pipe(Effect.provide(layer));
4597 expect(reads).not.toContain("Half");
4598 expect(plan.resources.Half!.action).toBe("create");
4599 }),
4600 );
4601
4602 test(
4603 "destroy of a creating row defers read recovery to apply even with resolved props",
4604 Effect.gen(function* () {
4605 // Plan never probes a deleted attr-less row — resolved or not. Apply's
4606 // `deleteResource` owns the authoritative read-then-delete recovery
4607 // (it also covers replaced-chain old generations that never pass
4608 // through plan); see the apply.test.ts destroy-recovery cases.
4609 yield* seed({
4610 Zombie: {
4611 ...creatingWithUnresolvedProps("Zombie"),
4612 props: { string: "resolved" },
4613 },
4614 });
4615 const { reads, layer } = trackReads();
4616 const plan = yield* makePlan(Effect.void).pipe(Effect.provide(layer));
4617 expect(reads).not.toContain("Zombie");
4618 expect(plan.deletions.Zombie).toMatchObject({ action: "delete" });
4619 expect((plan.deletions.Zombie as any).state.attr).toBeUndefined();
4620 }),
4621 );
4622
4623 test(
4624 "a recovery read that crashes degrades to re-driving the create instead of killing the plan",
4625 Effect.gen(function* () {
4626 // Stripped-at-commit props: an unresolved Output persisted as a hole
4627 // still passes `isResolved`, so the read probe DOES run — and a
4628 // provider that dereferences the hole crashes with a defect (e.g. a
4629 // SchemaError deep in its SDK client, see #995). The plan must
4630 // contain the defect to this resource's probe and fall through to
4631 // re-driving the create.
4632 yield* seed({
4633 Half: {
4634 ...creatingWithUnresolvedProps("Half"),
4635 props: { string: undefined } as any,
4636 },
4637 });
4638 const layer = Layer.succeed(TestResourceHooks, {
4639 read: () =>
4640 Effect.die(
4641 new Error("SchemaError: Expected string, got undefined"),
4642 ),
4643 });
4644 const plan = yield* makePlan(
4645 Effect.gen(function* () {
4646 yield* TestResource("Half", { string: "resolved-now" });
4647 }),
4648 ).pipe(Effect.provide(layer));
4649 expect(plan.resources.Half!.action).toBe("create");
4650 }),
4651 );
4652
4653 test(
4654 "resolved persisted creating props still go through read recovery when re-declared (control)",
4655 Effect.gen(function* () {
4656 yield* seed({
4657 Half: {
4658 ...creatingWithUnresolvedProps("Half"),
4659 props: { string: "resolved" },
4660 },
4661 });
4662 const { reads, layer } = trackReads();
4663 const plan = yield* makePlan(
4664 Effect.gen(function* () {
4665 yield* TestResource("Half", { string: "resolved-now" });
4666 }),
4667 ).pipe(Effect.provide(layer));
4668 expect(reads).toContain("Half");
4669 expect(plan.resources.Half!.action).toBe("create");
4670 }),
4671 );
4672 },
4673);
4674
4675describe("provider modes (local ⇄ live)", { tags: ["unit", "local"] }, () => {
4676 // ModalResource registers via `ProviderLayer.dual` with distinct live and
4677 // local implementations. These tests cover the PLAN-level semantics:
4678 // - the resolved mode lands on the plan node (`node.mode`)
4679 // - a persisted mode different from the resolved mode forces a
4680 // REPLACEMENT, overriding whatever the provider diff would say
4681 // - legacy rows (no persisted mode) are assumed live, unless their
4682 // attrs carry the `dev:` identity marker (then local)
4683 // - deletions carry the persisted mode so orphans are torn down by the
4684 // provider that created them
4685 // - a mode-switching upstream invalidates its attrs for downstream diffs
4686
4687 const modalState = (
4688 fqn: string,
4689 overrides?: Partial<ResourceState>,
4690 ): ResourceState =>
4691 ({
4692 instanceId,
4693 providerVersion: 0,
4694 logicalId: fqn,
4695 fqn,
4696 namespace: undefined,
4697 resourceType: "Test.ModalResource",
4698 status: "created",
4699 props: { value: "v1" },
4700 attr: { value: "v1", runtime: "local" },
4701 downstream: [],
4702 bindings: [],
4703 providerMode: "local",
4704 ...overrides,
4705 }) as ResourceState;
4706
4707 test(
4708 "a fresh create resolves the run default (live) onto the node",
4709 Effect.gen(function* () {
4710 const plan = yield* makePlan(ModalResource("A", { value: "v1" }));
4711 expect(plan.resources.A).toMatchObject({
4712 action: "create",
4713 mode: "live",
4714 });
4715 }),
4716 );
4717
4718 test(
4719 "a dev run resolves the local mode onto the node",
4720 Effect.gen(function* () {
4721 const plan = yield* inDev(makePlan(ModalResource("A", { value: "v1" })));
4722 expect(plan.resources.A).toMatchObject({
4723 action: "create",
4724 mode: "local",
4725 });
4726 }),
4727 );
4728
4729 test(
4730 "remote() opts a resource out of local emulation during dev",
4731 Effect.gen(function* () {
4732 const plan = yield* inDev(
4733 makePlan(ModalResource("A", { value: "v1" }).pipe(remote())),
4734 );
4735 expect(plan.resources.A).toMatchObject({
4736 action: "create",
4737 mode: "live",
4738 });
4739 }),
4740 );
4741
4742 test(
4743 "mode-agnostic resources plan with mode undefined even in a dev run",
4744 Effect.gen(function* () {
4745 // A single-implementation provider (no dual registration) satisfies
4746 // any requested mode — constructs that mix emulatable and live-only
4747 // resources just work in dev.
4748 const plan = yield* inDev(
4749 makePlan(
4750 Effect.gen(function* () {
4751 yield* ModalResource("A", { value: "v1" });
4752 yield* TestResource("T", { string: "x" });
4753 }),
4754 ),
4755 );
4756 expect(plan.resources.A!.mode).toBe("local");
4757 expect(plan.resources.T!.mode).toBeUndefined();
4758 }),
4759 );
4760
4761 test(
4762 "a persisted mode different from the resolved mode forces a replacement",
4763 Effect.gen(function* () {
4764 yield* seed({ A: modalState("A") }); // providerMode: "local"
4765
4766 // Identical props — the provider diff would report `noop` — but the
4767 // row was reconciled by the LOCAL provider and this run resolves to
4768 // LIVE, so the plan must replace (create-first).
4769 const plan = yield* makePlan(ModalResource("A", { value: "v1" }));
4770 expect(plan.resources.A).toMatchObject({
4771 action: "replace",
4772 deleteFirst: false,
4773 mode: "live",
4774 });
4775 }),
4776 );
4777
4778 test(
4779 "the same mode plans normally (noop on identical props)",
4780 Effect.gen(function* () {
4781 yield* seed({ A: modalState("A") }); // providerMode: "local"
4782 const plan = yield* inDev(makePlan(ModalResource("A", { value: "v1" })));
4783 expect(plan.resources.A).toMatchObject({ action: "noop" });
4784 }),
4785 );
4786
4787 test(
4788 "switching live → local replaces too",
4789 Effect.gen(function* () {
4790 yield* seed({ A: modalState("A", { providerMode: "live" }) });
4791 const plan = yield* inDev(makePlan(ModalResource("A", { value: "v1" })));
4792 expect(plan.resources.A).toMatchObject({
4793 action: "replace",
4794 mode: "local",
4795 });
4796 }),
4797 );
4798
4799 test(
4800 "a mode switch overrides the provider diff (update would have sufficed)",
4801 Effect.gen(function* () {
4802 yield* seed({ A: modalState("A") }); // providerMode: "local"
4803 // Changed value — same-mode planning would produce `update` — but the
4804 // mode switch escalates to `replace` without consulting the diff.
4805 const plan = yield* makePlan(ModalResource("A", { value: "v2" }));
4806 expect(plan.resources.A).toMatchObject({
4807 action: "replace",
4808 mode: "live",
4809 });
4810 }),
4811 );
4812
4813 test(
4814 "legacy rows without a persisted mode are assumed live",
4815 Effect.gen(function* () {
4816 yield* seed({ A: modalState("A", { providerMode: undefined }) });
4817
4818 // An unstamped row was written by a pre-provider-mode engine (or by
4819 // a provider that only became dual later) — its physical resource is
4820 // LIVE. A deploy (live) run sees no churn; a dev run replaces it
4821 // exactly like a stamped live row. Assuming the run's mode instead
4822 // would silently adopt the deployed live resource as a local
4823 // instance and leak it untracked.
4824 const liveDefault = yield* makePlan(ModalResource("A", { value: "v1" }));
4825 expect(liveDefault.resources.A).toMatchObject({ action: "noop" });
4826
4827 const devRun = yield* inDev(
4828 makePlan(ModalResource("A", { value: "v1" })),
4829 );
4830 expect(devRun.resources.A).toMatchObject({
4831 action: "replace",
4832 mode: "local",
4833 });
4834 }),
4835 );
4836
4837 test(
4838 "a mode-agnostic resource never replaces across dev/deploy runs",
4839 Effect.gen(function* () {
4840 yield* seed({
4841 T: {
4842 instanceId,
4843 providerVersion: 0,
4844 logicalId: "T",
4845 fqn: "T",
4846 namespace: undefined,
4847 resourceType: "Test.TestResource",
4848 status: "created",
4849 props: { string: "x" },
4850 attr: {
4851 string: "x",
4852 stringArray: [],
4853 stableString: "T",
4854 stableArray: ["T"],
4855 },
4856 downstream: [],
4857 bindings: [],
4858 // Mode-agnostic providers never stamp a mode.
4859 providerMode: undefined,
4860 },
4861 });
4862 const plan = yield* inDev(makePlan(TestResource("T", { string: "x" })));
4863 expect(plan.resources.T).toMatchObject({ action: "noop" });
4864 }),
4865 );
4866
4867 test(
4868 "an interrupted create still replaces on a mode switch",
4869 Effect.gen(function* () {
4870 // A `creating` row (attr-less, interrupted) normally re-drives its
4871 // create via the read-recovery branch. When the mode switched, that
4872 // branch is skipped — the other runtime's half-created instance must
4873 // be replaced, not resumed.
4874 yield* seed({
4875 A: modalState("A", {
4876 status: "creating",
4877 attr: undefined,
4878 } as Partial<ResourceState>),
4879 });
4880 const plan = yield* makePlan(ModalResource("A", { value: "v1" }));
4881 expect(plan.resources.A).toMatchObject({
4882 action: "replace",
4883 mode: "live",
4884 });
4885 }),
4886 );
4887
4888 test(
4889 "an interrupted update still replaces on a mode switch",
4890 Effect.gen(function* () {
4891 yield* seed({
4892 A: modalState("A", {
4893 status: "updating",
4894 props: { value: "v2" },
4895 old: {
4896 props: { value: "v1" },
4897 bindings: [],
4898 attr: { value: "v1", runtime: "local" },
4899 },
4900 } as Partial<ResourceState>),
4901 });
4902 // Same-mode planning would resume the interrupted update; a mode
4903 // switch must escalate to a replacement of the other runtime's
4904 // instance instead.
4905 const plan = yield* makePlan(ModalResource("A", { value: "v2" }));
4906 expect(plan.resources.A).toMatchObject({
4907 action: "replace",
4908 mode: "live",
4909 });
4910 }),
4911 );
4912
4913 test(
4914 "an in-flight replacement generation hit by a mode switch restarts a new generation",
4915 Effect.gen(function* () {
4916 // `replacing`: the (local) replacement candidate is still being
4917 // created. A mode switch makes that candidate itself obsolete — the
4918 // plan must mint a NEW outer generation (`restart: true`) rather than
4919 // resuming the local candidate under the live provider.
4920 yield* seed({
4921 A: modalState("A", {
4922 status: "replacing",
4923 attr: undefined,
4924 deleteFirst: false,
4925 old: modalState("A", {
4926 instanceId: "00000000000000000000000000000000",
4927 }),
4928 } as Partial<ResourceState>),
4929 });
4930 const plan = yield* makePlan(ModalResource("A", { value: "v1" }));
4931 expect(plan.resources.A).toMatchObject({
4932 action: "replace",
4933 restart: true,
4934 mode: "live",
4935 });
4936 }),
4937 );
4938
4939 test(
4940 "a completed-but-undrained replacement hit by a mode switch restarts; same mode just drains",
4941 Effect.gen(function* () {
4942 const replaced = (providerMode: "local" | "live") =>
4943 modalState("A", {
4944 status: "replaced",
4945 deleteFirst: false,
4946 providerMode,
4947 old: modalState("A", {
4948 instanceId: "00000000000000000000000000000000",
4949 }),
4950 } as Partial<ResourceState>);
4951
4952 // The live replacement of a LOCAL row completed but its old chain
4953 // hasn't drained. Re-planning in the same (live) mode continues the
4954 // existing generation (no restart — GC just finishes).
4955 yield* seed({ A: replaced("live") });
4956 const sameMode = yield* makePlan(ModalResource("A", { value: "v1" }));
4957 expect(sameMode.resources.A).toMatchObject({ action: "replace" });
4958 expect((sameMode.resources.A as any).restart).toBeUndefined();
4959
4960 // But if the completed replacement was LOCAL and this run resolves
4961 // live, the current "new" generation is itself obsolete — restart.
4962 yield* seed({ A: replaced("local") });
4963 const switched = yield* makePlan(ModalResource("A", { value: "v1" }));
4964 expect(switched.resources.A).toMatchObject({
4965 action: "replace",
4966 restart: true,
4967 mode: "live",
4968 });
4969 }),
4970 );
4971
4972 test(
4973 "orphan deletions carry the persisted mode",
4974 Effect.gen(function* () {
4975 yield* seed({
4976 LocalOrphan: modalState("LocalOrphan"), // providerMode: "local"
4977 LegacyOrphan: modalState("LegacyOrphan", {
4978 providerMode: undefined,
4979 }),
4980 });
4981 const plan = yield* makePlan(Effect.void);
4982 // The Delete node records the mode its provider was resolved for —
4983 // Apply deletes the local orphan with the LOCAL provider even though
4984 // this is a live-default run.
4985 expect(plan.deletions.LocalOrphan).toMatchObject({
4986 action: "delete",
4987 mode: "local",
4988 });
4989 expect(plan.deletions.LegacyOrphan).toMatchObject({ action: "delete" });
4990 expect(plan.deletions.LegacyOrphan!.mode).toBeUndefined();
4991 }),
4992 );
4993
4994 test(
4995 "a mode-switching upstream invalidates its attrs for downstream diffs",
4996 Effect.gen(function* () {
4997 yield* seed({
4998 A: modalState("A", { downstream: ["B"] }), // providerMode: "local"
4999 B: {
5000 instanceId,
5001 providerVersion: 0,
5002 logicalId: "B",
5003 fqn: "B",
5004 namespace: undefined,
5005 resourceType: "Test.TestResource",
5006 status: "created",
5007 props: { string: "v1" },
5008 attr: {
5009 string: "v1",
5010 stringArray: [],
5011 stableString: "B",
5012 stableArray: ["B"],
5013 },
5014 downstream: [],
5015 bindings: [],
5016 },
5017 });
5018
5019 const program = Effect.gen(function* () {
5020 const a = yield* ModalResource("A", { value: "v1" });
5021 yield* TestResource("B", { string: a.value });
5022 });
5023
5024 // Mode switch (local row, deploy plan): A is being replaced, so its
5025 // attrs are NOT stable — B must observe an unresolved upstream and
5026 // plan an update rather than nooping against stale values.
5027 const switched = yield* makePlan(program);
5028 expect(switched.resources.A).toMatchObject({ action: "replace" });
5029 expect(switched.resources.B).toMatchObject({ action: "update" });
5030
5031 // Control — same mode (dev run): A noops, its persisted attrs
5032 // resolve, B noops.
5033 const same = yield* inDev(makePlan(program));
5034 expect(same.resources.A).toMatchObject({ action: "noop" });
5035 expect(same.resources.B).toMatchObject({ action: "noop" });
5036 }),
5037 );
5038});
5039
5040describe(
5041 "binding client data-plane routing (plan)",
5042 { tags: ["unit", "local"] },
5043 () => {
5044 // Binding.Service wraps deploy-time clients so they hit the plane the
5045 // bound resource actually lives on. In a `dev` run ambient is the
5046 // emulator; `Alchemy.remote()` must still wrap with the live layer (the
5047 // inverse of the local wrap). Plan-time `yield* client()` is the same
5048 // routing `Service.execute` uses.
5049
5050 test(
5051 "in a dev run, a local resource's binding client hits the emulator plane",
5052 Effect.gen(function* () {
5053 const plan = yield* inDev(
5054 makePlan(
5055 Effect.gen(function* () {
5056 const a = yield* ModalResource("A", { value: "v1" });
5057 const read = yield* ProbeBinding(a);
5058 return yield* read();
5059 }),
5060 ),
5061 );
5062 expect(plan.output).toBe("local");
5063 }),
5064 );
5065
5066 test(
5067 "in a dev run, a remote() resource's binding client hits the live plane",
5068 Effect.gen(function* () {
5069 const plan = yield* inDev(
5070 makePlan(
5071 Effect.gen(function* () {
5072 const a = yield* ModalResource("A", { value: "v1" }).pipe(
5073 remote(),
5074 );
5075 const read = yield* ProbeBinding(a);
5076 return yield* read();
5077 }),
5078 ),
5079 );
5080 expect(plan.output).toBe("live");
5081 }),
5082 );
5083
5084 test(
5085 "a live-mode run wraps remote/live clients with the live plane (not ambient)",
5086 Effect.gen(function* () {
5087 const plan = yield* makePlan(
5088 Effect.gen(function* () {
5089 const a = yield* ModalResource("A", { value: "v1" });
5090 const read = yield* ProbeBinding(a);
5091 return yield* read();
5092 }),
5093 );
5094 expect(plan.output).toBe("live");
5095 }),
5096 );
5097
5098 test(
5099 "a binding spanning local and remote() resources dies",
5100 Effect.gen(function* () {
5101 const exit = yield* inDev(
5102 makePlan(
5103 Effect.gen(function* () {
5104 const local = yield* ModalResource("A", { value: "v1" });
5105 const live = yield* ModalResource("B", { value: "v1" }).pipe(
5106 remote(),
5107 );
5108 const read = yield* ProbeBinding([local, live]);
5109 return yield* read();
5110 }),
5111 ),
5112 ).pipe(Effect.exit);
5113 expect(Exit.isFailure(exit)).toBe(true);
5114 if (Exit.isFailure(exit)) {
5115 expect(String(Cause.squash(exit.cause))).toContain(
5116 "mixed data planes",
5117 );
5118 }
5119 }),
5120 );
5121 },
5122);
5123
5124// Upstream dependency detection must find a Resource/Output reference at ANY
5125// nesting depth of plain data — objects in arrays, arrays in objects, and
5126// arbitrary mixes (#1082 hardened the walkers with a plain-data gate + cycle
5127// guards; these pin that no nesting shape lost its dependency edge). Each
5128// case plans `A` (upstream) and `B` whose props embed a reference to `A` in a
5129// different shape, then asserts the A→B edge exists in the plan DAG.
5130describe(
5131 "upstream detection across nesting shapes",
5132 { tags: ["unit", "local"] },
5133 () => {
5134 // Each shape gets the raw resource and an attr Output to embed.
5135 const shapes: [name: string, props: (a: any) => Record<string, any>][] = [
5136 ["raw resource at top level", (a) => ({ ref: a })],
5137 ["attr output at top level", (a) => ({ name: a.name })],
5138 ["raw resource in object", (a) => ({ obj: { ref: a } })],
5139 ["attr output in object", (a) => ({ obj: { name: a.name } })],
5140 [
5141 "deeply nested object (4 levels)",
5142 (a) => ({ l1: { l2: { l3: { l4: { name: a.name } } } } }),
5143 ],
5144 ["raw resource in array", (a) => ({ arr: [a] })],
5145 ["attr output in array", (a) => ({ arr: [a.name] })],
5146 [
5147 "output among primitives in array",
5148 (a) => ({ arr: [1, "x", a.name, null, true] }),
5149 ],
5150 ["array in object in array", (a) => ({ arr: [{ inner: [a.name] }] })],
5151 ["object in array in object", (a) => ({ obj: { list: [{ ref: a }] } })],
5152 [
5153 "arrays in objects in arrays in objects",
5154 (a) => ({
5155 layers: [
5156 { config: { hosts: [{ url: a.name }, { url: "static" }] } },
5157 { config: { hosts: [] } },
5158 ],
5159 }),
5160 ],
5161 [
5162 "mixed: raw resource and output at different depths",
5163 (a) => ({
5164 top: a,
5165 nested: { deep: [{ deeper: { name: a.name } }] },
5166 }),
5167 ],
5168 [
5169 "nested empty containers alongside the ref",
5170 (a) => ({
5171 empties: [{}, [], { x: [] }],
5172 ref: { arr: [[a.name]] },
5173 }),
5174 ],
5175 ["array of arrays", (a) => ({ matrix: [[a.name]] })],
5176 ];
5177
5178 for (const [name, props] of shapes) {
5179 test(
5180 `finds the dependency: ${name}`,
5181 Effect.gen(function* () {
5182 const plan = yield* Effect.gen(function* () {
5183 const a = yield* Bucket("A", { name: "nest-a" });
5184 yield* TestResource("B", props(a) as any);
5185 }).pipe(makePlan);
5186
5187 expect(plan.resources.A!.action).toBe("create");
5188 expect(plan.resources.B!.action).toBe("create");
5189 // The dependency edge A -> B must exist regardless of nesting shape.
5190 expect(plan.resources.A!.downstream).toContain("B");
5191 expect(plan.resources.B!.downstream).not.toContain("A");
5192 }),
5193 );
5194 }
5195
5196 test(
5197 "a reference inside a foreign class instance is NOT a dependency",
5198 Effect.gen(function* () {
5199 class SdkConfig {
5200 constructor(readonly ref: any) {}
5201 }
5202 const plan = yield* Effect.gen(function* () {
5203 const a = yield* Bucket("A", { name: "nest-a" });
5204 yield* TestResource("B", { config: new SdkConfig(a.name) } as any);
5205 }).pipe(makePlan);
5206
5207 expect(plan.resources.A!.downstream).not.toContain("B");
5208 }),
5209 );
5210
5211 test(
5212 "cyclic plain objects in props do not hang planning",
5213 Effect.gen(function* () {
5214 const cyclic: any = { name: "cycle" };
5215 cyclic.self = cyclic;
5216 const plan = yield* Effect.gen(function* () {
5217 const a = yield* Bucket("A", { name: "nest-a" });
5218 yield* TestResource("B", { config: cyclic, ref: a.name } as any);
5219 }).pipe(makePlan);
5220
5221 // The cycle is tolerated AND the sibling dependency is still found.
5222 expect(plan.resources.A!.downstream).toContain("B");
5223 }),
5224 );
5225 },
5226);
5227
5228describe("renamed resources (renamedFrom)", { tags: ["unit", "local"] }, () => {
5229 const bucketRow = (
5230 fqn: string,
5231 rowInstanceId: string = instanceId,
5232 ): ResourceState => ({
5233 instanceId: rowInstanceId,
5234 providerVersion: 0,
5235 logicalId: parseFqnLogicalId(fqn),
5236 fqn,
5237 namespace: undefined,
5238 resourceType: "Test.Bucket",
5239 status: "created",
5240 props: { name: "b" },
5241 attr: { name: "b", bucketArn: `arn:test:bucket:${fqn}` },
5242 bindings: [],
5243 downstream: [],
5244 });
5245 const parseFqnLogicalId = (fqn: string) => fqn.split("/").pop()!;
5246
5247 test(
5248 "a row at a former FQN plans as an update at the new FQN, never a create+delete",
5249 Effect.gen(function* () {
5250 yield* seed({ OldBucket: bucketRow("OldBucket") });
5251
5252 const plan = yield* Effect.gen(function* () {
5253 yield* Bucket("NewBucket", { name: "b" }).pipe(
5254 renamedFrom("OldBucket"),
5255 );
5256 return {};
5257 }).pipe(makePlan);
5258
5259 const node = plan.resources.NewBucket!;
5260 // An update, not a noop: the physical resource's tags are still
5261 // branded with the OLD logical id, so a reconcile must run to
5262 // re-brand them under the new identity. Never a create.
5263 expect(node.action).toEqual("update");
5264 expect(node.renamedFrom).toEqual(["OldBucket"]);
5265 // The row rides on the node under its NEW identity (apply persists
5266 // the move before any lifecycle runs).
5267 expect(node.state?.fqn).toEqual("NewBucket");
5268 expect(node.state?.logicalId).toEqual("NewBucket");
5269 expect(node.state?.instanceId).toEqual(instanceId);
5270 // The former row is NOT an orphan.
5271 expect(Object.keys(plan.deletions)).toHaveLength(0);
5272 }),
5273 );
5274
5275 test(
5276 "the alias is ignored when the new FQN already has a row with a different instanceId",
5277 Effect.gen(function* () {
5278 yield* seed({
5279 OldBucket: bucketRow("OldBucket", "0ld00000000000000000000000000000"),
5280 NewBucket: bucketRow("NewBucket"),
5281 });
5282
5283 const plan = yield* Effect.gen(function* () {
5284 yield* Bucket("NewBucket", { name: "b" }).pipe(
5285 renamedFrom("OldBucket"),
5286 );
5287 return {};
5288 }).pipe(makePlan);
5289
5290 // The declared resource plans from its OWN row...
5291 const node = plan.resources.NewBucket!;
5292 expect(node.action).toEqual("noop");
5293 expect(node.renamedFrom).toBeUndefined();
5294 expect(node.state?.instanceId).toEqual(instanceId);
5295 // ...and the former row is a distinct resource: a normal orphan.
5296 expect(plan.deletions.OldBucket?.action).toEqual("delete");
5297 }),
5298 );
5299
5300 test(
5301 "rows at both FQNs with the same instanceId are an in-flight migration, not an orphan",
5302 Effect.gen(function* () {
5303 // Simulates a crash between apply's `state.set` (new FQN) and
5304 // `state.delete` (former FQN).
5305 yield* seed({
5306 OldBucket: bucketRow("OldBucket"),
5307 NewBucket: bucketRow("NewBucket"),
5308 });
5309
5310 const plan = yield* Effect.gen(function* () {
5311 yield* Bucket("NewBucket", { name: "b" }).pipe(
5312 renamedFrom("OldBucket"),
5313 );
5314 return {};
5315 }).pipe(makePlan);
5316
5317 // The node plans from the new row and marks the leftover for
5318 // state-only cleanup at apply; no delete of the physical resource.
5319 const node = plan.resources.NewBucket!;
5320 expect(node.action).toEqual("noop");
5321 expect(node.renamedFrom).toEqual(["OldBucket"]);
5322 expect(Object.keys(plan.deletions)).toHaveLength(0);
5323 }),
5324 );
5325
5326 test(
5327 "the old id can be reused by a new resource in the same deploy",
5328 Effect.gen(function* () {
5329 yield* seed({ OldBucket: bucketRow("OldBucket") });
5330
5331 const plan = yield* Effect.gen(function* () {
5332 // A brand-new resource reuses the old id...
5333 yield* Bucket("OldBucket", { name: "fresh" });
5334 // ...while the original resource (which owns the row) renames.
5335 yield* Bucket("NewBucket", { name: "b" }).pipe(
5336 renamedFrom("OldBucket"),
5337 );
5338 return {};
5339 }).pipe(makePlan);
5340
5341 // The rename claim wins the row: `NewBucket` migrates it...
5342 const renamed = plan.resources.NewBucket!;
5343 expect(renamed.action).toEqual("update");
5344 expect(renamed.renamedFrom).toEqual(["OldBucket"]);
5345 expect(renamed.state?.instanceId).toEqual(instanceId);
5346 // ...and the reusing resource starts from scratch — it must NOT
5347 // inherit the migrated resource's row (or physical resource).
5348 const reuser = plan.resources.OldBucket!;
5349 expect(reuser.action).toEqual("create");
5350 expect(reuser.state).toBeUndefined();
5351 expect(Object.keys(plan.deletions)).toHaveLength(0);
5352 }),
5353 );
5354
5355 test(
5356 "former ids resolve against the ambient namespace (StaticSite's <id>/Worker → <id>)",
5357 Effect.gen(function* () {
5358 // The pre-rename shape: a `Worker` resource declared under the
5359 // `App/Site` namespace chain.
5360 yield* seed({
5361 "App/Site/Worker": {
5362 ...bucketRow("App/Site/Worker"),
5363 namespace: { Id: "Site", Parent: { Id: "App" } },
5364 },
5365 });
5366
5367 const plan = yield* Effect.gen(function* () {
5368 // The post-rename shape: the resource is `Site` itself, declared
5369 // inside the same ambient namespace and claiming its former
5370 // namespace-RELATIVE id — `renamedFrom("Site/Worker")` resolves to
5371 // `App/Site/Worker` under `Namespace.push("App")`.
5372 yield* Bucket("Site", { name: "b" }).pipe(
5373 renamedFrom("Site/Worker"),
5374 Namespace.push("App"),
5375 );
5376 return {};
5377 }).pipe(makePlan);
5378
5379 const node = plan.resources["App/Site"]!;
5380 expect(node.action).toEqual("update");
5381 expect(node.renamedFrom).toEqual(["App/Site/Worker"]);
5382 expect(node.state?.fqn).toEqual("App/Site");
5383 expect(node.state?.namespace).toEqual({ Id: "App" });
5384 expect(Object.keys(plan.deletions)).toHaveLength(0);
5385 }),
5386 );
5387
5388 test(
5389 "the absolute { fqn } form claims a former FQN across namespaces",
5390 Effect.gen(function* () {
5391 // The resource used to live at the ROOT of the stack; it moved into
5392 // a namespace. A relative former id cannot express that (it would
5393 // resolve inside the new namespace), so the absolute form is used.
5394 yield* seed({ Thing: bucketRow("Thing") });
5395
5396 const plan = yield* Effect.gen(function* () {
5397 yield* Bucket("Thing", { name: "b" }).pipe(
5398 renamedFrom({ fqn: "Thing" }),
5399 Namespace.push("New"),
5400 );
5401 return {};
5402 }).pipe(makePlan);
5403
5404 const node = plan.resources["New/Thing"]!;
5405 expect(node.action).toEqual("update");
5406 expect(node.renamedFrom).toEqual(["Thing"]);
5407 expect(node.state?.fqn).toEqual("New/Thing");
5408 expect(node.state?.instanceId).toEqual(instanceId);
5409 expect(Object.keys(plan.deletions)).toHaveLength(0);
5410 }),
5411 );
5412
5413 test(
5414 "a rename chain with several same-instanceId leftovers is cleaned in one plan",
5415 Effect.gen(function* () {
5416 // A → B → C rename history with repeated partial failures: rows
5417 // linger at BOTH former FQNs, all copies of the same row (migration
5418 // preserves the instanceId).
5419 yield* seed({
5420 OldA: bucketRow("OldA"),
5421 OldB: bucketRow("OldB"),
5422 });
5423
5424 const plan = yield* Effect.gen(function* () {
5425 // Most recent former id first.
5426 yield* Bucket("NewBucket", { name: "b" }).pipe(
5427 renamedFrom("OldA", "OldB"),
5428 );
5429 return {};
5430 }).pipe(makePlan);
5431
5432 const node = plan.resources.NewBucket!;
5433 expect(node.action).toEqual("update");
5434 // The migration source AND the same-instance leftover are both
5435 // collected — one apply drops them all.
5436 expect(node.renamedFrom).toEqual(["OldA", "OldB"]);
5437 expect(node.state?.instanceId).toEqual(instanceId);
5438 expect(Object.keys(plan.deletions)).toHaveLength(0);
5439 }),
5440 );
5441
5442 test(
5443 "a foreign row at a later former FQN is orphan-deleted, not adopted",
5444 Effect.gen(function* () {
5445 // `OldA` is the real predecessor; `OldB` is someone else's row
5446 // (different instanceId) that happens to sit at an older former FQN.
5447 yield* seed({
5448 OldA: bucketRow("OldA"),
5449 OldB: bucketRow("OldB", "f0re1gn0000000000000000000000000"),
5450 });
5451
5452 const plan = yield* Effect.gen(function* () {
5453 yield* Bucket("NewBucket", { name: "b" }).pipe(
5454 renamedFrom("OldA", "OldB"),
5455 );
5456 return {};
5457 }).pipe(makePlan);
5458
5459 const node = plan.resources.NewBucket!;
5460 expect(node.action).toEqual("update");
5461 expect(node.renamedFrom).toEqual(["OldA"]);
5462 expect(node.state?.instanceId).toEqual(instanceId);
5463 // The foreign row is a normal orphan — deleted in the SAME plan (it
5464 // never enters the migrated set, so the in-memory migration doesn't
5465 // shield it).
5466 expect(plan.deletions.OldB?.action).toEqual("delete");
5467 expect(plan.deletions.OldA).toBeUndefined();
5468 }),
5469 );
5470
5471 test(
5472 "a former row with a different resourceType is never migrated",
5473 Effect.gen(function* () {
5474 // The row at the former FQN was written by a DIFFERENT resource type
5475 // — it cannot be this resource's row, whatever its FQN says.
5476 yield* seed({
5477 OldBucket: {
5478 ...bucketRow("OldBucket"),
5479 resourceType: "Test.Queue",
5480 attr: { name: "b", queueUrl: "https://test.queue.com/b" },
5481 },
5482 });
5483
5484 const plan = yield* Effect.gen(function* () {
5485 yield* Bucket("NewBucket", { name: "b" }).pipe(
5486 renamedFrom("OldBucket"),
5487 );
5488 return {};
5489 }).pipe(makePlan);
5490
5491 // Fresh create; the type-mismatched row is a normal orphan.
5492 const node = plan.resources.NewBucket!;
5493 expect(node.action).toEqual("create");
5494 expect(node.renamedFrom).toBeUndefined();
5495 expect(plan.deletions.OldBucket?.action).toEqual("delete");
5496 }),
5497 );
5498
5499 test(
5500 "a foreign-typed row at the NEW FQN blocks the migration loudly",
5501 Effect.gen(function* () {
5502 // A different resource type's row occupies `NewBucket`. Migrating
5503 // over it would silently abandon that row's cloud resource, so the
5504 // plan fails with a clear remediation instead.
5505 yield* seed({
5506 NewBucket: {
5507 ...bucketRow("NewBucket", "f0re1gn0000000000000000000000000"),
5508 resourceType: "Test.Queue",
5509 attr: { name: "q", queueUrl: "https://test.queue.com/q" },
5510 },
5511 OldBucket: bucketRow("OldBucket"),
5512 });
5513
5514 const exit = yield* Effect.gen(function* () {
5515 yield* Bucket("NewBucket", { name: "b" }).pipe(
5516 renamedFrom("OldBucket"),
5517 );
5518 return {};
5519 }).pipe(makePlan, Effect.exit);
5520
5521 expect(Exit.isFailure(exit)).toBe(true);
5522 if (Exit.isFailure(exit)) {
5523 const die = exit.cause.reasons.find(Cause.isDieReason);
5524 expect(String(die?.defect)).toContain(
5525 "a state row of a different type ('Test.Queue') already occupies 'NewBucket'",
5526 );
5527 }
5528 }),
5529 );
5530
5531 test(
5532 "a mid-replacement row migrates with its old-generation chain intact",
5533 Effect.gen(function* () {
5534 // The row is in `replaced` status: the new generation is live and
5535 // the old generation is queued for garbage collection. The rename
5536 // must carry the whole row — chain included — so GC still drains it.
5537 yield* seed({
5538 OldBucket: {
5539 ...bucketRow("OldBucket"),
5540 status: "replaced",
5541 deleteFirst: false,
5542 old: {
5543 ...bucketRow("OldBucket", "01d6e7000000000000000000000000000"),
5544 status: "created",
5545 },
5546 } as ResourceState,
5547 });
5548
5549 const plan = yield* Effect.gen(function* () {
5550 yield* Bucket("NewBucket", { name: "b" }).pipe(
5551 renamedFrom("OldBucket"),
5552 );
5553 return {};
5554 }).pipe(makePlan);
5555
5556 const node = plan.resources.NewBucket!;
5557 expect(node.renamedFrom).toEqual(["OldBucket"]);
5558 expect(node.state?.fqn).toEqual("NewBucket");
5559 expect(node.state?.instanceId).toEqual(instanceId);
5560 // The replacement backlog rides the migration.
5561 expect((node.state as any).old?.instanceId).toEqual(
5562 "01d6e7000000000000000000000000000",
5563 );
5564 expect(Object.keys(plan.deletions)).toHaveLength(0);
5565 }),
5566 );
5567
5568 test(
5569 "renamedFrom on a fresh resource (no rows anywhere) is inert",
5570 Effect.gen(function* () {
5571 // Every new StaticSite carries `renamedFrom(`${id}/Worker`)` forever,
5572 // so a green-field deploy must behave exactly as if the decoration
5573 // were absent: a plain create — and the cold-start adoption probe
5574 // still runs (probe suppression only applies while a row is actually
5575 // migrating away).
5576 const reads: string[] = [];
5577 const plan = yield* Effect.gen(function* () {
5578 yield* TestResource("New", { string: "v" }).pipe(renamedFrom("Old"));
5579 return {};
5580 }).pipe(
5581 makePlan,
5582 Effect.provide(
5583 Layer.succeed(TestResourceHooks, {
5584 read: (id: string) =>
5585 Effect.sync(() => {
5586 reads.push(id);
5587 return undefined;
5588 }),
5589 }),
5590 ),
5591 );
5592
5593 const node = plan.resources.New!;
5594 expect(node.action).toEqual("create");
5595 expect(node.renamedFrom).toBeUndefined();
5596 expect(node.state).toBeUndefined();
5597 // The state-loss recovery probe still ran.
5598 expect(reads).toEqual(["New"]);
5599 expect(Object.keys(plan.deletions)).toHaveLength(0);
5600 }),
5601 );
5602
5603 test(
5604 "a rename combined with a replacement-triggering change plans a replace carrying the rename",
5605 Effect.gen(function* () {
5606 yield* seed({
5607 Old: {
5608 instanceId,
5609 providerVersion: 0,
5610 logicalId: "Old",
5611 fqn: "Old",
5612 namespace: undefined,
5613 resourceType: "Test.TestResource",
5614 status: "created",
5615 props: { string: "v", replaceString: "a" },
5616 attr: { string: "v", replaceString: "a" } as any,
5617 bindings: [],
5618 downstream: [],
5619 },
5620 });
5621
5622 const plan = yield* Effect.gen(function* () {
5623 yield* TestResource("New", {
5624 string: "v",
5625 replaceString: "b",
5626 }).pipe(renamedFrom("Old"));
5627 return {};
5628 }).pipe(makePlan);
5629
5630 // The replacement wins the action; the rename rides along so apply
5631 // still moves the row (and the old-generation delete include the
5632 // migrated attrs under the new FQN).
5633 const node = plan.resources.New!;
5634 expect(node.action).toEqual("replace");
5635 expect(node.renamedFrom).toEqual(["Old"]);
5636 expect(node.state?.instanceId).toEqual(instanceId);
5637 expect(Object.keys(plan.deletions)).toHaveLength(0);
5638 }),
5639 );
5640
5641 test(
5642 "an interrupted-create row migrates and resumes the create under the new FQN",
5643 Effect.gen(function* () {
5644 // The pre-rename deploy crashed mid-create: the row is `creating`.
5645 yield* seed({
5646 OldBucket: {
5647 ...bucketRow("OldBucket"),
5648 status: "creating",
5649 } as ResourceState,
5650 });
5651
5652 const plan = yield* Effect.gen(function* () {
5653 yield* Bucket("NewBucket", { name: "b" }).pipe(
5654 renamedFrom("OldBucket"),
5655 );
5656 return {};
5657 }).pipe(makePlan);
5658
5659 // Create resumes with the SAME instanceId under the new identity —
5660 // deterministic physical names regenerate identically, so the
5661 // half-created cloud resource is found rather than duplicated.
5662 const node = plan.resources.NewBucket!;
5663 expect(node.action).toEqual("create");
5664 expect(node.renamedFrom).toEqual(["OldBucket"]);
5665 expect(node.state?.instanceId).toEqual(instanceId);
5666 expect(node.state?.fqn).toEqual("NewBucket");
5667 expect(Object.keys(plan.deletions)).toHaveLength(0);
5668 }),
5669 );
5670
5671 test(
5672 "a duplicated former id is collected once",
5673 Effect.gen(function* () {
5674 yield* seed({ OldBucket: bucketRow("OldBucket") });
5675
5676 const plan = yield* Effect.gen(function* () {
5677 yield* Bucket("NewBucket", { name: "b" }).pipe(
5678 renamedFrom("OldBucket", { fqn: "OldBucket" }),
5679 );
5680 return {};
5681 }).pipe(makePlan);
5682
5683 expect(plan.resources.NewBucket?.renamedFrom).toEqual(["OldBucket"]);
5684 }),
5685 );
5686
5687 test(
5688 "a same-deploy rename shift (A→B while B→C) migrates both rows",
5689 Effect.gen(function* () {
5690 // Two existing resources shift names in ONE deploy: the resource at
5691 // `A` becomes `B`, and the resource at `B` becomes `C`. Each row
5692 // must follow ITS resource — B's resolution may not treat the row
5693 // at `B` as its own, because C is claiming it.
5694 const instanceB = "b0000000000000000000000000000000";
5695 yield* seed({
5696 A: bucketRow("A"),
5697 B: bucketRow("B", instanceB),
5698 });
5699
5700 const plan = yield* Effect.gen(function* () {
5701 yield* Bucket("B", { name: "b" }).pipe(renamedFrom("A"));
5702 yield* Bucket("C", { name: "b" }).pipe(renamedFrom("B"));
5703 return {};
5704 }).pipe(makePlan);
5705
5706 // C took B's row...
5707 const c = plan.resources.C!;
5708 expect(c.action).toEqual("update");
5709 expect(c.renamedFrom).toEqual(["B"]);
5710 expect(c.state?.instanceId).toEqual(instanceB);
5711 // ...so B falls back to A's row (never a fresh create)...
5712 const b = plan.resources.B!;
5713 expect(b.action).toEqual("update");
5714 expect(b.renamedFrom).toEqual(["A"]);
5715 expect(b.state?.instanceId).toEqual(instanceId);
5716 // ...and nothing is deleted.
5717 expect(Object.keys(plan.deletions)).toHaveLength(0);
5718 }),
5719 );
5720
5721 test(
5722 "a same-deploy rename swap (A⇄B) fails the plan loudly",
5723 Effect.gen(function* () {
5724 // Swapping two live resources' ids cannot be persisted safely (the
5725 // two migrations would set and delete each other's rows); it must
5726 // die as a rename cycle, never silently half-apply.
5727 yield* seed({
5728 A: bucketRow("A"),
5729 B: bucketRow("B", "b0000000000000000000000000000000"),
5730 });
5731
5732 const exit = yield* Effect.gen(function* () {
5733 yield* Bucket("A", { name: "b" }).pipe(renamedFrom("B"));
5734 yield* Bucket("B", { name: "b" }).pipe(renamedFrom("A"));
5735 return {};
5736 }).pipe(makePlan, Effect.exit);
5737
5738 expect(Exit.isFailure(exit)).toBe(true);
5739 if (Exit.isFailure(exit)) {
5740 const die = exit.cause.reasons.find(Cause.isDieReason);
5741 expect(String(die?.defect)).toContain("cycle");
5742 }
5743 }),
5744 );
5745
5746 test(
5747 "two resources claiming the same former FQN fail the plan loudly",
5748 Effect.gen(function* () {
5749 const exit = yield* Effect.gen(function* () {
5750 yield* Bucket("A", { name: "a" }).pipe(renamedFrom("Shared"));
5751 yield* Bucket("B", { name: "b" }).pipe(renamedFrom("Shared"));
5752 return {};
5753 }).pipe(makePlan, Effect.exit);
5754
5755 expect(Exit.isFailure(exit)).toBe(true);
5756 if (Exit.isFailure(exit)) {
5757 const die = exit.cause.reasons.find(Cause.isDieReason);
5758 expect(String(die?.defect)).toContain("both claim former FQN 'Shared'");
5759 }
5760 }),
5761 );
5762});
5763
5764describe("filtered planning", { tags: ["unit", "local"] }, () => {
5765 for (const keeper of ["new", "unbound", "bound"] as const) {
5766 test.provider(
5767 `requires durable binding evidence from a ${keeper} keeper`,
5768 (stack) =>
5769 Effect.gen(function* () {
5770 yield* stack.destroy();
5771 const program = (clear: boolean) =>
5772 Effect.gen(function* () {
5773 const a = yield* BindingTarget("A", {});
5774 const middle = yield* BindingTarget("Middle", {});
5775 if (!clear) {
5776 yield* a.bind("Middle", { env: { MIDDLE: middle.name } });
5777 yield* middle.bind("A", { env: { SOURCE: a.string } });
5778 }
5779 if (clear || keeper !== "new") {
5780 const keep = yield* BindingTarget("Keeper", {
5781 string: clear ? "new" : "old",
5782 });
5783 if (clear || keeper === "bound")
5784 yield* keep.bind("constant", { env: { VALUE: "kept" } });
5785 }
5786 yield* TestResource("B", {});
5787 });
5788 yield* stack.deploy(program(false));
5789 const result = yield* stack
5790 .plan(program(true), { include: ["A", "Middle", "Keeper"] })
5791 .pipe(Effect.exit);
5792 if (keeper === "bound") {
5793 if (Exit.isFailure(result))
5794 return yield* Effect.failCause(result.cause);
5795 expect(result.value.resources.Keeper.action).toBe("update");
5796 } else {
5797 expect(Exit.isFailure(result)).toBe(true);
5798 if (Exit.isFailure(result))
5799 expect(Cause.pretty(result.cause)).toContain(
5800 "last historical binding evidence",
5801 );
5802 }
5803 yield* stack.plan(program(true));
5804 yield* stack.plan(program(true), {
5805 include: ["A", "Middle", "Keeper", "B"],
5806 });
5807 yield* stack.destroy();
5808 }),
5809 );
5810 }
5811
5812 for (const incomplete of [
5813 "resource",
5814 "action",
5815 "updating history",
5816 ] as const) {
5817 test.provider(
5818 `refuses filtered reconciliation of incomplete ${incomplete} metadata`,
5819 (stack) =>
5820 Effect.gen(function* () {
5821 yield* stack.destroy();
5822 const Compute = Action("A", (input: { value: string }) =>
5823 Effect.succeed(input.value),
5824 );
5825 const program = (value: string) =>
5826 Effect.gen(function* () {
5827 if (incomplete === "action") yield* Compute({ value });
5828 else yield* TestResource("A", { string: value });
5829 yield* TestResource("B", {});
5830 });
5831 yield* stack.deploy(program("old"));
5832 if (incomplete === "updating history") {
5833 yield* stack.deploy(program("new")).pipe(
5834 Effect.provideService(TestResourceHooks, {
5835 update: () => Effect.die("interrupted update"),
5836 }),
5837 Effect.exit,
5838 );
5839 }
5840 const state = yield* yield* State;
5841 const key = { stack: stack.name, stage: stack.stage, fqn: "A" };
5842 const row = yield* state.get(key);
5843 if (!row) return yield* Effect.die("Expected persisted A");
5844 const legacy = { ...row };
5845 if (incomplete === "updating history") {
5846 if (legacy.kind === "action" || legacy.status !== "updating")
5847 return yield* Effect.die("Expected interrupted A update");
5848 const old = { ...legacy.old };
5849 legacy.old = old;
5850 yield* Effect.sync(() => Reflect.deleteProperty(old, "downstream"));
5851 } else
5852 yield* Effect.sync(() =>
5853 Reflect.deleteProperty(legacy, "downstream"),
5854 );
5855 yield* state.set({ ...key, value: legacy });
5856 const result = yield* stack
5857 .plan(program("new"), { include: ["A"], force: true })
5858 .pipe(Effect.exit);
5859 expect(Exit.isFailure(result)).toBe(true);
5860 if (Exit.isFailure(result))
5861 expect(Cause.pretty(result.cause)).toContain(
5862 "incomplete historical downstream metadata",
5863 );
5864 expect(yield* state.get(key)).toEqual(legacy);
5865 yield* stack.plan(program("new"), { include: ["A", "B"] });
5866 yield* stack.plan(program("new"));
5867 yield* stack.plan(program("new"), { include: ["B"], force: true });
5868 yield* stack.deploy(program("new"), { force: true });
5869 yield* stack.destroy();
5870 }),
5871 );
5872 }
5873
5874 test(
5875 "preserves exact direct and higher-order plan output types",
5876 Effect.sync(() => {
5877 type Equal<A, B> =
5878 (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
5879 ? true
5880 : false;
5881 type Output = { value: string };
5882 const stack: Stack.StackSpec<Output> = {
5883 name: "Inference",
5884 stage: "test",
5885 resources: {},
5886 bindings: {},
5887 actions: {},
5888 output: { value: "value" },
5889 };
5890 const direct = Plan.make(stack);
5891 const piped = Effect.succeed(stack).pipe(Effect.flatMap(Plan.make));
5892 const forced = Plan.make(stack, { force: true });
5893 const explicit = Plan.make<Output>(stack, { force: true });
5894 const filtered = Plan.make(stack, { include: ["Selected"] });
5895 const optional = (options: Plan.FilteredPlanOptions) =>
5896 Plan.make(stack, options);
5897 const optionalArgument = (options?: Plan.FilteredPlanOptions) =>
5898 Plan.make(stack, options);
5899 const assertions: [
5900 Equal<Effect.Success<typeof direct>["output"], Output>,
5901 Equal<Effect.Success<typeof piped>["output"], Output>,
5902 Equal<Effect.Success<typeof forced>["output"], Output>,
5903 Equal<Effect.Success<typeof explicit>["output"], Output>,
5904 Equal<Effect.Success<typeof filtered>["output"], undefined>,
5905 Equal<
5906 Effect.Success<ReturnType<typeof optional>>["output"],
5907 Output | undefined
5908 >,
5909 Equal<
5910 Effect.Success<ReturnType<typeof optionalArgument>>["output"],
5911 Output | undefined
5912 >,
5913 ] = [true, true, true, true, true, true, true];
5914 expect(assertions.every(Boolean)).toBe(true);
5915 }),
5916 );
5917
5918 test(
5919 "legacy annotated plan options stay full while optional filters stay optional",
5920 Effect.sync(() => {
5921 type Equal<A, B> =
5922 (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
5923 ? true
5924 : false;
5925 type Output = { value: string };
5926 const stack: Stack.StackSpec<Output> = {
5927 name: "Types",
5928 stage: "test",
5929 resources: {},
5930 bindings: {},
5931 actions: {},
5932 output: { value: "value" },
5933 };
5934 const options: Plan.MakePlanOptions = { force: true };
5935 const legacy = Plan.make(stack, options);
5936 const optionalLegacy = (options?: Plan.MakePlanOptions) =>
5937 Plan.make(stack, options);
5938 const excluded = Plan.make(stack, { exclude: ["Other"] });
5939 const both = Plan.make(stack, { include: ["One"], exclude: ["Other"] });
5940 const absent = Plan.make(stack, {
5941 include: undefined,
5942 exclude: undefined,
5943 });
5944 const optionalInclude = (options: { include?: ReadonlyArray<string> }) =>
5945 Plan.make(stack, options);
5946 const optionalExclude = (options: { exclude?: ReadonlyArray<string> }) =>
5947 Plan.make(stack, options);
5948 const piped = Effect.succeed(stack).pipe(
5949 Effect.flatMap((stack) => Plan.make(stack, options)),
5950 );
5951 const filteredPiped = Effect.succeed(stack).pipe(
5952 Effect.flatMap((stack) => Plan.make(stack, { exclude: ["Other"] })),
5953 );
5954 const assertions: [
5955 Equal<Effect.Success<typeof legacy>["output"], Output>,
5956 Equal<
5957 Effect.Success<ReturnType<typeof optionalLegacy>>["output"],
5958 Output
5959 >,
5960 Equal<Effect.Success<typeof excluded>["output"], undefined>,
5961 Equal<Effect.Success<typeof both>["output"], undefined>,
5962 Equal<Effect.Success<typeof absent>["output"], Output>,
5963 Equal<
5964 Effect.Success<ReturnType<typeof optionalInclude>>["output"],
5965 Output | undefined
5966 >,
5967 Equal<
5968 Effect.Success<ReturnType<typeof optionalExclude>>["output"],
5969 Output | undefined
5970 >,
5971 Equal<Effect.Success<typeof piped>["output"], Output>,
5972 Equal<Effect.Success<typeof filteredPiped>["output"], undefined>,
5973 ] = [true, true, true, true, true, true, true, true, true];
5974 expect(assertions.every(Boolean)).toBe(true);
5975 }),
5976 );
5977
5978 test(
5979 "full plan options cannot erase known or optional filters",
5980 Effect.sync(() => {
5981 type Assignable<A, B> = [A] extends [B] ? true : false;
5982 type Base = Omit<Plan.MakePlanOptions, "include" | "exclude">;
5983 const assignable: [
5984 Assignable<
5985 Base & { include: ReadonlyArray<string> },
5986 Plan.MakePlanOptions
5987 >,
5988 Assignable<
5989 Base & { exclude: ReadonlyArray<string> },
5990 Plan.MakePlanOptions
5991 >,
5992 Assignable<
5993 Base & { include?: ReadonlyArray<string> },
5994 Plan.MakePlanOptions
5995 >,
5996 Assignable<
5997 Base & { exclude?: ReadonlyArray<string> },
5998 Plan.MakePlanOptions
5999 >,
6000 Assignable<Plan.FilteredPlanOptions, Plan.MakePlanOptions>,
6001 ] = [false, false, false, false, false];
6002 expect(assignable.some(Boolean)).toBe(false);
6003 }),
6004 );
6005
6006 const namespaced = Effect.gen(function* () {
6007 yield* TestResource("Branch", {}).pipe(Namespace.push("One"));
6008 yield* TestResource("Branch", {}).pipe(Namespace.push("Two"));
6009 yield* TestResource("Password", {});
6010 });
6011
6012 for (const include of [[], [""], [" "], ["Missing"], ["Branch"]]) {
6013 test(
6014 `rejects invalid selectors ${JSON.stringify(include)} before provider reads`,
6015 Effect.gen(function* () {
6016 const reads: string[] = [];
6017 const exit = yield* makePlan(namespaced, { include }).pipe(
6018 Effect.provideService(TestResourceHooks, {
6019 read: (id) =>
6020 Effect.sync(() => {
6021 reads.push(id);
6022 return undefined;
6023 }),
6024 }),
6025 Effect.exit,
6026 );
6027 expect(Exit.isFailure(exit)).toBe(true);
6028 if (Exit.isFailure(exit)) {
6029 const message = Cause.pretty(exit.cause);
6030 expect(message).toContain("InvalidResourceSelection");
6031 expect(message).toContain("One/Branch");
6032 expect(message).toContain("Two/Branch");
6033 expect(message).toContain("Password");
6034 }
6035 expect(reads).toEqual([]);
6036 }),
6037 );
6038 }
6039
6040 test(
6041 "accepts exact FQNs and deduplicates selectors",
6042 Effect.gen(function* () {
6043 const plan = yield* makePlan(namespaced, {
6044 include: ["One/Branch", "Password", "One/Branch"],
6045 });
6046 expect(Object.keys(plan.resources).sort()).toEqual([
6047 "One/Branch",
6048 "Password",
6049 ]);
6050 expect(plan.output).toBeUndefined();
6051 expect(plan.deletions).toEqual({});
6052 }),
6053 );
6054
6055 test(
6056 "does not resolve unselected missing providers or remote credential demands",
6057 Effect.gen(function* () {
6058 interface Missing extends Resource<
6059 "Missing.Provider",
6060 {},
6061 { value: string }
6062 > {}
6063 const Missing = Resource<Missing>("Missing.Provider");
6064 const program = Effect.gen(function* () {
6065 yield* Missing("Missing", {});
6066 yield* ModalResource("Remote", { value: "remote" }).pipe(remote());
6067 yield* TestResource("Selected", {});
6068 });
6069 const auth = {
6070 get Test(): never {
6071 throw new Error("unselected credentials demanded");
6072 },
6073 };
6074 const plan = yield* inDev(
6075 makePlan(program, { include: ["Selected"] }),
6076 ).pipe(Effect.provideService(AuthProviders, auth));
6077 expect(Object.keys(plan.resources)).toEqual(["Selected"]);
6078 const demanded = yield* inDev(
6079 makePlan(ModalResource("Remote", { value: "remote" }).pipe(remote())),
6080 ).pipe(Effect.provideService(AuthProviders, auth), Effect.exit);
6081 expect(Exit.isFailure(demanded)).toBe(true);
6082 if (Exit.isFailure(demanded)) {
6083 expect(Cause.pretty(demanded.cause)).toContain(
6084 "unselected credentials demanded",
6085 );
6086 }
6087 }),
6088 );
6089
6090 test(
6091 "closes resource props, binding, Action input and captured dependencies",
6092 Effect.gen(function* () {
6093 const program = Effect.gen(function* () {
6094 const source = yield* TestResource("Source", {});
6095 const captured = yield* TestResource("Captured", {});
6096 const Compute = Action(
6097 "Compute",
6098 Effect.gen(function* () {
6099 const value = yield* captured.string;
6100 return (input: { source: string }) =>
6101 Effect.map(value, (resolved) => ({
6102 value: `${input.source}:${resolved}`,
6103 }));
6104 }),
6105 );
6106 const result = yield* Compute({ source: source.string });
6107 const host = yield* BindingTarget("Host", {});
6108 yield* host.bind("Result", { env: { RESULT: result.value } });
6109 const consumer = yield* TestResource("Consumer", {
6110 string: host.string,
6111 });
6112 yield* TestResource("Unselected", {});
6113 return consumer.string;
6114 });
6115 const plan = yield* makePlan(program, { include: ["Consumer"] });
6116 expect(Object.keys(plan.resources).sort()).toEqual([
6117 "Captured",
6118 "Consumer",
6119 "Host",
6120 "Source",
6121 ]);
6122 expect(Object.keys(plan.actions)).toEqual(["Compute"]);
6123 expect([...plan.selectedFqns!].sort()).toEqual([
6124 "Captured",
6125 "Compute",
6126 "Consumer",
6127 "Host",
6128 "Source",
6129 ]);
6130 }),
6131 );
6132
6133 test(
6134 "closes binding cycles without selecting unrelated nodes",
6135 Effect.gen(function* () {
6136 const plan = yield* makePlan(
6137 Effect.gen(function* () {
6138 const a = yield* BindingTarget("A", {});
6139 const b = yield* BindingTarget("B", {});
6140 yield* a.bind("B", { env: { B: b.name } });
6141 yield* b.bind("A", { env: { A: a.name } });
6142 yield* TestResource("Other", {});
6143 }),
6144 { include: ["A"] },
6145 );
6146 expect(Object.keys(plan.resources).sort()).toEqual(["A", "B"]);
6147 expect([...plan.cycleMembers].sort()).toEqual(["A", "B"]);
6148 }),
6149 );
6150
6151 test(
6152 "does not diff or adopt an unselected resource",
6153 Effect.gen(function* () {
6154 yield* seed({
6155 BadDiff: {
6156 status: "created",
6157 fqn: "BadDiff",
6158 logicalId: "BadDiff",
6159 namespace: undefined,
6160 instanceId,
6161 providerVersion: 0,
6162 resourceType: "Test.BindingTarget",
6163 props: {},
6164 attr: { name: "BadDiff", string: "BadDiff", env: {} },
6165 bindings: [],
6166 downstream: [],
6167 },
6168 Undeclared: {
6169 status: "created",
6170 fqn: "Undeclared",
6171 logicalId: "Undeclared",
6172 namespace: undefined,
6173 instanceId,
6174 providerVersion: 0,
6175 resourceType: "Missing.Provider",
6176 props: {},
6177 attr: {},
6178 bindings: [],
6179 downstream: [],
6180 },
6181 });
6182 const calls: string[] = [];
6183 const program = Effect.gen(function* () {
6184 yield* BindingTarget("BadDiff", {});
6185 yield* TestResource("BadRead", {});
6186 yield* TestResource("Selected", {});
6187 });
6188 yield* makePlan(program, { include: ["Selected"] }).pipe(
6189 Effect.provideService(TestResourceHooks, {
6190 diff: () => Effect.die("unselected diff"),
6191 read: (id) =>
6192 id === "BadRead"
6193 ? Effect.die("unselected adoption")
6194 : Effect.sync(() => {
6195 calls.push(id);
6196 return undefined;
6197 }),
6198 }),
6199 );
6200 expect(calls).toEqual(["Selected"]);
6201 }),
6202 );
6203});
6204
6205describe("resource selection patterns", { tags: ["unit", "local"] }, () => {
6206 const fqns = [
6207 "Branch",
6208 "One/Branch",
6209 "Two/Branch",
6210 "One/Nested/Leaf",
6211 "One/.Hidden",
6212 ".Private/Node",
6213 "Comma,Name",
6214 "Literal[1]",
6215 "Literal1",
6216 "Deep/Unique",
6217 "One/Twin",
6218 "Two/Twin",
6219 ];
6220 const program = Effect.gen(function* () {
6221 for (const fqn of fqns) {
6222 const slash = fqn.lastIndexOf("/");
6223 const resource = TestResource(fqn.slice(slash + 1), {});
6224 yield* slash < 0
6225 ? resource
6226 : resource.pipe(Namespace.push(fqn.slice(0, slash)));
6227 }
6228 return { full: "output" };
6229 });
6230 const cases: ReadonlyArray<{
6231 name: string;
6232 options?: Plan.FilteredPlanOptions;
6233 expected: ReadonlyArray<string>;
6234 }> = [
6235 { name: "full default", expected: fqns },
6236 {
6237 name: "undefined filters",
6238 options: { include: undefined, exclude: undefined },
6239 expected: fqns,
6240 },
6241 {
6242 name: "exact FQN before ambiguous logical ID",
6243 options: { include: ["Branch"] },
6244 expected: ["Branch"],
6245 },
6246 {
6247 name: "unique logical ID",
6248 options: { include: ["Unique"] },
6249 expected: ["Deep/Unique"],
6250 },
6251 {
6252 name: "literal commas",
6253 options: { include: ["Comma,Name"] },
6254 expected: ["Comma,Name"],
6255 },
6256 {
6257 name: "literal FQN before glob characters",
6258 options: { include: ["Literal[1]"] },
6259 expected: ["Literal[1]"],
6260 },
6261 {
6262 name: "direct segment star",
6263 options: { include: ["One/*"] },
6264 expected: ["One/Branch", "One/.Hidden", "One/Twin"],
6265 },
6266 {
6267 name: "recursive globstar",
6268 options: { include: ["One/**"] },
6269 expected: fqns.filter((fqn) => fqn.startsWith("One/")),
6270 },
6271 {
6272 name: "all including dot namespaces is partial",
6273 options: { include: ["**"] },
6274 expected: fqns,
6275 },
6276 {
6277 name: "positive braces",
6278 options: { include: ["{One,Two}/Branch"] },
6279 expected: ["One/Branch", "Two/Branch"],
6280 },
6281 {
6282 name: "positive extglob",
6283 options: { include: ["@(One|Two)/Branch"] },
6284 expected: ["One/Branch", "Two/Branch"],
6285 },
6286 {
6287 name: "question mark",
6288 options: { include: ["On?/Branch"] },
6289 expected: ["One/Branch"],
6290 },
6291 {
6292 name: "character class",
6293 options: { include: ["[OT]*/Branch"] },
6294 expected: ["One/Branch", "Two/Branch"],
6295 },
6296 {
6297 name: "duplicate union seeds",
6298 options: { include: ["One/*", "Branch", "One/*", "One/Branch"] },
6299 expected: ["One/Branch", "One/.Hidden", "One/Twin", "Branch"],
6300 },
6301 {
6302 name: "exclusion only",
6303 options: { exclude: ["One/**"] },
6304 expected: fqns.filter((fqn) => !fqn.startsWith("One/")),
6305 },
6306 {
6307 name: "literal exclusion FQN wins",
6308 options: { exclude: ["Branch"] },
6309 expected: fqns.filter((fqn) => fqn !== "Branch"),
6310 },
6311 {
6312 name: "multiple includes and excludes",
6313 options: {
6314 include: ["One/**", "Two/**"],
6315 exclude: ["**/Twin", "One/Nested/**"],
6316 },
6317 expected: ["One/Branch", "One/.Hidden", "Two/Branch"],
6318 },
6319 {
6320 name: "exclusion wins overlap and duplicates",
6321 options: {
6322 include: ["One/*", "One/Branch"],
6323 exclude: ["One/Branch", "One/Branch"],
6324 },
6325 expected: ["One/.Hidden", "One/Twin"],
6326 },
6327 ];
6328 for (const { name, options, expected } of cases) {
6329 test(
6330 name,
6331 Effect.gen(function* () {
6332 const plan = yield* makePlan(program, options);
6333 expect(Object.keys(plan.resources).sort()).toEqual(
6334 [...expected].sort(),
6335 );
6336 if (options?.include !== undefined || options?.exclude !== undefined) {
6337 expect([...plan.selectedFqns!].sort()).toEqual([...expected].sort());
6338 expect(plan.output).toBeUndefined();
6339 expect(plan.deletions).toEqual({});
6340 } else {
6341 expect(plan.selectedFqns).toBeUndefined();
6342 expect(plan.output).toEqual({ full: "output" });
6343 }
6344 }),
6345 );
6346 }
6347
6348 for (const literal of ["!Literal", "./!Literal", "././!(Literal)"]) {
6349 test(
6350 `exact FQN priority precedes negation metadata ${literal}`,
6351 Effect.gen(function* () {
6352 const declaration = Effect.gen(function* () {
6353 yield* TestResource(literal, {});
6354 yield* TestResource("Other", {});
6355 });
6356 const included = yield* makePlan(declaration, { include: [literal] });
6357 expect(Object.keys(included.resources)).toEqual([literal]);
6358 const excluded = yield* makePlan(declaration, { exclude: [literal] });
6359 expect(Object.keys(excluded.resources)).toEqual(["Other"]);
6360 }),
6361 );
6362 }
6363
6364 for (const options of [
6365 { include: [] },
6366 { exclude: [] },
6367 { include: ["Branch"], exclude: [] },
6368 { include: [""] },
6369 { exclude: [" "] },
6370 { include: ["Missing"] },
6371 { include: ["Missing/**"] },
6372 { include: ["Uni*"] },
6373 { include: ["Branch,Unique"] },
6374 { include: ["Twin"] },
6375 { exclude: ["Twin"] },
6376 { include: ["!One/**"] },
6377 { exclude: ["!One/**"] },
6378 { include: ["!!One/**"] },
6379 { include: ["!(One)/**"] },
6380 { include: ["./!One/**"] },
6381 { exclude: ["./!One/**"] },
6382 { include: ["./!(One)/**"] },
6383 { exclude: ["./!(One)/**"] },
6384 { include: ["././!One/**"] },
6385 { exclude: ["././!One/**"] },
6386 { include: ["././!(One)/**"] },
6387 { exclude: ["././!(One)/**"] },
6388 { include: ["./././!One/**"] },
6389 { exclude: ["./././!One/**"] },
6390 { include: ["./././!(One)/**"] },
6391 { exclude: ["./././!(One)/**"] },
6392 { include: ["./!!One/**"] },
6393 { exclude: ["./!!One/**"] },
6394 { include: ["./!./!One/**"] },
6395 { exclude: ["./!./!One/**"] },
6396 { include: ["One/["] },
6397 { exclude: ["One/{"] },
6398 { include: ["Branch"], exclude: ["Branch"] },
6399 { exclude: ["**"] },
6400 ]) {
6401 test(
6402 `rejects ${JSON.stringify(options)} before state or providers`,
6403 Effect.gen(function* () {
6404 let stateAccesses = 0;
6405 const calls: string[] = [];
6406 const exit = yield* makePlan(program, options).pipe(
6407 Effect.provideService(
6408 State,
6409 Effect.suspend(() => {
6410 stateAccesses++;
6411 return InMemoryService();
6412 }),
6413 ),
6414 Effect.provideService(TestResourceHooks, {
6415 read: (id) =>
6416 Effect.sync(() => {
6417 calls.push(`read:${id}`);
6418 return undefined;
6419 }),
6420 diff: (id) =>
6421 Effect.sync(() => {
6422 calls.push(`diff:${id}`);
6423 }),
6424 }),
6425 Effect.exit,
6426 );
6427 expect(Exit.isFailure(exit)).toBe(true);
6428 if (Exit.isFailure(exit)) {
6429 const message = Cause.pretty(exit.cause);
6430 expect(message).toContain("InvalidResourceSelection");
6431 if (
6432 options.include?.some((pattern) =>
6433 pattern.replace(/^(?:\.\/)+/, "").startsWith("!"),
6434 ) ||
6435 options.exclude?.some((pattern) =>
6436 pattern.replace(/^(?:\.\/)+/, "").startsWith("!"),
6437 )
6438 )
6439 expect(message).toContain("use --exclude");
6440 if (
6441 options.include?.includes("Twin") ||
6442 options.exclude?.includes("Twin")
6443 ) {
6444 expect(message).toContain("Ambiguous");
6445 expect(message).toContain("One/Twin");
6446 expect(message).toContain("Two/Twin");
6447 }
6448 }
6449 expect(stateAccesses).toBe(0);
6450 expect(calls).toEqual([]);
6451 }),
6452 );
6453 }
6454
6455 test(
6456 "warns once per unmatched exclusion and remains partial",
6457 Effect.gen(function* () {
6458 const logs: Array<{ level: string; message: unknown }> = [];
6459 const plan = yield* makePlan(program, {
6460 exclude: ["Missing", "Absent/**", "Missing"],
6461 }).pipe(
6462 Effect.provide(
6463 Logger.layer([
6464 Logger.make<unknown, void>((options) => {
6465 logs.push({ level: options.logLevel, message: options.message });
6466 }),
6467 ]),
6468 ),
6469 );
6470 expect(Object.keys(plan.resources).sort()).toEqual([...fqns].sort());
6471 expect(plan.output).toBeUndefined();
6472 expect(plan.selectedFqns?.size).toBe(fqns.length);
6473 const warnings = logs.filter((entry) => entry.level === "Warn");
6474 expect(warnings).toHaveLength(2);
6475 expect(String(warnings[0].message)).toContain("Missing");
6476 expect(String(warnings[1].message)).toContain("Absent/**");
6477 }),
6478 );
6479
6480 for (const [excluded, chain] of [
6481 ["App/Source", "App/Consumer -> App/Host -> App/Compute -> App/Source"],
6482 ["App/Captured", "App/Consumer -> App/Host -> App/Compute -> App/Captured"],
6483 ["App/Compute", "App/Consumer -> App/Host -> App/Compute"],
6484 ["App/Host", "App/Consumer -> App/Host"],
6485 ]) {
6486 test(
6487 `blocks transitive excluded ${excluded} with its FQN chain`,
6488 Effect.gen(function* () {
6489 const graph = Effect.gen(function* () {
6490 const source = yield* TestResource("Source", {});
6491 const captured = yield* TestResource("Captured", {});
6492 const Compute = Action(
6493 "Compute",
6494 Effect.gen(function* () {
6495 const value = yield* captured.string;
6496 return (input: { source: string }) =>
6497 Effect.map(value, (capture) => ({
6498 value: `${input.source}:${capture}`,
6499 }));
6500 }),
6501 );
6502 const result = yield* Compute({ source: source.string });
6503 const host = yield* BindingTarget("Host", {});
6504 yield* host.bind("Result", { env: { RESULT: result.value } });
6505 yield* TestResource("Consumer", { string: host.string });
6506 }).pipe(Namespace.push("App"));
6507 const exit = yield* makePlan(graph, {
6508 include: ["App/Consumer"],
6509 exclude: [excluded],
6510 }).pipe(
6511 Effect.provideService(
6512 State,
6513 Effect.die("state accessed before selection"),
6514 ),
6515 Effect.exit,
6516 );
6517 expect(Exit.isFailure(exit)).toBe(true);
6518 if (Exit.isFailure(exit)) {
6519 const message = Cause.pretty(exit.cause);
6520 expect(message).toContain(chain);
6521 expect(message).toContain(`exclude pattern '${excluded}'`);
6522 }
6523 }),
6524 );
6525 }
6526
6527 test(
6528 "a binding cycle cannot cross an exclusion",
6529 Effect.gen(function* () {
6530 const graph = Effect.gen(function* () {
6531 const a = yield* BindingTarget("A", {});
6532 const b = yield* BindingTarget("B", {});
6533 yield* a.bind("B", { env: { B: b.name } });
6534 yield* b.bind("A", { env: { A: a.name } });
6535 });
6536 const exit = yield* makePlan(graph, {
6537 include: ["A"],
6538 exclude: ["B"],
6539 }).pipe(
6540 Effect.provideService(
6541 State,
6542 Effect.die("state accessed before selection"),
6543 ),
6544 Effect.exit,
6545 );
6546 expect(Exit.isFailure(exit)).toBe(true);
6547 if (Exit.isFailure(exit))
6548 expect(Cause.pretty(exit.cause)).toContain("A -> B");
6549 }),
6550 );
6551});