1import { Action } from "@/Action";
2import { adopt, AdoptPolicy, OwnedBySomeoneElse, Unowned } from "@/AdoptPolicy";
3import { apply, DestroyError } from "@/Apply";
4import { isResolved } from "@/Diff";
5import * as ProviderLayer from "@/Local/ProviderLayer";
6import { Resource } from "@/Resource";
7import * as Context from "effect/Context";
8import { Cli } from "@/Report.ts";
9import * as Namespace from "@/Namespace.ts";
10import * as Output from "@/Output";
11import * as Provider from "@/Provider";
12import * as RemovalPolicy from "@/RemovalPolicy.ts";
13import { renamedFrom } from "@/Rename.ts";
14import { remote } from "@/ProviderMode.ts";
15import * as Plan from "@/Plan";
16import { Stage } from "@/Stage";
17import { Stack, make as makeStack } from "@/Stack";
18import {
19 type ActionState,
20 type CreatingResourceState,
21 type ReplacedResourceState,
22 type ReplacingResourceState,
23 type ResourceState,
24 State,
25 StateStoreError,
26} from "@/State";
27import * as Test from "@/Test/Alchemy";
28import { assert, describe, expect } from "alchemy-test";
29import { Data, Layer } from "effect";
30import * as Cause from "effect/Cause";
31import * as Deferred from "effect/Deferred";
32import * as Duration from "effect/Duration";
33import * as Effect from "effect/Effect";
34import * as Exit from "effect/Exit";
35import * as Fiber from "effect/Fiber";
36import * as Option from "effect/Option";
37import * as Redacted from "effect/Redacted";
38import {
39 AliasedWidget,
40 aliasedWidgetDeletes,
41 aliasedWidgetProvider,
42 ArtifactProbe,
43 BindingTarget,
44 capturedConfigHost,
45 CollisionRegistry,
46 DeleteFirstResource,
47 DeletedBindingRegressionTarget,
48 DurationResource,
49 FqnProbe,
50 Function,
51 inDev,
52 KindStablesResource,
53 ModalResource,
54 modalCalls,
55 type ModalResourceProps,
56 PhasedTarget,
57 ProbeBinding,
58 StaticStablesResource,
59 TestLayers,
60 TestResource,
61 TestResourceHooks,
62 type TestResourceProps,
63} from "./test.resources.ts";
64
65const { test } = Test.make({ providers: TestLayers() });
66
67const getState = Effect.fn(function* <S = ResourceState>(resourceId: string) {
68 const state = yield* yield* State;
69 const stk = yield* Stack;
70 return (yield* state.get({
71 stack: stk.name,
72 stage: stk.stage,
73 fqn: resourceId,
74 })) as S;
75});
76/** The planned action for a logical id, or `undefined` if it isn't planned. */
77const actionOfPlan = (plan: any, logicalId: string) =>
78 (Object.values(plan.resources) as any[]).find(
79 (node: any) => node.resource.LogicalId === logicalId,
80 )?.action;
81
82const listState = Effect.fn(function* () {
83 const state = yield* yield* State;
84 const stk = yield* Stack;
85 return yield* state.list({ stack: stk.name, stage: stk.stage });
86});
87
88const recordingCli = (events: Array<{ id: string; status: string }>) =>
89 Cli.of({
90 startPlanningSession: () =>
91 Effect.succeed({
92 update: () => Effect.void,
93 succeed: () => Effect.void,
94 fail: () => Effect.void,
95 close: Effect.void,
96 }),
97 approvePlan: () => Effect.succeed(true),
98 displayPlan: () => Effect.void,
99 startApplySession: () =>
100 Effect.succeed({
101 done: () => Effect.void,
102 emit: (event) =>
103 Effect.sync(() => {
104 if (event._tag === "apply.resource.status") {
105 events.push({ id: event.id, status: event.status });
106 }
107 }),
108 }),
109 });
110
111const expectConvergedStatus = (status: ResourceState["status"] | undefined) => {
112 expect(["created", "updated"]).toContain(status);
113};
114
115// Graceful failure handling means downstream resources of a failed upstream
116// may have committed an intermediate "creating"/"replacing" status before
117// their `waitForDeps` discovered the upstream failure - or may have fully
118// converged using a stable previous output of the failed upstream (e.g. a
119// replacement whose old generation is still live). This helper tolerates any
120// of those outcomes; the corresponding recovery deploy validates terminal
121// state.
122const expectNotStarted = (state: ResourceState | undefined) => {
123 expect([undefined, "creating", "replacing", "created", "updated"]).toContain(
124 state?.status,
125 );
126};
127
128export class ResourceFailure extends Data.TaggedError("ResourceFailure")<{
129 message: string;
130}> {
131 constructor() {
132 super({ message: `Failed to create` });
133 }
134}
135
136const hook =
137 (hooks?: {
138 create?: (id: string, props: TestResourceProps) => Effect.Effect<void, any>;
139 update?: (id: string, props: TestResourceProps) => Effect.Effect<void, any>;
140 delete?: (id: string) => Effect.Effect<void, any>;
141 read?: (id: string) => Effect.Effect<any, any>;
142 }) =>
143 <A, Err, Req>(test: Effect.Effect<A, Err, Req>) =>
144 test.pipe(
145 Effect.provide(
146 Layer.succeed(
147 TestResourceHooks,
148 hooks ?? {
149 create: () => Effect.fail(new ResourceFailure()),
150 update: () => Effect.fail(new ResourceFailure()),
151 delete: () => Effect.fail(new ResourceFailure()),
152 read: () => Effect.succeed(undefined),
153 },
154 ),
155 ),
156 // Phase-1 (create/update) failures surface as the raw ResourceFailure;
157 // Phase-2 (GC/destroy) delete failures are aggregated into DestroyError.
158 // @ts-expect-error - catchTag changes the return type
159 Effect.catchTag(["ResourceFailure", "DestroyError"], () =>
160 Effect.succeed(true),
161 ),
162 ) as Effect.Effect<A, Err, Req | State>;
163
164// Helper to fail on specific resource IDs
165const failOn = (
166 resourceId: string,
167 hook: "create" | "update" | "delete",
168): {
169 create?: (id: string, props: TestResourceProps) => Effect.Effect<void, any>;
170 update?: (id: string, props: TestResourceProps) => Effect.Effect<void, any>;
171 delete?: (id: string) => Effect.Effect<void, any>;
172} => ({
173 [hook]: (id: string) =>
174 id === resourceId
175 ? Effect.fail(new ResourceFailure())
176 : Effect.succeed(undefined),
177});
178
179// Helper to fail on multiple resource IDs for different hooks
180const failOnMultiple = (
181 failures: Array<{ id: string; hook: "create" | "update" | "delete" }>,
182): {
183 create?: (id: string, props: TestResourceProps) => Effect.Effect<void, any>;
184 update?: (id: string, props: TestResourceProps) => Effect.Effect<void, any>;
185 delete?: (id: string) => Effect.Effect<void, any>;
186} => {
187 const createFailures = failures
188 .filter((f) => f.hook === "create")
189 .map((f) => f.id);
190 const updateFailures = failures
191 .filter((f) => f.hook === "update")
192 .map((f) => f.id);
193 const deleteFailures = failures
194 .filter((f) => f.hook === "delete")
195 .map((f) => f.id);
196
197 return {
198 create: (id: string) =>
199 createFailures.includes(id)
200 ? Effect.fail(new ResourceFailure())
201 : Effect.succeed(undefined),
202 update: (id: string) =>
203 updateFailures.includes(id)
204 ? Effect.fail(new ResourceFailure())
205 : Effect.succeed(undefined),
206 delete: (id: string) =>
207 deleteFailures.includes(id)
208 ? Effect.fail(new ResourceFailure())
209 : Effect.succeed(undefined),
210 };
211};
212
213describe("Action output convergence", { tags: ["unit", "local"] }, () => {
214 for (const consumer of ["prop", "binding"] as const) {
215 test.provider(
216 `unchanged Action ${consumer} consumers stop reconciling after the first deploy`,
217 (stack) =>
218 Effect.gen(function* () {
219 let runs = 0;
220 const reconciled: string[] = [];
221 const Compute = Action("Compute", (_: {}) =>
222 Effect.sync(() => {
223 runs++;
224 return { value: "v1" };
225 }),
226 );
227 const program = Effect.gen(function* () {
228 const result = yield* Compute({});
229 if (consumer === "prop") {
230 const host = yield* TestResource("Host", {
231 string: result.value,
232 });
233 return host.string;
234 }
235 const host = yield* BindingTarget("Host", {});
236 yield* host.bind("Result", { env: { RESULT: result.value } });
237 return host.env.RESULT;
238 });
239 yield* Effect.gen(function* () {
240 for (const _ of [1, 2, 3]) {
241 expect(yield* stack.deploy(program)).toBe("v1");
242 }
243 expect(runs).toBe(1);
244 expect(reconciled).toEqual(["create"]);
245 const plan = yield* stack.plan(program);
246 expect(actionOfPlan(plan, "Host")).toBe("noop");
247 }).pipe(
248 Effect.provideService(TestResourceHooks, {
249 create: () =>
250 Effect.sync(() => {
251 reconciled.push("create");
252 }),
253 update: () =>
254 Effect.sync(() => {
255 reconciled.push("update");
256 }),
257 }),
258 );
259 }),
260 );
261 }
262
263 for (const sameOutput of [false, true]) {
264 test.provider(
265 `changed Action input ${sameOutput ? "with the same output" : "with a fresh output"} converges after the rerun`,
266 (stack) =>
267 Effect.gen(function* () {
268 let runs = 0;
269 const reconciled: (string | undefined)[] = [];
270 const Compute = Action("Compute", (input: { revision: string }) =>
271 Effect.sync(() => {
272 runs++;
273 return { value: sameOutput ? "constant" : input.revision };
274 }),
275 );
276 const program = (revision: string) =>
277 Effect.gen(function* () {
278 const result = yield* Compute({ revision });
279 const host = yield* TestResource("Host", {
280 string: result.value,
281 });
282 return host.string;
283 });
284 const record = (_: string, props: TestResourceProps) =>
285 Effect.sync(() => {
286 reconciled.push(props.string);
287 });
288 yield* Effect.gen(function* () {
289 expect(yield* stack.deploy(program("v1"))).toBe(
290 sameOutput ? "constant" : "v1",
291 );
292 expect(yield* stack.deploy(program("v2"))).toBe(
293 sameOutput ? "constant" : "v2",
294 );
295 expect(runs).toBe(2);
296 if (sameOutput) {
297 expect([1, 2]).toContain(reconciled.length);
298 expect(reconciled.every((value) => value === "constant")).toBe(
299 true,
300 );
301 } else {
302 expect(reconciled).toEqual(["v1", "v2"]);
303 }
304 const settledCount = reconciled.length;
305 expect(actionOfPlan(yield* stack.plan(program("v2")), "Host")).toBe(
306 "noop",
307 );
308 yield* stack.deploy(program("v2"));
309 expect(runs).toBe(2);
310 expect(reconciled).toHaveLength(settledCount);
311 }).pipe(
312 Effect.provideService(TestResourceHooks, {
313 create: record,
314 update: record,
315 }),
316 );
317 }),
318 );
319 }
320
321 test.provider(
322 "changed Action binding data reaches the host before subsequent deploys converge",
323 (stack) =>
324 Effect.gen(function* () {
325 let runs = 0;
326 let reconciles = 0;
327 const Compute = Action("Compute", (input: { value: string }) =>
328 Effect.sync(() => {
329 runs++;
330 return input;
331 }),
332 );
333 const program = (value: string) =>
334 Effect.gen(function* () {
335 const result = yield* Compute({ value });
336 const host = yield* BindingTarget("Host", {});
337 yield* host.bind("Result", { env: { RESULT: result.value } });
338 return host.env.RESULT;
339 });
340 const record = () =>
341 Effect.sync(() => {
342 reconciles++;
343 });
344 yield* Effect.gen(function* () {
345 expect(yield* stack.deploy(program("v1"))).toBe("v1");
346 expect(yield* stack.deploy(program("v2"))).toBe("v2");
347 expect(runs).toBe(2);
348 expect(reconciles).toBe(2);
349 expect(actionOfPlan(yield* stack.plan(program("v2")), "Host")).toBe(
350 "noop",
351 );
352 expect(yield* stack.deploy(program("v2"))).toBe("v2");
353 expect(runs).toBe(2);
354 expect(reconciles).toBe(2);
355 }).pipe(
356 Effect.provideService(TestResourceHooks, {
357 create: record,
358 update: record,
359 }),
360 );
361 }),
362 );
363
364 test.provider(
365 "Action chains propagate fresh outputs and stop rerunning once unchanged",
366 (stack) =>
367 Effect.gen(function* () {
368 const runs: string[] = [];
369 const Compute = Action("Compute", (input: { value: string }) =>
370 Effect.sync(() => {
371 runs.push(input.value);
372 return { value: `${input.value}!` };
373 }),
374 );
375 const program = (value: string) =>
376 Effect.gen(function* () {
377 const first = yield* Compute("First", { value });
378 const second = yield* Compute("Second", { value: first.value });
379 const host = yield* Function("Host", {
380 env: { RESULT: second.value },
381 });
382 return host.env.RESULT;
383 });
384 expect(yield* stack.deploy(program("v1"))).toBe("v1!!");
385 expect(yield* stack.deploy(program("v2"))).toBe("v2!!");
386 expect(yield* stack.deploy(program("v2"))).toBe("v2!!");
387 expect(runs).toEqual(["v1", "v1!", "v2", "v2!"]);
388 expect(actionOfPlan(yield* stack.plan(program("v2")), "Host")).toBe(
389 "noop",
390 );
391 }),
392 );
393
394 test.provider(
395 "binding-only changes to an upstream resource invalidate Action inputs and consumer outputs",
396 (stack) =>
397 Effect.gen(function* () {
398 const seen: string[] = [];
399 const Compute = Action("Compute", (input: { value: string }) =>
400 Effect.sync(() => {
401 seen.push(input.value);
402 return input;
403 }),
404 );
405 const program = (value: string) =>
406 Effect.gen(function* () {
407 const source = yield* BindingTarget("Source", {});
408 yield* source.bind("Value", { env: { RESULT: value } });
409 const result = yield* Compute({ value: source.env.RESULT });
410 const host = yield* Function("Host", {
411 env: { RESULT: result.value },
412 });
413 return host.env.RESULT;
414 });
415 expect(yield* stack.deploy(program("v1"))).toBe("v1");
416 const changed = yield* stack.plan(program("v2"));
417 expect(changed.resources.Source.action).toBe("update");
418 expect(changed.actions.Compute.action).toBe("run");
419 expect(changed.resources.Host.action).toBe("update");
420 expect(yield* stack.deploy(program("v2"))).toBe("v2");
421 expect(yield* stack.deploy(program("v2"))).toBe("v2");
422 expect(seen).toEqual(["v1", "v2"]);
423 expect(actionOfPlan(yield* stack.plan(program("v2")), "Host")).toBe(
424 "noop",
425 );
426 }),
427 );
428
429 test.provider(
430 "an Action planned to run skips its body when upstream resolves to the same input",
431 (stack) =>
432 Effect.gen(function* () {
433 let firstRuns = 0;
434 let secondRuns = 0;
435 const First = Action("First", (_: { revision: string }) =>
436 Effect.sync(() => {
437 firstRuns++;
438 return { value: "constant" };
439 }),
440 );
441 const Second = Action("Second", (input: { value: string }) =>
442 Effect.sync(() => {
443 secondRuns++;
444 return { value: `${input.value}!` };
445 }),
446 );
447 const program = (revision: string) =>
448 Effect.gen(function* () {
449 const first = yield* First({ revision });
450 const second = yield* Second({ value: first.value });
451 const host = yield* Function("Host", {
452 env: { RESULT: second.value },
453 });
454 return host.env.RESULT;
455 });
456 expect(yield* stack.deploy(program("v1"))).toBe("constant!");
457 const changed = yield* stack.plan(program("v2"));
458 expect(changed.actions.First.action).toBe("run");
459 expect(changed.actions.Second.action).toBe("run");
460 expect(yield* stack.deploy(program("v2"))).toBe("constant!");
461 expect(firstRuns).toBe(2);
462 expect(secondRuns).toBe(1);
463 const forced = yield* program("v2").pipe(
464 makeStack({
465 name: stack.name,
466 providers: TestLayers(),
467 state: stack.state,
468 }),
469 Effect.flatMap((spec) =>
470 Plan.make(spec, { force: true }).pipe(
471 Effect.flatMap(apply),
472 Effect.provide(spec.services),
473 ),
474 ),
475 Effect.provideService(Stage, stack.stage),
476 );
477 expect(forced).toBe("constant!");
478 expect(firstRuns).toBe(3);
479 expect(secondRuns).toBe(2);
480 expect(actionOfPlan(yield* stack.plan(program("v2")), "Host")).toBe(
481 "noop",
482 );
483 }),
484 );
485
486 test.provider(
487 "changed Action output replaces the whole environment without retaining removed keys",
488 (stack) =>
489 Effect.gen(function* () {
490 const Compute = Action("Compute", (input: { revision: number }) =>
491 Effect.succeed<Record<string, string>>(
492 input.revision === 1
493 ? { KEEP: "old", REMOVE: "old" }
494 : { KEEP: "new" },
495 ),
496 );
497 const program = (revision: number) =>
498 Effect.gen(function* () {
499 const env = yield* Compute({ revision });
500 const host = yield* Function("Host", { env });
501 return host.env;
502 });
503 expect(yield* stack.deploy(program(1))).toEqual({
504 KEEP: "old",
505 REMOVE: "old",
506 });
507 expect(yield* stack.deploy(program(2))).toEqual({ KEEP: "new" });
508 expect(actionOfPlan(yield* stack.plan(program(2)), "Host")).toBe(
509 "noop",
510 );
511 }),
512 );
513
514 test.provider(
515 "a failed Action rerun blocks its value consumer and recovery uses the new output",
516 (stack) =>
517 Effect.gen(function* () {
518 let fail = false;
519 const reconciled: (string | undefined)[] = [];
520 const Compute = Action("Compute", (input: { revision: string }) =>
521 Effect.gen(function* () {
522 if (fail) return yield* Effect.fail(new ResourceFailure());
523 return { value: input.revision };
524 }),
525 );
526 const program = (revision: string) =>
527 Effect.gen(function* () {
528 const result = yield* Compute({ revision });
529 const host = yield* TestResource("Host", { string: result.value });
530 return host.string;
531 });
532 const record = (_: string, props: TestResourceProps) =>
533 Effect.sync(() => {
534 reconciled.push(props.string);
535 });
536 yield* Effect.gen(function* () {
537 expect(yield* stack.deploy(program("v1"))).toBe("v1");
538 fail = true;
539 const failed = yield* Effect.exit(stack.deploy(program("v2")));
540 assert(Exit.isFailure(failed));
541 expect(
542 failed.cause.reasons.find(Cause.isFailReason)?.error,
543 ).toBeInstanceOf(ResourceFailure);
544 expect(reconciled).toEqual(["v1"]);
545 fail = false;
546 expect(yield* stack.deploy(program("v2"))).toBe("v2");
547 expect(reconciled).toEqual(["v1", "v2"]);
548 }).pipe(
549 Effect.provideService(TestResourceHooks, {
550 create: record,
551 update: record,
552 }),
553 );
554 }),
555 );
556
557 for (const value of [undefined, null, false, 0, ""] as const) {
558 test.provider(
559 `persisted ${String(value)} Action output remains reusable after apply`,
560 (stack) =>
561 Effect.gen(function* () {
562 let runs = 0;
563 const Compute = Action("Compute", (_: {}) =>
564 Effect.sync(() => {
565 runs++;
566 return value;
567 }),
568 );
569 const program = Effect.gen(function* () {
570 const result = yield* Compute({});
571 const host = yield* Function("Host", {
572 env: { RESULT: Output.map(result, String) },
573 });
574 return host.env.RESULT;
575 });
576 expect(yield* stack.deploy(program)).toBe(String(value));
577 expect(yield* stack.deploy(program)).toBe(String(value));
578 expect(runs).toBe(1);
579 expect(actionOfPlan(yield* stack.plan(program), "Host")).toBe("noop");
580 }),
581 );
582 }
583
584 test.provider(
585 "a forced Action rerun publishes fresh output instead of its persisted result",
586 (stack) =>
587 Effect.gen(function* () {
588 let runs = 0;
589 const Compute = Action("Compute", (_: {}) =>
590 Effect.sync(() => ({ value: String(++runs) })),
591 );
592 const program = Effect.gen(function* () {
593 const result = yield* Compute({});
594 const host = yield* Function("Host", {
595 env: { RESULT: result.value },
596 });
597 return host.env.RESULT;
598 });
599 expect(yield* stack.deploy(program)).toBe("1");
600 const forced = yield* program.pipe(
601 makeStack({
602 name: stack.name,
603 providers: TestLayers(),
604 state: stack.state,
605 }),
606 Effect.flatMap((spec) =>
607 Plan.make(spec, { force: true }).pipe(
608 Effect.flatMap(apply),
609 Effect.provide(spec.services),
610 ),
611 ),
612 Effect.provideService(Stage, stack.stage),
613 );
614 expect(forced).toBe("2");
615 expect(yield* stack.deploy(program)).toBe("2");
616 expect(runs).toBe(2);
617 }),
618 );
619
620 test.provider(
621 "an Action migration marker in the environment converges without removing its dependency",
622 (stack) =>
623 Effect.gen(function* () {
624 let runs = 0;
625 const Compute = Action("Compute", (_: {}) =>
626 Effect.sync(() => {
627 runs++;
628 return { migrated: true };
629 }),
630 );
631 const program = Effect.gen(function* () {
632 const result = yield* Compute({});
633 const host = yield* Function("Host", {
634 name: "host",
635 env: { MIGRATION: Output.map(result, JSON.stringify) },
636 });
637 return host.name;
638 });
639 expect(yield* stack.deploy(program)).toBe("host");
640 expect(yield* stack.deploy(program)).toBe("host");
641 expect(runs).toBe(1);
642 const plan = yield* stack.plan(program);
643 expect(plan.actions.Compute.action).toBe("noop");
644 expect(actionOfPlan(plan, "Host")).toBe("noop");
645 }),
646 );
647
648 for (const operation of ["create", "update", "replace"] as const) {
649 test.provider(
650 `Action completion precedes bound host ${operation}`,
651 (stack) =>
652 Effect.gen(function* () {
653 const events: string[] = [];
654 const Compute = Action("Compute", (_: { revision: string }) =>
655 Effect.gen(function* () {
656 yield* Effect.yieldNow;
657 events.push("action completed");
658 return { migrated: true };
659 }),
660 );
661 const program = (revision: string) =>
662 Effect.gen(function* () {
663 const result = yield* Compute({ revision });
664 const host = yield* BindingTarget("Host", {
665 string: revision,
666 replaceString: operation === "replace" ? revision : "fixed",
667 });
668 yield* host.bind("Migration", {
669 env: { MIGRATION: Output.map(result, JSON.stringify) },
670 });
671 return host.string;
672 });
673 const record = () =>
674 Effect.sync(() => {
675 events.push("host reconciled");
676 });
677 yield* Effect.gen(function* () {
678 if (operation !== "create") yield* stack.deploy(program("v1"));
679 events.length = 0;
680 expect(actionOfPlan(yield* stack.plan(program("v2")), "Host")).toBe(
681 operation,
682 );
683 expect(yield* stack.deploy(program("v2"))).toBe("v2");
684 expect(events).toEqual(["action completed", "host reconciled"]);
685 }).pipe(
686 Effect.provideService(TestResourceHooks, {
687 create: record,
688 update: record,
689 }),
690 );
691 }),
692 );
693
694 test.provider(`Action failure prevents bound host ${operation}`, (stack) =>
695 Effect.gen(function* () {
696 let fail = false;
697 const reconciled: string[] = [];
698 const Compute = Action("Compute", (_: { revision: string }) =>
699 fail ? Effect.fail(new ResourceFailure()) : Effect.succeed("done"),
700 );
701 const program = (revision: string) =>
702 Effect.gen(function* () {
703 const result = yield* Compute({ revision });
704 const host = yield* BindingTarget("Host", {
705 string: revision,
706 replaceString: operation === "replace" ? revision : "fixed",
707 });
708 yield* host.bind("Migration", {
709 env: { MIGRATION: Output.map(result, JSON.stringify) },
710 });
711 return host.string;
712 });
713 const record = (id: string) =>
714 Effect.sync(() => {
715 reconciled.push(id);
716 });
717 yield* Effect.gen(function* () {
718 if (operation !== "create") yield* stack.deploy(program("v1"));
719 reconciled.length = 0;
720 fail = true;
721 expect(actionOfPlan(yield* stack.plan(program("v2")), "Host")).toBe(
722 operation,
723 );
724 const failed = yield* Effect.exit(stack.deploy(program("v2")));
725 assert(Exit.isFailure(failed));
726 expect(
727 failed.cause.reasons.find(Cause.isFailReason)?.error,
728 ).toBeInstanceOf(ResourceFailure);
729 expect(reconciled).toEqual([]);
730 }).pipe(
731 Effect.provideService(TestResourceHooks, {
732 create: record,
733 update: record,
734 }),
735 );
736 }),
737 );
738 }
739});
740
741// #1831: a changed value read by a Platform's Init must reach the provider,
742// even though `CapturedConfigHost`'s diff always returns `noop`.
743describe("Platform Init-captured config", { tags: ["unit", "local"] }, () => {
744 test.provider("a changed captured value deploys and converges", (stack) =>
745 Effect.gen(function* () {
746 expect((yield* stack.deploy(capturedConfigHost("a"))).mode).toBe("a");
747 expect(
748 actionOfPlan(yield* stack.plan(capturedConfigHost("a")), "Host"),
749 ).toBe("noop");
750 expect((yield* stack.deploy(capturedConfigHost("b"))).mode).toBe("b");
751 expect(
752 actionOfPlan(yield* stack.plan(capturedConfigHost("b")), "Host"),
753 ).toBe("noop");
754 }),
755 );
756});
757
758describe("basic operations", { tags: ["unit", "local"] }, () => {
759 test.provider("should create, update, and delete resources", (stack) =>
760 Effect.gen(function* () {
761 expect(
762 yield* Effect.gen(function* () {
763 const A = yield* TestResource("A", {
764 string: "test-string",
765 });
766 return A.string;
767 }).pipe(stack.deploy),
768 ).toEqual("test-string");
769
770 expect(
771 yield* Effect.gen(function* () {
772 const A = yield* TestResource("A", {
773 string: "test-string-new",
774 });
775 return A.string;
776 }).pipe(stack.deploy),
777 ).toEqual("test-string-new");
778
779 yield* stack.destroy();
780
781 expect(yield* getState("A")).toBeUndefined();
782 expect(yield* listState()).toEqual([]);
783 }),
784 );
785
786 test.provider("should resolve output properties", (stack) =>
787 Effect.gen(function* () {
788 expect(
789 yield* Effect.gen(function* () {
790 const A = yield* TestResource("A", {
791 string: "test-string",
792 stringArray: ["test-string-array"],
793 });
794 const B = yield* TestResource("B", {
795 string: A.string,
796 });
797 return B.string;
798 }).pipe(stack.deploy),
799 ).toEqual("test-string");
800
801 expect(
802 yield* Effect.gen(function* () {
803 const A = yield* TestResource("A", {
804 string: "test-string",
805 stringArray: ["test-string-array"],
806 });
807 const B = yield* TestResource("B", {
808 string: A.string.pipe(Output.map((string) => string.toUpperCase())),
809 });
810 return B.string;
811 }).pipe(stack.deploy),
812 ).toEqual("TEST-STRING");
813
814 expect(
815 yield* Effect.gen(function* () {
816 const A = yield* TestResource("A", {
817 string: "test-string",
818 stringArray: ["test-string-array"],
819 });
820 const B = yield* TestResource("B", {
821 string: A.string.pipe(
822 Output.map((string) => string.toUpperCase() + "-NEW"),
823 ),
824 });
825 return B.string;
826 }).pipe(stack.deploy),
827 ).toEqual("TEST-STRING-NEW");
828
829 expect(
830 yield* Effect.gen(function* () {
831 const A = yield* TestResource("A", {
832 string: "test-string",
833 stringArray: ["test-string-array"],
834 });
835 const B = yield* TestResource("B", {
836 string: A.string.pipe(
837 Output.flatMap((string) =>
838 Output.literal(string.toUpperCase() + "-FLAT"),
839 ),
840 ),
841 });
842 return B.string;
843 }).pipe(stack.deploy),
844 ).toEqual("TEST-STRING-FLAT");
845 }),
846 );
847
848 test.provider(
849 "should apply downstream resources when a stable kind shadows an output discriminator",
850 (stack) =>
851 Effect.gen(function* () {
852 yield* KindStablesResource("Database", {
853 value: "v1",
854 }).pipe(stack.deploy);
855
856 const output = yield* Effect.gen(function* () {
857 const database = yield* KindStablesResource("Database", {
858 value: "v2",
859 });
860 const role = yield* KindStablesResource("Role", {
861 value: "role",
862 upstream: database,
863 });
864 return { database, role };
865 }).pipe(stack.deploy);
866
867 expect(output.database.value).toBe("v2");
868 expect(output.role.upstreamKind).toBe("postgresql");
869 }),
870 );
871
872 test.provider(
873 "should resolve bindings inside constructs using namespaced resources",
874 (stack) =>
875 Effect.gen(function* () {
876 const Site = (id: string, _props: {}) =>
877 Effect.gen(function* () {
878 const bucket = yield* BindingTarget("Bucket", {
879 string: "bucket-value",
880 });
881 const distribution = yield* BindingTarget("Distribution", {
882 string: "distribution-value",
883 });
884
885 yield* bucket.bind("Policy", {
886 env: {
887 BUCKET: bucket.string,
888 DISTRIBUTION: distribution.string,
889 },
890 });
891
892 return {
893 bucket,
894 distribution,
895 };
896 }).pipe(Namespace.push(id));
897
898 const output = yield* Site("MarketingSite", {}).pipe(stack.deploy);
899
900 expect(output.bucket.env).toEqual({
901 BUCKET: "bucket-value",
902 DISTRIBUTION: "distribution-value",
903 });
904 expectConvergedStatus(
905 (yield* getState("MarketingSite/Bucket"))?.status,
906 );
907 expect((yield* getState("MarketingSite/Distribution"))?.status).toEqual(
908 "created",
909 );
910 }),
911 );
912
913 test.provider(
914 "should exclude deleted bindings before provider updates",
915 (stack) =>
916 Effect.gen(function* () {
917 const created = yield* stack.deploy(
918 Effect.gen(function* () {
919 const target = yield* DeletedBindingRegressionTarget("A", {
920 name: "target",
921 });
922 yield* target.bind("TestBinding", {
923 env: {
924 FEATURE_FLAG: "on",
925 },
926 });
927 return target;
928 }),
929 );
930
931 expect(created.env).toEqual({
932 FEATURE_FLAG: "on",
933 });
934
935 const updated = yield* stack.deploy(
936 Effect.gen(function* () {
937 return yield* DeletedBindingRegressionTarget("A", {
938 name: "target",
939 });
940 }),
941 );
942
943 expect(updated.env).toEqual({});
944 expect(yield* getState("A")).toMatchObject({
945 bindings: [],
946 attr: {
947 env: {},
948 },
949 });
950 }),
951 );
952
953 // #874: terminal commits must persist the RESOLVED binding payload the
954 // provider reconciled with, not the raw plan-time expressions. Raw
955 // `node.bindings` hold unresolved Outputs (silently dropped by JSON state
956 // stores), so persisting them makes every later plan's `diffBindings`
957 // compare a lossy stored shape against fully-resolved data — a phantom
958 // binding update on every plan. Exercises the create, update, and replace
959 // commit sites.
960 test.provider("terminal commits persist resolved binding payloads", (stack) =>
961 Effect.gen(function* () {
962 const program = (opts: { source: string; replaceString?: string }) =>
963 Effect.gen(function* () {
964 const source = yield* BindingTarget("BindSource", {
965 string: opts.source,
966 });
967 const host = yield* BindingTarget("BindHost", {
968 name: "host",
969 replaceString: opts.replaceString,
970 });
971 // `source.string` is an unresolved Output at plan time.
972 yield* host.bind("FromSource", {
973 env: { VALUE: source.string },
974 });
975 return { source, host };
976 });
977
978 const actionOf = (plan: any, logicalId: string) =>
979 (Object.values(plan.resources) as any[]).find(
980 (node: any) => node.resource.LogicalId === logicalId,
981 )?.action;
982
983 const expectHostBindings = Effect.fn(function* (value: string) {
984 const hostState = yield* getState("BindHost");
985 expect(hostState?.bindings).toEqual([
986 { sid: "FromSource", data: { env: { VALUE: value } } },
987 ]);
988 });
989
990 // ── create commit ──
991 yield* stack.deploy(program({ source: "v1" }));
992 yield* expectHostBindings("v1");
993 const created = yield* stack.plan(program({ source: "v1" }));
994 expect(actionOf(created, "BindSource")).toBe("noop");
995 expect(actionOf(created, "BindHost")).toBe("noop");
996
997 // ── update commit ──
998 yield* stack.deploy(program({ source: "v2" }));
999 yield* expectHostBindings("v2");
1000 const updated = yield* stack.plan(program({ source: "v2" }));
1001 expect(actionOf(updated, "BindSource")).toBe("noop");
1002 expect(actionOf(updated, "BindHost")).toBe("noop");
1003
1004 // ── replace commit ──
1005 yield* stack.deploy(program({ source: "v2", replaceString: "flip" }));
1006 yield* expectHostBindings("v2");
1007 const replaced = yield* stack.plan(
1008 program({ source: "v2", replaceString: "flip" }),
1009 );
1010 expect(actionOf(replaced, "BindSource")).toBe("noop");
1011 expect(actionOf(replaced, "BindHost")).toBe("noop");
1012 }),
1013 );
1014
1015 test.provider(
1016 "persists resolved binding data so an unchanged redeploy plans binding noops",
1017 (stack) =>
1018 Effect.gen(function* () {
1019 // Binding data references an output of a resource created in the
1020 // SAME deploy — unresolved at plan time, resolved during apply.
1021 const program = Effect.gen(function* () {
1022 const upstream = yield* BindingTarget("Upstream", {
1023 string: "upstream-value",
1024 });
1025 const target = yield* BindingTarget("Target", { string: "t" });
1026 yield* target.bind("Cap", { env: { UPSTREAM: upstream.string } });
1027 return target;
1028 });
1029
1030 const created = yield* stack.deploy(program);
1031 expect(created.env).toEqual({ UPSTREAM: "upstream-value" });
1032
1033 // The persisted binding data must hold the RESOLVED value (what
1034 // `reconcile` received) — not the raw plan-time data, whose Output
1035 // proxies JSON state stores silently drop.
1036 expect(yield* getState("Target")).toMatchObject({
1037 status: "created",
1038 bindings: [
1039 { sid: "Cap", data: { env: { UPSTREAM: "upstream-value" } } },
1040 ],
1041 });
1042
1043 // An unchanged redeploy must plan the binding as a noop. Before the
1044 // fix, the truncated persisted data diffed against the now-resolved
1045 // value and forced a spurious update on every deploy after a create.
1046 yield* stack.deploy(program);
1047 expect((yield* getState("Target"))?.status).toEqual("created");
1048 }),
1049 );
1050
1051 test.provider(
1052 "should update a surviving consumer before deleting a removed dependency",
1053 (stack) =>
1054 Effect.gen(function* () {
1055 const created = yield* stack.deploy(
1056 Effect.gen(function* () {
1057 const secret = yield* TestResource("Secret", {
1058 string: "secret-value",
1059 });
1060 const worker = yield* Function("Worker", {
1061 name: "worker",
1062 env: {
1063 SECRET: secret.string,
1064 },
1065 });
1066 return { secret, worker };
1067 }),
1068 );
1069
1070 expect(created.worker.env).toEqual({
1071 SECRET: "secret-value",
1072 });
1073 expect((yield* getState("Secret"))?.status).toEqual("created");
1074 expect((yield* getState("Worker"))?.status).toEqual("created");
1075
1076 const updated = yield* stack.deploy(
1077 Effect.gen(function* () {
1078 return yield* Function("Worker", {
1079 name: "worker",
1080 });
1081 }),
1082 );
1083
1084 expect(updated.env).toEqual({});
1085 expect(yield* getState("Secret")).toBeUndefined();
1086 expect((yield* getState("Worker"))?.status).toEqual("updated");
1087 }),
1088 );
1089
1090 test.provider(
1091 "should create a resource with a binding that references its own output",
1092 (stack) =>
1093 Effect.gen(function* () {
1094 const created = yield* stack.deploy(
1095 Effect.gen(function* () {
1096 const target = yield* DeletedBindingRegressionTarget("A", {
1097 name: "target",
1098 });
1099 yield* target.bind("SelfBinding", {
1100 env: {
1101 SELF_NAME: target.name,
1102 },
1103 });
1104 return target;
1105 }),
1106 );
1107
1108 expect(created.env).toEqual({
1109 SELF_NAME: "target",
1110 });
1111 }),
1112 { timeout: 10_000 },
1113 );
1114});
1115
1116// Regression: a logical ID may legitimately contain the FQN separator ("/").
1117// The GitHub event source registers a webhook keyed by `${owner}/${repository}`
1118// (e.g. "alchemy-run/alchemy"). During destroy the deletion path used to
1119// recompute the state key via `toFqn(namespace, logicalId)`, but `logicalId`
1120// came from `parseFqn` which splits on "/" and keeps only the last segment
1121// ("alchemy"). The recomputed key missed the real state row, so the
1122// resource was deleted from the cloud yet never removed from state — resurfacing
1123// as an orphan deletion on every subsequent destroy, forever.
1124describe("FQN separator in logical ID", { tags: ["unit", "local"] }, () => {
1125 test.provider(
1126 "destroy clears state for a top-level logical ID containing '/'",
1127 (stack) =>
1128 Effect.gen(function* () {
1129 const fqn = "alchemy-run/alchemy";
1130
1131 yield* stack.deploy(
1132 Effect.gen(function* () {
1133 return yield* TestResource(fqn, { string: "v1" });
1134 }),
1135 );
1136
1137 // The row is persisted under the full FQN (separator and all).
1138 expect((yield* getState(fqn))?.status).toEqual("created");
1139
1140 const deleted: string[] = [];
1141 yield* stack.destroy().pipe(
1142 hook({
1143 delete: (id: string) =>
1144 Effect.sync(() => {
1145 deleted.push(id);
1146 }),
1147 }),
1148 );
1149
1150 // provider.delete ran exactly once, AND the state row was removed
1151 // (the pre-fix bug deleted the cloud resource but missed the row).
1152 expect(deleted).toHaveLength(1);
1153 expect(yield* getState(fqn)).toBeUndefined();
1154 expect(yield* listState()).toEqual([]);
1155 }),
1156 );
1157
1158 test.provider(
1159 "destroy clears state for a namespaced logical ID containing '/'",
1160 (stack) =>
1161 Effect.gen(function* () {
1162 // Mirrors the GitHub webhook: a host construct (the Worker) whose
1163 // child resource's logical ID is "owner/repo".
1164 const fqn = "ReleaseService/alchemy-run/alchemy";
1165
1166 yield* stack.deploy(
1167 Effect.gen(function* () {
1168 return yield* TestResource("alchemy-run/alchemy", {
1169 string: "v1",
1170 });
1171 }).pipe(Namespace.push("ReleaseService")),
1172 );
1173
1174 expect((yield* getState(fqn))?.status).toEqual("created");
1175
1176 yield* stack.destroy();
1177
1178 expect(yield* getState(fqn)).toBeUndefined();
1179 expect(yield* listState()).toEqual([]);
1180 }),
1181 );
1182});
1183
1184describe("linear update propagation", { tags: ["unit", "local"] }, () => {
1185 // Regression: in a linear chain (A -> B with no cycle), an update to A
1186 // followed by an update to B must let B see A's *post-update* attr, never
1187 // the stale prior attr. Before the cycle-gating change, A would publish
1188 // its prior attr early and B's update would race against the live value,
1189 // sometimes deploying with stale data (e.g. a Worker reading a Build's
1190 // outdir/hash before the build finished).
1191 test.provider(
1192 "downstream update receives upstream's post-update attr",
1193 (stack) =>
1194 Effect.gen(function* () {
1195 yield* stack.deploy(
1196 Effect.gen(function* () {
1197 const A = yield* TestResource("A", { string: "v1" });
1198 const B = yield* TestResource("B", { string: A.string });
1199 return { A, B };
1200 }),
1201 );
1202
1203 const sawByB: string[] = [];
1204 const captureBHooks = {
1205 create: () => Effect.succeed(undefined),
1206 update: (id: string, props: TestResourceProps) =>
1207 Effect.sync(() => {
1208 if (id === "B" && typeof props.string === "string") {
1209 sawByB.push(props.string);
1210 }
1211 }),
1212 delete: () => Effect.succeed(undefined),
1213 read: () => Effect.succeed(undefined),
1214 };
1215
1216 const output = yield* Effect.gen(function* () {
1217 const A = yield* TestResource("A", { string: "v2" });
1218 const B = yield* TestResource("B", { string: A.string });
1219 return { A, B };
1220 }).pipe(stack.deploy, hook(captureBHooks));
1221
1222 expect(output.A.string).toEqual("v2");
1223 expect(output.B.string).toEqual("v2");
1224 // B.update must have observed the fresh upstream value, never the
1225 // stale "v1". A single fresh-only call is the ideal; we accept any
1226 // sequence as long as no stale value leaked through.
1227 expect(sawByB.length).toBeGreaterThan(0);
1228 expect(sawByB.every((v) => v === "v2")).toBe(true);
1229 }),
1230 );
1231
1232 // Regression: a dependent that repins from upstream A to upstream B updates
1233 // *itself*, while A and B are both noops. The noop pass must still persist
1234 // their new `downstream` — delete ordering reads it from the persisted row,
1235 // so a stale list would delete B concurrently with the dependent that still
1236 // references it (and needlessly wait on A).
1237 test.provider(
1238 "noop upstreams persist a repinned dependent's downstream edge",
1239 (stack) =>
1240 Effect.gen(function* () {
1241 const program = (pin: "A" | "B") =>
1242 Effect.gen(function* () {
1243 const A = yield* TestResource("A", { string: "a" });
1244 const B = yield* TestResource("B", { string: "b" });
1245 const C = yield* TestResource("C", {
1246 string: (pin === "A" ? A : B).string,
1247 });
1248 return { A, B, C };
1249 });
1250
1251 yield* stack.deploy(program("A"));
1252 expect((yield* getState("A"))?.downstream).toEqual(["C"]);
1253 expect((yield* getState("B"))?.downstream).toEqual([]);
1254
1255 yield* stack.deploy(program("B"));
1256 expect((yield* getState("A"))?.downstream).toEqual([]);
1257 expect((yield* getState("B"))?.downstream).toEqual(["C"]);
1258 }),
1259 );
1260});
1261
1262// Regression: `deleteFirst` on a `replace` diff was plumbed from the provider
1263// all the way into persisted state but never *read* — every replacement was
1264// create-first, with the old generation reclaimed afterwards by Phase-2 GC.
1265// That silently broke any resource whose replacement can't coexist with the
1266// original (fixed physical name, singleton): the create collided with the
1267// not-yet-deleted original. These tests pin both orderings.
1268describe("deleteFirst replacements", { tags: ["unit", "local"] }, () => {
1269 test.provider(
1270 "deletes the old generation BEFORE creating the replacement",
1271 (stack) =>
1272 Effect.gen(function* () {
1273 yield* stack.deploy(
1274 Effect.gen(function* () {
1275 return yield* DeleteFirstResource("R", { replaceString: "v1" });
1276 }),
1277 );
1278
1279 const order: string[] = [];
1280 const recordHooks = {
1281 create: () =>
1282 Effect.sync(() => {
1283 order.push("create");
1284 }),
1285 update: () => Effect.succeed(undefined),
1286 delete: () =>
1287 Effect.sync(() => {
1288 order.push("delete");
1289 }),
1290 };
1291
1292 yield* Effect.gen(function* () {
1293 return yield* DeleteFirstResource("R", { replaceString: "v2" });
1294 }).pipe(stack.deploy, hook(recordHooks));
1295
1296 // The whole point: delete-old precedes create-new.
1297 expect(order).toEqual(["delete", "create"]);
1298
1299 // The resource collapses straight to a terminal `created` state with
1300 // no leftover replacement chain for GC to drain.
1301 const state = yield* getState("R");
1302 expect(state?.status).toEqual("created");
1303 expect((state as { old?: unknown }).old).toBeUndefined();
1304 expect(yield* listState()).toHaveLength(1);
1305 }),
1306 );
1307
1308 test.provider(
1309 "default (non-deleteFirst) replacement still creates BEFORE deleting",
1310 (stack) =>
1311 Effect.gen(function* () {
1312 // `TestResource` returns a plain `{ action: "replace" }` (deleteFirst
1313 // defaults to false), so the engine must stay create-first.
1314 yield* stack.deploy(
1315 Effect.gen(function* () {
1316 return yield* TestResource("R", { replaceString: "v1" });
1317 }),
1318 );
1319
1320 const order: string[] = [];
1321 yield* Effect.gen(function* () {
1322 return yield* TestResource("R", { replaceString: "v2" });
1323 }).pipe(
1324 stack.deploy,
1325 hook({
1326 create: () =>
1327 Effect.sync(() => {
1328 order.push("create");
1329 }),
1330 update: () => Effect.succeed(undefined),
1331 delete: () =>
1332 Effect.sync(() => {
1333 order.push("delete");
1334 }),
1335 }),
1336 );
1337
1338 expect(order).toEqual(["create", "delete"]);
1339 }),
1340 );
1341
1342 test.provider(
1343 "lets a same-identity replacement succeed where create-first would collide",
1344 (stack) =>
1345 Effect.gen(function* () {
1346 // A shared registry of live physical names. The provider's create
1347 // fails if the (fixed) name is still live — exactly the failure mode
1348 // of a real fixed-name resource (Docker network "already exists",
1349 // no-op `volume create`) when create runs before the old is deleted.
1350 const registry = { live: new Set<string>() };
1351 const withRegistry = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
1352 effect.pipe(
1353 Effect.provide(Layer.succeed(CollisionRegistry, registry)),
1354 );
1355
1356 yield* stack
1357 .deploy(
1358 Effect.gen(function* () {
1359 return yield* DeleteFirstResource("R", {
1360 name: "singleton",
1361 replaceString: "v1",
1362 });
1363 }),
1364 )
1365 .pipe(withRegistry);
1366 expect(registry.live.has("singleton")).toBe(true);
1367
1368 // Before the fix this deploy died with a CollisionError because the
1369 // create of the new "singleton" ran while the old one was still live.
1370 const result = yield* stack
1371 .deploy(
1372 Effect.gen(function* () {
1373 return yield* DeleteFirstResource("R", {
1374 name: "singleton",
1375 replaceString: "v2",
1376 });
1377 }),
1378 )
1379 .pipe(withRegistry);
1380
1381 expect(result.name).toEqual("singleton");
1382 expect(result.replaceString).toEqual("v2");
1383 // Exactly one live instance remains (old torn down, new created).
1384 expect(registry.live.size).toBe(1);
1385 expect(registry.live.has("singleton")).toBe(true);
1386
1387 const state = yield* getState("R");
1388 expect(state?.status).toEqual("created");
1389 }),
1390 );
1391});
1392
1393describe("circularity via bindings", { tags: ["unit", "local"] }, () => {
1394 const selfBoundStack = (props: {
1395 string: string;
1396 replaceString?: string;
1397 includeD?: boolean;
1398 }) =>
1399 Effect.gen(function* () {
1400 const A = yield* BindingTarget("A", {
1401 name: "a",
1402 string: props.string,
1403 replaceString: props.replaceString,
1404 });
1405 yield* A.bind("SelfBinding", {
1406 env: {
1407 SELF: A.string,
1408 },
1409 });
1410 const B = yield* TestResource("B", { string: A.string });
1411 if (props.includeD) {
1412 const D = yield* TestResource("D", { string: B.string });
1413 return { A, B, D };
1414 }
1415 return { A, B };
1416 });
1417
1418 const mutualBindingStack = (props: {
1419 aString: string;
1420 aReplaceString?: string;
1421 bString?: string;
1422 includeD?: boolean;
1423 }) =>
1424 Effect.gen(function* () {
1425 const A = yield* BindingTarget("A", {
1426 name: "a",
1427 string: props.aString,
1428 replaceString: props.aReplaceString,
1429 });
1430 const B = yield* BindingTarget("B", {
1431 name: "b",
1432 string: props.bString ?? "b-value",
1433 });
1434 yield* A.bind("FromB", {
1435 env: {
1436 PEER: B.string,
1437 },
1438 });
1439 yield* B.bind("FromA", {
1440 env: {
1441 PEER: A.string,
1442 },
1443 });
1444 if (props.includeD) {
1445 const D = yield* TestResource("D", {
1446 string: Output.interpolate`${A.string}-${B.string}`,
1447 });
1448 return { A, B, D };
1449 }
1450 return { A, B };
1451 });
1452
1453 const propAndBindingCycleStack = () =>
1454 Effect.gen(function* () {
1455 const A = yield* BindingTarget("A", {
1456 name: "a",
1457 string: "a-value",
1458 });
1459 const B = yield* TestResource("B", {
1460 string: A.string,
1461 });
1462 yield* A.bind("FromB", {
1463 env: {
1464 PEER: B.string,
1465 },
1466 });
1467 return { A, B };
1468 });
1469
1470 test.provider(
1471 "create succeeds when props use precreate output and bindings use downstream output",
1472 (stack) =>
1473 Effect.gen(function* () {
1474 const output = yield* stack.deploy(propAndBindingCycleStack());
1475
1476 expect(output.A.env).toEqual({ PEER: "a-value" });
1477 expect(output.B.string).toEqual("a-value");
1478 expectConvergedStatus((yield* getState("A"))?.status);
1479 expectConvergedStatus((yield* getState("B"))?.status);
1480 }),
1481 { timeout: 10_000 },
1482 );
1483
1484 describe("self-referential bindings", () => {
1485 test.provider("create succeeds with self binding", (stack) =>
1486 Effect.gen(function* () {
1487 const output = yield* stack.deploy(
1488 selfBoundStack({
1489 string: "a-value",
1490 replaceString: "original",
1491 }),
1492 );
1493
1494 expect(output.A.env).toEqual({ SELF: "a-value" });
1495 expect(output.B.string).toEqual("a-value");
1496 expectConvergedStatus((yield* getState("A"))?.status);
1497 expectConvergedStatus((yield* getState("B"))?.status);
1498 }),
1499 );
1500
1501 test.provider(
1502 "replacing state noop replay recovers and creates downstream resources",
1503 (stack) =>
1504 Effect.gen(function* () {
1505 yield* selfBoundStack({
1506 string: "a-value",
1507 replaceString: "original",
1508 }).pipe(stack.deploy);
1509
1510 const program = selfBoundStack({
1511 string: "a-value-replaced",
1512 replaceString: "changed",
1513 includeD: true,
1514 });
1515
1516 yield* program.pipe(stack.deploy, hook(failOn("A", "create")));
1517
1518 expect(
1519 (yield* getState<ReplacingResourceState>("A"))?.status,
1520 ).toEqual("replacing");
1521 expectConvergedStatus((yield* getState("B"))?.status);
1522 expectNotStarted(yield* getState("D"));
1523
1524 const output = yield* program.pipe(stack.deploy);
1525 expectConvergedStatus((yield* getState("A"))?.status);
1526 expect((yield* getState("B"))?.status).toEqual("updated");
1527 expectConvergedStatus((yield* getState("D"))?.status);
1528 expect(output.A.env).toEqual({ SELF: "a-value-replaced" });
1529 expect(output.D!.string).toEqual("a-value-replaced");
1530 }),
1531 );
1532
1533 test.provider(
1534 "replacing state update replay updates replacement and creates downstream resources",
1535 (stack) =>
1536 Effect.gen(function* () {
1537 yield* selfBoundStack({
1538 string: "a-value",
1539 replaceString: "original",
1540 }).pipe(stack.deploy);
1541
1542 yield* selfBoundStack({
1543 string: "a-value-replaced",
1544 replaceString: "changed",
1545 includeD: true,
1546 }).pipe(stack.deploy, hook(failOn("A", "create")));
1547
1548 expect(
1549 (yield* getState<ReplacingResourceState>("A"))?.status,
1550 ).toEqual("replacing");
1551 expectConvergedStatus((yield* getState("B"))?.status);
1552 expectNotStarted(yield* getState("D"));
1553
1554 const output = yield* selfBoundStack({
1555 string: "a-value-updated-during-recovery",
1556 replaceString: "changed",
1557 includeD: true,
1558 }).pipe(stack.deploy);
1559
1560 expectConvergedStatus((yield* getState("A"))?.status);
1561 expect((yield* getState("B"))?.status).toEqual("updated");
1562 expectConvergedStatus((yield* getState("D"))?.status);
1563 expect(output.A.env).toEqual({
1564 SELF: "a-value-updated-during-recovery",
1565 });
1566 expect(output.D!.string).toEqual("a-value-updated-during-recovery");
1567 }),
1568 );
1569
1570 test.provider(
1571 "replaced state noop replay finishes cleanup and creates downstream resources",
1572 (stack) =>
1573 Effect.gen(function* () {
1574 yield* selfBoundStack({
1575 string: "a-value",
1576 replaceString: "original",
1577 }).pipe(stack.deploy);
1578
1579 const program = selfBoundStack({
1580 string: "a-value-replaced",
1581 replaceString: "changed",
1582 includeD: true,
1583 });
1584
1585 yield* program.pipe(stack.deploy, hook(failOn("B", "update")));
1586
1587 expect((yield* getState<ReplacedResourceState>("A"))?.status).toEqual(
1588 "replaced",
1589 );
1590 expect((yield* getState("B"))?.status).toEqual("updating");
1591 expectNotStarted(yield* getState("D"));
1592
1593 const output = yield* program.pipe(stack.deploy);
1594 expectConvergedStatus((yield* getState("A"))?.status);
1595 expect((yield* getState("B"))?.status).toEqual("updated");
1596 expectConvergedStatus((yield* getState("D"))?.status);
1597 expect(output.A.env).toEqual({ SELF: "a-value-replaced" });
1598 expect(output.D!.string).toEqual("a-value-replaced");
1599 }),
1600 );
1601
1602 test.provider(
1603 "replaced state update replay updates replacement and downstream resources",
1604 (stack) =>
1605 Effect.gen(function* () {
1606 yield* selfBoundStack({
1607 string: "a-value",
1608 replaceString: "original",
1609 }).pipe(stack.deploy);
1610
1611 yield* selfBoundStack({
1612 string: "a-value-replaced",
1613 replaceString: "changed",
1614 includeD: true,
1615 }).pipe(stack.deploy, hook(failOn("B", "update")));
1616
1617 expect((yield* getState<ReplacedResourceState>("A"))?.status).toEqual(
1618 "replaced",
1619 );
1620 expect((yield* getState("B"))?.status).toEqual("updating");
1621 expectNotStarted(yield* getState("D"));
1622
1623 const output = yield* selfBoundStack({
1624 string: "a-value-updated-after-replace",
1625 replaceString: "changed",
1626 includeD: true,
1627 }).pipe(stack.deploy);
1628
1629 expectConvergedStatus((yield* getState("A"))?.status);
1630 expect((yield* getState("B"))?.status).toEqual("updated");
1631 expectConvergedStatus((yield* getState("D"))?.status);
1632 expect(output.A.env).toEqual({
1633 SELF: "a-value-updated-after-replace",
1634 });
1635 expect(output.D!.string).toEqual("a-value-updated-after-replace");
1636 }),
1637 );
1638 });
1639
1640 describe("mutual A <-> B bindings", () => {
1641 test.provider("create succeeds with mutual bindings", (stack) =>
1642 Effect.gen(function* () {
1643 const output = yield* stack.deploy(
1644 mutualBindingStack({
1645 aString: "a-value",
1646 }),
1647 );
1648
1649 expect(output.A.env).toEqual({ PEER: "b-value" });
1650 expect(output.B.env).toEqual({ PEER: "a-value" });
1651 expectConvergedStatus((yield* getState("A"))?.status);
1652 expectConvergedStatus((yield* getState("B"))?.status);
1653 }),
1654 );
1655
1656 test.provider("destroy succeeds with mutual bindings", (stack) =>
1657 Effect.gen(function* () {
1658 yield* mutualBindingStack({
1659 aString: "a-value",
1660 }).pipe(stack.deploy);
1661
1662 yield* stack.destroy();
1663
1664 expect(yield* getState("A")).toBeUndefined();
1665 expectNotStarted(yield* getState("B"));
1666 }),
1667 );
1668
1669 describe("from replacing state", () => {
1670 test.provider(
1671 "replacing noop recovery creates downstream resources",
1672 (stack) =>
1673 Effect.gen(function* () {
1674 yield* mutualBindingStack({
1675 aString: "a-value",
1676 aReplaceString: "original",
1677 }).pipe(stack.deploy);
1678
1679 const program = mutualBindingStack({
1680 aString: "a-value-replaced",
1681 aReplaceString: "changed",
1682 includeD: true,
1683 });
1684
1685 yield* program.pipe(stack.deploy, hook(failOn("A", "create")));
1686
1687 expect(
1688 (yield* getState<ReplacingResourceState>("A"))?.status,
1689 ).toEqual("replacing");
1690 expectConvergedStatus((yield* getState("B"))?.status);
1691 expectNotStarted(yield* getState("D"));
1692
1693 const output = yield* program.pipe(stack.deploy);
1694 expectConvergedStatus((yield* getState("A"))?.status);
1695 expect((yield* getState("B"))?.status).toEqual("updated");
1696 expectConvergedStatus((yield* getState("D"))?.status);
1697 expect(output.A.env).toEqual({ PEER: "b-value" });
1698 expect(output.B.env).toEqual({ PEER: "a-value-replaced" });
1699 expect(output.D!.string).toEqual("a-value-replaced-b-value");
1700 }),
1701 );
1702
1703 test.provider(
1704 "replacing update recovery creates downstream resources",
1705 (stack) =>
1706 Effect.gen(function* () {
1707 yield* mutualBindingStack({
1708 aString: "a-value",
1709 aReplaceString: "original",
1710 }).pipe(stack.deploy);
1711
1712 yield* mutualBindingStack({
1713 aString: "a-value-replaced",
1714 aReplaceString: "changed",
1715 includeD: true,
1716 }).pipe(stack.deploy, hook(failOn("A", "create")));
1717
1718 expect(
1719 (yield* getState<ReplacingResourceState>("A"))?.status,
1720 ).toEqual("replacing");
1721 expectConvergedStatus((yield* getState("B"))?.status);
1722 expectNotStarted(yield* getState("D"));
1723
1724 const output = yield* mutualBindingStack({
1725 aString: "a-value-updated-during-recovery",
1726 aReplaceString: "changed",
1727 includeD: true,
1728 }).pipe(stack.deploy);
1729
1730 expectConvergedStatus((yield* getState("A"))?.status);
1731 expect((yield* getState("B"))?.status).toEqual("updated");
1732 expectConvergedStatus((yield* getState("D"))?.status);
1733 expect(output.A.env).toEqual({ PEER: "b-value" });
1734 expect(output.B.env).toEqual({
1735 PEER: "a-value-updated-during-recovery",
1736 });
1737 expect(output.D!.string).toEqual(
1738 "a-value-updated-during-recovery-b-value",
1739 );
1740 }),
1741 );
1742
1743 test.provider(
1744 "replacing replace recovery nests another replacement",
1745 (stack) =>
1746 Effect.gen(function* () {
1747 yield* mutualBindingStack({
1748 aString: "a-value",
1749 aReplaceString: "original",
1750 }).pipe(stack.deploy);
1751
1752 yield* mutualBindingStack({
1753 aString: "a-value-replaced",
1754 aReplaceString: "changed",
1755 includeD: true,
1756 }).pipe(stack.deploy, hook(failOn("A", "create")));
1757
1758 const output = yield* mutualBindingStack({
1759 aString: "a-value-another-replacement",
1760 aReplaceString: "another-change",
1761 includeD: true,
1762 }).pipe(stack.deploy);
1763
1764 expectConvergedStatus((yield* getState("A"))?.status);
1765 expect((yield* getState("B"))?.status).toEqual("updated");
1766 expectConvergedStatus((yield* getState("D"))?.status);
1767 expect(output.B.env).toEqual({
1768 PEER: "a-value-another-replacement",
1769 });
1770 }),
1771 );
1772 });
1773
1774 describe("from replaced state", () => {
1775 test.provider(
1776 "replaced noop recovery updates downstream then creates downstream resources",
1777 (stack) =>
1778 Effect.gen(function* () {
1779 yield* mutualBindingStack({
1780 aString: "a-value",
1781 aReplaceString: "original",
1782 }).pipe(stack.deploy);
1783
1784 const program = mutualBindingStack({
1785 aString: "a-value-replaced",
1786 aReplaceString: "changed",
1787 includeD: true,
1788 });
1789
1790 yield* program.pipe(stack.deploy, hook(failOn("B", "update")));
1791
1792 expect(
1793 (yield* getState<ReplacedResourceState>("A"))?.status,
1794 ).toEqual("replaced");
1795 expect((yield* getState("B"))?.status).toEqual("updating");
1796 expectNotStarted(yield* getState("D"));
1797
1798 const output = yield* program.pipe(stack.deploy);
1799 expect((yield* getState("A"))?.status).toEqual("created");
1800 expect((yield* getState("B"))?.status).toEqual("updated");
1801 expectConvergedStatus((yield* getState("D"))?.status);
1802 expect(output.A.env).toEqual({ PEER: "b-value" });
1803 expect(output.B.env).toEqual({ PEER: "a-value-replaced" });
1804 expect(output.D!.string).toEqual("a-value-replaced-b-value");
1805 }),
1806 );
1807
1808 test.provider(
1809 "replaced with update recovery updates replacement and downstream resources",
1810 (stack) =>
1811 Effect.gen(function* () {
1812 yield* mutualBindingStack({
1813 aString: "a-value",
1814 aReplaceString: "original",
1815 }).pipe(stack.deploy);
1816
1817 yield* mutualBindingStack({
1818 aString: "a-value-replaced",
1819 aReplaceString: "changed",
1820 includeD: true,
1821 }).pipe(stack.deploy, hook(failOn("B", "update")));
1822
1823 expect(
1824 (yield* getState<ReplacedResourceState>("A"))?.status,
1825 ).toEqual("replaced");
1826 expect((yield* getState("B"))?.status).toEqual("updating");
1827 expectNotStarted(yield* getState("D"));
1828
1829 const output = yield* mutualBindingStack({
1830 aString: "a-value-updated-after-replace",
1831 aReplaceString: "changed",
1832 includeD: true,
1833 }).pipe(stack.deploy);
1834
1835 expect((yield* getState("A"))?.status).toEqual("created");
1836 expect((yield* getState("B"))?.status).toEqual("updated");
1837 expectConvergedStatus((yield* getState("D"))?.status);
1838 expect(output.A.env).toEqual({ PEER: "b-value" });
1839 expect(output.B.env).toEqual({
1840 PEER: "a-value-updated-after-replace",
1841 });
1842 expect(output.D!.string).toEqual(
1843 "a-value-updated-after-replace-b-value",
1844 );
1845 }),
1846 );
1847
1848 test.provider(
1849 "replaced replace recovery nests another replacement",
1850 (stack) =>
1851 Effect.gen(function* () {
1852 yield* mutualBindingStack({
1853 aString: "a-value",
1854 aReplaceString: "original",
1855 }).pipe(stack.deploy);
1856
1857 yield* mutualBindingStack({
1858 aString: "a-value-replaced",
1859 aReplaceString: "changed",
1860 includeD: true,
1861 }).pipe(stack.deploy, hook(failOn("B", "update")));
1862
1863 const output = yield* mutualBindingStack({
1864 aString: "a-value-another-replacement",
1865 aReplaceString: "another-change",
1866 includeD: true,
1867 }).pipe(stack.deploy);
1868
1869 expectConvergedStatus((yield* getState("A"))?.status);
1870 expect((yield* getState("B"))?.status).toEqual("updated");
1871 expectConvergedStatus((yield* getState("D"))?.status);
1872 expect(output.B.env).toEqual({
1873 PEER: "a-value-another-replacement",
1874 });
1875 }),
1876 );
1877 });
1878 });
1879});
1880
1881describe("prop-flow convergence", { tags: ["unit", "local"] }, () => {
1882 test.provider(
1883 "downstream outside a cycle waits for precreated upstream reconciliation",
1884 (stack) =>
1885 Effect.gen(function* () {
1886 let reconciled = false;
1887 const observations: boolean[] = [];
1888 yield* Effect.gen(function* () {
1889 const worker = yield* PhasedTarget("Worker", {
1890 desired: "queue-handler",
1891 replaceKey: "v1",
1892 });
1893 const binding = yield* TestResource("Binding", {
1894 string: worker.stableId,
1895 });
1896 yield* worker.bind("Feedback", { env: { B: binding.string } });
1897 // Like a Queue Consumer: the script name is available from the
1898 // stub, but attaching requires the real Worker upload to finish.
1899 return yield* TestResource("Consumer", { string: worker.stableId });
1900 }).pipe(
1901 stack.deploy,
1902 hook({
1903 create: (id) =>
1904 Effect.gen(function* () {
1905 if (id === "Worker") {
1906 yield* Effect.sleep("50 millis");
1907 reconciled = true;
1908 } else if (id === "Consumer") {
1909 observations.push(reconciled);
1910 }
1911 }),
1912 }),
1913 );
1914 expect(observations).toEqual([true]);
1915 }),
1916 );
1917
1918 const phasedCycleStack = (props: {
1919 desired: string;
1920 replaceKey?: string;
1921 use: "stableId" | "value";
1922 includeC?: boolean;
1923 }) =>
1924 Effect.gen(function* () {
1925 const A = yield* PhasedTarget("A", {
1926 desired: props.desired,
1927 replaceKey: props.replaceKey,
1928 });
1929 const selected = props.use === "stableId" ? A.stableId : A.value;
1930 const B = yield* TestResource("B", {
1931 string: selected,
1932 });
1933 yield* A.bind("FromB", {
1934 env: {
1935 B: B.string,
1936 },
1937 });
1938
1939 if (props.includeC) {
1940 const C = yield* TestResource("C", {
1941 string: B.string,
1942 });
1943 return { A, B, C };
1944 }
1945
1946 return { A, B };
1947 });
1948
1949 test.provider(
1950 "fresh circular create may use a stable precreate identifier",
1951 (stack) =>
1952 Effect.gen(function* () {
1953 const output = yield* phasedCycleStack({
1954 desired: "final-a",
1955 replaceKey: "v1",
1956 use: "stableId",
1957 }).pipe(stack.deploy);
1958
1959 expect(output.A.value).toEqual("final-a");
1960 expect(output.B.string).toEqual("stable:v1");
1961 }),
1962 );
1963
1964 test.provider(
1965 "fresh circular create should converge downstream props to final values",
1966 (stack) =>
1967 Effect.gen(function* () {
1968 const output = yield* phasedCycleStack({
1969 desired: "final-a",
1970 replaceKey: "v1",
1971 use: "value",
1972 }).pipe(stack.deploy);
1973
1974 expect(output.A.value).toEqual("final-a");
1975 expect(output.B.string).toEqual("final-a");
1976 }),
1977 );
1978
1979 test.provider(
1980 "fresh replacement should converge newly created downstream props to replacement values",
1981 (stack) =>
1982 Effect.gen(function* () {
1983 yield* phasedCycleStack({
1984 desired: "old-a",
1985 replaceKey: "v1",
1986 use: "value",
1987 }).pipe(stack.deploy);
1988
1989 const output = yield* phasedCycleStack({
1990 desired: "new-a",
1991 replaceKey: "v2",
1992 use: "value",
1993 }).pipe(stack.deploy);
1994
1995 expect(output.A.value).toEqual("new-a");
1996 expect(output.B.string).toEqual("new-a");
1997 }),
1998 );
1999
2000 test.provider(
2001 "stale precreate values should not propagate transitively",
2002 (stack) =>
2003 Effect.gen(function* () {
2004 const output = yield* phasedCycleStack({
2005 desired: "final-a",
2006 replaceKey: "v1",
2007 use: "value",
2008 includeC: true,
2009 }).pipe(stack.deploy);
2010
2011 expect(output.A.value).toEqual("final-a");
2012 expect(output.B.string).toEqual("final-a");
2013 expect(output.C!.string).toEqual("final-a");
2014 }),
2015 );
2016
2017 test.provider(
2018 "binding feedback converges across an A -> B -> A fixed point",
2019 (stack) =>
2020 Effect.gen(function* () {
2021 const output = yield* Effect.gen(function* () {
2022 const A = yield* PhasedTarget("A", {
2023 desired: "final-a",
2024 replaceKey: "v1",
2025 });
2026 const B = yield* TestResource("B", {
2027 string: A.value,
2028 });
2029 yield* A.bind("FromB", {
2030 env: {
2031 B: B.string,
2032 },
2033 });
2034 return { A, B };
2035 }).pipe(stack.deploy);
2036
2037 expect(output.A.value).toEqual("final-a");
2038 expect(output.B.string).toEqual("final-a");
2039 expect(output.A.env).toEqual({
2040 B: "final-a",
2041 });
2042 }),
2043 );
2044
2045 test.provider(
2046 "terminal created or updated status is delayed until fixed-point convergence finishes",
2047 (stack) =>
2048 Effect.gen(function* () {
2049 const events: Array<{ id: string; status: string }> = [];
2050 const cli = Cli.of({
2051 startPlanningSession: () =>
2052 Effect.succeed({
2053 update: () => Effect.void,
2054 succeed: () => Effect.void,
2055 fail: () => Effect.void,
2056 close: Effect.void,
2057 }),
2058 approvePlan: () => Effect.succeed(true),
2059 displayPlan: () => Effect.void,
2060 startApplySession: () =>
2061 Effect.succeed({
2062 done: () => Effect.void,
2063 emit: (event) =>
2064 Effect.sync(() => {
2065 if (event._tag === "apply.resource.status") {
2066 events.push({
2067 id: event.id,
2068 status: event.status,
2069 });
2070 }
2071 }),
2072 }),
2073 });
2074
2075 const output = yield* Effect.gen(function* () {
2076 const A = yield* PhasedTarget("A", {
2077 desired: "final-a",
2078 replaceKey: "v1",
2079 });
2080 const B = yield* TestResource("B", {
2081 string: A.value,
2082 });
2083 yield* A.bind("FromB", {
2084 env: {
2085 B: B.string,
2086 },
2087 });
2088 return { A, B };
2089 }).pipe(stack.deploy, Effect.provide(Layer.succeed(Cli, cli)));
2090
2091 expect(output.A.env).toEqual({
2092 B: "final-a",
2093 });
2094
2095 const statusesById = events.reduce(
2096 (acc, event: { id: string; status: string }) => {
2097 (acc[event.id] ??= []).push(event);
2098 return acc;
2099 },
2100 {} as Record<string, Array<{ id: string; status: string }>>,
2101 );
2102 const terminal = (id: string) =>
2103 (statusesById[id] ?? [])
2104 .map((event: { id: string; status: string }) => event.status)
2105 .filter(
2106 (status: string) => status === "created" || status === "updated",
2107 );
2108
2109 expect(terminal("A")).toEqual(["updated"]);
2110 expect(terminal("B")).toEqual(["updated"]);
2111 }),
2112 );
2113
2114 test.provider("apply sessions finalize after a resource failure", (stack) =>
2115 Effect.gen(function* () {
2116 let finalized = 0;
2117 const cli = Cli.of({
2118 startPlanningSession: () =>
2119 Effect.succeed({
2120 update: () => Effect.void,
2121 succeed: () => Effect.void,
2122 fail: () => Effect.void,
2123 close: Effect.void,
2124 }),
2125 approvePlan: () => Effect.succeed(true),
2126 displayPlan: () => Effect.void,
2127 startApplySession: () =>
2128 Effect.succeed({
2129 done: () =>
2130 Effect.sync(() => {
2131 finalized += 1;
2132 }),
2133 emit: () => Effect.void,
2134 }),
2135 });
2136
2137 yield* TestResource("A", { string: "value" }).pipe(
2138 stack.deploy,
2139 hook(failOn("A", "create")),
2140 Effect.provide(Layer.succeed(Cli, cli)),
2141 );
2142
2143 expect(finalized).toBe(1);
2144 }),
2145 );
2146
2147 // Regression: a resource with `precreate` (e.g. Cloudflare Worker) resolves
2148 // its early `ready` signal before its real `reconcile` runs. A non-cyclic
2149 // downstream must still wait for the upstream's TERMINAL output, so that an
2150 // upstream `reconcile` failure interrupts the downstream instead of letting
2151 // it proceed off the precreate stub. Before the fix the downstream raced
2152 // ahead on the precreate identifier and fully created itself even though the
2153 // upstream failed.
2154 test.provider(
2155 "precreate upstream reconcile failure interrupts non-cyclic downstream (stable id dep)",
2156 (stack) =>
2157 Effect.gen(function* () {
2158 const program = Effect.gen(function* () {
2159 const A = yield* PhasedTarget("A", {
2160 desired: "a-value",
2161 replaceKey: "v1",
2162 });
2163 // B depends on A.stableId — a value already available from A's
2164 // precreate stub — yet must still be gated on A's reconcile.
2165 const B = yield* TestResource("B", {
2166 string: A.stableId,
2167 });
2168 return { A, B };
2169 });
2170
2171 yield* program.pipe(stack.deploy, hook(failOn("A", "create")));
2172
2173 // A's reconcile failed after committing "creating".
2174 expect((yield* getState("A"))?.status).toEqual("creating");
2175 // B must NOT have reached its own reconcile. It may have committed an
2176 // intermediate "creating" while waiting on deps, but it must never be
2177 // "created" — that would mean the upstream failure was ignored.
2178 expect((yield* getState("B"))?.status).not.toEqual("created");
2179
2180 // Recovery deploy converges both.
2181 const output = yield* program.pipe(stack.deploy);
2182 expectConvergedStatus((yield* getState("A"))?.status);
2183 expectConvergedStatus((yield* getState("B"))?.status);
2184 expect(output.B.string).toEqual("stable:v1");
2185 }),
2186 );
2187
2188 test.provider(
2189 "precreate upstream reconcile failure interrupts non-cyclic downstream (value dep)",
2190 (stack) =>
2191 Effect.gen(function* () {
2192 const program = Effect.gen(function* () {
2193 const A = yield* PhasedTarget("A", {
2194 desired: "a-value",
2195 replaceKey: "v1",
2196 });
2197 const B = yield* TestResource("B", {
2198 string: A.value,
2199 });
2200 const C = yield* TestResource("C", {
2201 string: B.string,
2202 });
2203 return { A, B, C };
2204 });
2205
2206 yield* program.pipe(stack.deploy, hook(failOn("A", "create")));
2207
2208 expect((yield* getState("A"))?.status).toEqual("creating");
2209 expect((yield* getState("B"))?.status).not.toEqual("created");
2210 // Transitive downstream never starts either.
2211 expectNotStarted(yield* getState("C"));
2212
2213 const output = yield* program.pipe(stack.deploy);
2214 expectConvergedStatus((yield* getState("A"))?.status);
2215 expectConvergedStatus((yield* getState("B"))?.status);
2216 expectConvergedStatus((yield* getState("C"))?.status);
2217 expect(output.C.string).toEqual("a-value");
2218 }),
2219 );
2220});
2221
2222describe("from created state", { tags: ["unit", "local"] }, () => {
2223 test.provider("noop when props unchanged", (stack) =>
2224 Effect.gen(function* () {
2225 const program = Effect.gen(function* () {
2226 const A = yield* TestResource("A", {
2227 string: "test-string",
2228 });
2229 return A.string;
2230 });
2231
2232 let output = yield* stack.deploy(program);
2233 expect(output).toEqual("test-string");
2234
2235 expect((yield* getState("A"))?.status).toEqual("created");
2236 output = yield* stack.deploy(program);
2237
2238 // Re-apply with same props - should be noop
2239 expect((yield* getState("A"))?.status).toEqual("created");
2240 expect(output).toEqual("test-string");
2241 }),
2242 );
2243
2244 test.provider("replace when props trigger replacement", (stack) =>
2245 Effect.gen(function* () {
2246 yield* stack.deploy(
2247 Effect.gen(function* () {
2248 const A = yield* TestResource("A", {
2249 replaceString: "original",
2250 });
2251 return A.replaceString;
2252 }),
2253 );
2254 expect((yield* getState("A"))?.status).toEqual("created");
2255
2256 // Change props that trigger replacement
2257
2258 const output = yield* stack.deploy(
2259 Effect.gen(function* () {
2260 const A = yield* TestResource("A", {
2261 replaceString: "new",
2262 });
2263 return A.replaceString;
2264 }),
2265 );
2266 expect((yield* getState("A"))?.status).toEqual("created");
2267 expect(output).toEqual("new");
2268 }),
2269 );
2270});
2271
2272describe("from updated state", { tags: ["unit", "local"] }, () => {
2273 test.provider("noop when props unchanged", (stack) =>
2274 Effect.gen(function* () {
2275 yield* stack.deploy(
2276 Effect.gen(function* () {
2277 yield* TestResource("A", {
2278 string: "test-string",
2279 });
2280 }),
2281 );
2282 expect((yield* getState("A"))?.status).toEqual("created");
2283
2284 // Update to get to updated state
2285 yield* stack.deploy(
2286 Effect.gen(function* () {
2287 yield* TestResource("A", {
2288 string: "test-string-changed",
2289 });
2290 }),
2291 );
2292 expect((yield* getState("A"))?.status).toEqual("updated");
2293
2294 // Re-apply with same props - should be noop
2295 const output = yield* stack.deploy(
2296 Effect.gen(function* () {
2297 const A = yield* TestResource("A", {
2298 string: "test-string-changed",
2299 });
2300 return A.string;
2301 }),
2302 );
2303 expect((yield* getState("A"))?.status).toEqual("updated");
2304 expect(output).toEqual("test-string-changed");
2305 }),
2306 );
2307
2308 test.provider("replace when props trigger replacement", (stack) =>
2309 Effect.gen(function* () {
2310 yield* stack.deploy(
2311 Effect.gen(function* () {
2312 yield* TestResource("A", {
2313 string: "test-string",
2314 replaceString: "original",
2315 });
2316 }),
2317 );
2318 expect((yield* getState("A"))?.status).toEqual("created");
2319
2320 // Update to get to updated state
2321 yield* stack.deploy(
2322 Effect.gen(function* () {
2323 yield* TestResource("A", {
2324 string: "test-string-changed",
2325 replaceString: "original",
2326 });
2327 }),
2328 );
2329 expect((yield* getState("A"))?.status).toEqual("updated");
2330
2331 // Change props that trigger replacement
2332 const output = yield* stack.deploy(
2333 Effect.gen(function* () {
2334 const A = yield* TestResource("A", {
2335 string: "test-string-changed",
2336 replaceString: "new",
2337 });
2338 return A.replaceString;
2339 }),
2340 );
2341 expect((yield* getState("A"))?.status).toEqual("created");
2342 expect(output).toEqual("new");
2343 }),
2344 );
2345});
2346
2347describe("from creating state", { tags: ["unit", "local"] }, () => {
2348 test.provider("continue creating when props unchanged", (stack) =>
2349 Effect.gen(function* () {
2350 yield* Effect.gen(function* () {
2351 yield* TestResource("A", {
2352 string: "test-string",
2353 });
2354 }).pipe(stack.deploy, hook());
2355 expect((yield* getState("A"))?.status).toEqual("creating");
2356
2357 const output = yield* Effect.gen(function* () {
2358 const A = yield* TestResource("A", {
2359 string: "test-string",
2360 });
2361 return A.string;
2362 }).pipe(stack.deploy);
2363 expect((yield* getState("A"))?.status).toEqual("created");
2364 expect(output).toEqual("test-string");
2365 }),
2366 );
2367
2368 test.provider(
2369 "continue creating when props have updatable changes",
2370 (stack) =>
2371 Effect.gen(function* () {
2372 yield* Effect.gen(function* () {
2373 yield* TestResource("A", {
2374 string: "test-string",
2375 });
2376 }).pipe(stack.deploy, hook());
2377 expect((yield* getState("A"))?.status).toEqual("creating");
2378
2379 const output = yield* Effect.gen(function* () {
2380 const A = yield* TestResource("A", {
2381 string: "test-string-changed",
2382 });
2383 return A.string;
2384 }).pipe(stack.deploy);
2385 expect(output).toEqual("test-string-changed");
2386 expect((yield* getState("A"))?.status).toEqual("created");
2387 }),
2388 );
2389
2390 test.provider("replace when props trigger replacement", (stack) =>
2391 Effect.gen(function* () {
2392 yield* Effect.gen(function* () {
2393 yield* TestResource("A", {
2394 replaceString: "test-string",
2395 });
2396 }).pipe(stack.deploy, hook());
2397 expect((yield* getState("A"))?.status).toEqual("creating");
2398
2399 const output = yield* Effect.gen(function* () {
2400 const A = yield* TestResource("A", {
2401 replaceString: "test-string-changed",
2402 });
2403 return A.replaceString;
2404 }).pipe(stack.deploy);
2405 expect(output).toEqual("test-string-changed");
2406 expect((yield* getState("A"))?.status).toEqual("created");
2407 }),
2408 );
2409
2410 test.provider(
2411 "destroy should handle creating state with no attributes",
2412 (stack) =>
2413 Effect.gen(function* () {
2414 // 1. Create a resource but fail - this leaves state in "creating" with no attr
2415 yield* Effect.gen(function* () {
2416 yield* TestResource("A", {
2417 string: "test-string",
2418 });
2419 }).pipe(stack.deploy, hook());
2420 expect((yield* getState("A"))?.status).toEqual("creating");
2421 expect((yield* getState("A"))?.attr).toBeUndefined();
2422
2423 // 2. Call destroy - this triggers collectGarbage which tries to delete
2424 // the orphaned resource. The bug is that output is undefined in the
2425 // delete call when the resource never completed creation.
2426 yield* stack.destroy();
2427
2428 // Resource should be cleaned up
2429 expect(yield* getState("A")).toBeUndefined();
2430 }),
2431 );
2432
2433 test.provider(
2434 "destroy should handle creating state when attributes can be recovered",
2435 (stack) =>
2436 Effect.gen(function* () {
2437 yield* Effect.gen(function* () {
2438 yield* TestResource("A", {
2439 string: "test-string",
2440 });
2441 }).pipe(stack.deploy, hook());
2442 expect((yield* getState("A"))?.status).toEqual("creating");
2443 expect((yield* getState("A"))?.attr).toBeUndefined();
2444
2445 yield* stack.destroy().pipe(
2446 hook({
2447 delete: () => Effect.fail(new ResourceFailure()),
2448 read: () =>
2449 Effect.succeed({
2450 string: "test-string",
2451 }),
2452 }),
2453 );
2454
2455 // Resource should be cleaned up
2456 expect((yield* getState("A"))?.status).toEqual("deleting");
2457
2458 // actually delete this time
2459 yield* stack.destroy().pipe(
2460 hook({
2461 read: () =>
2462 Effect.succeed({
2463 string: "test-string",
2464 }),
2465 }),
2466 );
2467
2468 expect(yield* getState("A")).toBeUndefined();
2469 }),
2470 );
2471
2472 test.provider(
2473 "destroy should handle replacing state when old resource has no attributes",
2474 (stack) =>
2475 Effect.gen(function* () {
2476 // 1. Create a resource but fail - this leaves state in "creating" with no attr
2477 yield* Effect.gen(function* () {
2478 yield* TestResource("A", {
2479 replaceString: "original",
2480 });
2481 }).pipe(stack.deploy, hook());
2482 expect((yield* getState("A"))?.status).toEqual("creating");
2483 expect((yield* getState("A"))?.attr).toBeUndefined();
2484
2485 // 2. Trigger replacement but also fail during create - this leaves state in "replacing"
2486 // with old.attr being undefined
2487 yield* Effect.gen(function* () {
2488 yield* TestResource("A", {
2489 replaceString: "new",
2490 });
2491 }).pipe(stack.deploy, hook());
2492 const state = yield* getState<ReplacingResourceState>("A");
2493 expect(state?.status).toEqual("replacing");
2494 expect(state?.old?.attr).toBeUndefined();
2495
2496 // 3. Call destroy - this triggers collectGarbage which tries to delete
2497 // the resource. The bug is that old.attr is undefined.
2498 yield* stack.destroy().pipe(
2499 hook({
2500 read: () =>
2501 Effect.succeed({
2502 replaceString: "original",
2503 }),
2504 }),
2505 );
2506
2507 // Resource should be cleaned up
2508 expect(yield* getState("A")).toBeUndefined();
2509 }),
2510 );
2511
2512 // ── attr-less `creating` rows must not orphan the physical resource ──
2513 //
2514 // A create can be interrupted AFTER the cloud-side call succeeded but
2515 // BEFORE reconcile returned Attributes, leaving a `creating` row with
2516 // `attr === undefined`. Destroy used to skip `provider.delete` entirely
2517 // for such rows and drop the state, silently orphaning the physical
2518 // resource. The engine now read-then-deletes: `provider.read` recovers
2519 // the attributes from the persisted props (deterministic physical name),
2520 // and only a confirmed-missing or Unowned resource skips the delete.
2521
2522 test.provider(
2523 "destroy deletes the recovered physical resource of an attr-less creating row",
2524 (stack) =>
2525 Effect.gen(function* () {
2526 // Interrupted create: cloud-side create "succeeded" but no attrs
2527 // were ever persisted.
2528 yield* Effect.gen(function* () {
2529 yield* TestResource("A", {
2530 string: "test-string",
2531 });
2532 }).pipe(stack.deploy, hook());
2533 expect((yield* getState("A"))?.status).toEqual("creating");
2534 expect((yield* getState("A"))?.attr).toBeUndefined();
2535
2536 const deleted: string[] = [];
2537 yield* stack.destroy().pipe(
2538 hook({
2539 read: () => Effect.succeed({ string: "test-string" }),
2540 delete: (id) => Effect.sync(() => void deleted.push(id)),
2541 }),
2542 );
2543
2544 // The physical resource recovered by `read` was actually deleted —
2545 // not silently orphaned.
2546 expect(deleted).toEqual(["A"]);
2547 expect(yield* getState("A")).toBeUndefined();
2548 }),
2549 );
2550
2551 test.provider(
2552 "destroy never deletes a recovered resource that is Unowned",
2553 (stack) =>
2554 Effect.gen(function* () {
2555 yield* Effect.gen(function* () {
2556 yield* TestResource("A", {
2557 string: "test-string",
2558 });
2559 }).pipe(stack.deploy, hook());
2560 expect((yield* getState("A"))?.status).toEqual("creating");
2561
2562 const deleted: string[] = [];
2563 yield* stack.destroy().pipe(
2564 hook({
2565 // The physical name exists but belongs to someone else — our
2566 // interrupted create actually lost a name race (or died before
2567 // stamping ownership).
2568 read: () => Effect.succeed(Unowned({ string: "foreign" })),
2569 delete: (id) => Effect.sync(() => void deleted.push(id)),
2570 }),
2571 );
2572
2573 // Foreign resources are left in place; only our state is dropped.
2574 expect(deleted).toEqual([]);
2575 expect(yield* getState("A")).toBeUndefined();
2576 }),
2577 );
2578
2579 test.provider(
2580 "destroy tolerates not-found for an attr-less creating row",
2581 (stack) =>
2582 Effect.gen(function* () {
2583 yield* Effect.gen(function* () {
2584 yield* TestResource("A", {
2585 string: "test-string",
2586 });
2587 }).pipe(stack.deploy, hook());
2588 expect((yield* getState("A"))?.status).toEqual("creating");
2589
2590 const deleted: string[] = [];
2591 yield* stack.destroy().pipe(
2592 hook({
2593 read: () => Effect.succeed(undefined),
2594 delete: (id) => Effect.sync(() => void deleted.push(id)),
2595 }),
2596 );
2597
2598 // Nothing exists cloud-side — delete is not invoked, state is dropped.
2599 expect(deleted).toEqual([]);
2600 expect(yield* getState("A")).toBeUndefined();
2601 }),
2602 );
2603
2604 test.provider(
2605 "destroy survives a recovery read that crashes on degraded creating props",
2606 (stack) =>
2607 Effect.gen(function* () {
2608 // An interrupted create can persist `creating` props whose
2609 // unresolved Outputs were stripped to holes; a provider read that
2610 // dereferences one crashes with a defect (e.g. a SchemaError deep
2611 // in its SDK client, see #995). Destroy must degrade to "nothing
2612 // recovered" and drop the row instead of bricking the stage.
2613 yield* Effect.gen(function* () {
2614 yield* TestResource("A", {
2615 string: "test-string",
2616 });
2617 }).pipe(stack.deploy, hook());
2618 expect((yield* getState("A"))?.status).toEqual("creating");
2619
2620 const deleted: string[] = [];
2621 yield* stack.destroy().pipe(
2622 hook({
2623 read: () =>
2624 Effect.die(
2625 new Error("SchemaError: Expected string, got undefined"),
2626 ),
2627 delete: (id) => Effect.sync(() => void deleted.push(id)),
2628 }),
2629 );
2630
2631 // Recovery failed — delete is not invoked, state is still dropped.
2632 expect(deleted).toEqual([]);
2633 expect(yield* getState("A")).toBeUndefined();
2634 }),
2635 );
2636
2637 test.provider(
2638 "destroy drops an attr-less creating row when the provider has no read",
2639 (stack) =>
2640 Effect.gen(function* () {
2641 // Manufacture the interrupted-create row directly: Test.Queue's
2642 // provider implements no `read`, so recovery is impossible and the
2643 // engine can only drop the row (surfacing a note, not crashing).
2644 const state = yield* yield* State;
2645 const stk = yield* Stack;
2646 yield* state.set({
2647 stack: stk.name,
2648 stage: stk.stage,
2649 fqn: "Q",
2650 value: {
2651 kind: "resource",
2652 status: "creating",
2653 resourceType: "Test.Queue",
2654 namespace: undefined,
2655 fqn: "Q",
2656 logicalId: "Q",
2657 instanceId: "q-instance",
2658 providerVersion: 0,
2659 downstream: [],
2660 bindings: [],
2661 props: { name: "q" },
2662 } satisfies CreatingResourceState,
2663 });
2664
2665 yield* stack.destroy();
2666 expect(yield* getState("Q")).toBeUndefined();
2667 }),
2668 );
2669
2670 test.provider(
2671 "replacement drain recovers and deletes an attr-less old generation",
2672 (stack) =>
2673 Effect.gen(function* () {
2674 // 1. Interrupted create — `creating` row with no attr.
2675 yield* Effect.gen(function* () {
2676 yield* TestResource("A", {
2677 replaceString: "original",
2678 });
2679 }).pipe(stack.deploy, hook());
2680 expect((yield* getState("A"))?.status).toEqual("creating");
2681 expect((yield* getState("A"))?.attr).toBeUndefined();
2682
2683 // 2. Deploy a replacement. The first `read` (plan-time create-resume
2684 // probe) reports not-found so the engine plans a replacement instead
2685 // of resuming the create; the drain-time `read` then discovers the
2686 // physical resource the interrupted create actually made, and the
2687 // engine must delete it while draining the replaced old generation.
2688 const deleted: string[] = [];
2689 let reads = 0;
2690 yield* Effect.gen(function* () {
2691 yield* TestResource("A", {
2692 replaceString: "new",
2693 });
2694 }).pipe(
2695 stack.deploy,
2696 hook({
2697 read: () =>
2698 Effect.sync(() => ++reads).pipe(
2699 Effect.map((n) =>
2700 n === 1 ? undefined : { replaceString: "original" },
2701 ),
2702 ),
2703 delete: (id) => Effect.sync(() => void deleted.push(id)),
2704 }),
2705 );
2706
2707 // The old generation's physical resource was recovered and deleted,
2708 // and the replacement collapsed to a stable `created` row.
2709 expect(deleted).toEqual(["A"]);
2710 expect((yield* getState("A"))?.status).toEqual("created");
2711 expect(
2712 ((yield* getState("A"))?.attr as TestResourceProps)?.replaceString,
2713 ).toEqual("new");
2714 }),
2715 );
2716
2717 test.provider(
2718 "resuming an interrupted create fails loudly when the recovered resource is Unowned",
2719 (stack) =>
2720 Effect.gen(function* () {
2721 yield* Effect.gen(function* () {
2722 yield* TestResource("A", {
2723 string: "test-string",
2724 });
2725 }).pipe(stack.deploy, hook());
2726 expect((yield* getState("A"))?.status).toEqual("creating");
2727
2728 const exit = yield* Effect.gen(function* () {
2729 yield* TestResource("A", {
2730 string: "test-string",
2731 });
2732 }).pipe(
2733 stack.deploy,
2734 hook({
2735 read: () => Effect.succeed(Unowned({ string: "foreign" })),
2736 }),
2737 Effect.exit,
2738 );
2739
2740 expect(Exit.isFailure(exit)).toBe(true);
2741 if (Exit.isFailure(exit)) {
2742 const reason = exit.cause.reasons.find(Cause.isFailReason);
2743 expect((reason?.error as any)?._tag).toBe("OwnedBySomeoneElse");
2744 }
2745 // The row is untouched — the user can re-run with --adopt.
2746 expect((yield* getState("A"))?.status).toEqual("creating");
2747 }),
2748 );
2749
2750 test.provider(
2751 "resuming an interrupted create with adopt(true) takes over an Unowned resource",
2752 (stack) =>
2753 Effect.gen(function* () {
2754 yield* Effect.gen(function* () {
2755 yield* TestResource("A", {
2756 string: "test-string",
2757 });
2758 }).pipe(stack.deploy, hook());
2759 expect((yield* getState("A"))?.status).toEqual("creating");
2760
2761 yield* Effect.gen(function* () {
2762 yield* TestResource("A", {
2763 string: "test-string",
2764 });
2765 }).pipe(
2766 adopt(true),
2767 stack.deploy,
2768 hook({
2769 read: () => Effect.succeed(Unowned({ string: "test-string" })),
2770 }),
2771 );
2772
2773 const persisted = yield* getState("A");
2774 expect(persisted?.status).toEqual("created");
2775 // The Unowned brand never reaches persisted state.
2776 expect(Unowned.is(persisted?.attr)).toBe(false);
2777 }),
2778 );
2779});
2780
2781describe("from updating state", { tags: ["unit", "local"] }, () => {
2782 test.provider("continue updating when props unchanged", (stack) =>
2783 Effect.gen(function* () {
2784 yield* Effect.gen(function* () {
2785 yield* TestResource("A", {
2786 string: "test-string",
2787 });
2788 }).pipe(stack.deploy);
2789 expect((yield* getState("A"))?.status).toEqual("created");
2790
2791 yield* Effect.gen(function* () {
2792 yield* TestResource("A", {
2793 string: "test-string-changed",
2794 });
2795 }).pipe(
2796 stack.deploy,
2797 hook({
2798 update: () => Effect.fail(new ResourceFailure()),
2799 }),
2800 );
2801 expect((yield* getState("A"))?.status).toEqual("updating");
2802
2803 const output = yield* Effect.gen(function* () {
2804 const A = yield* TestResource("A", {
2805 string: "test-string-changed",
2806 });
2807 return A.string;
2808 }).pipe(stack.deploy);
2809 expect((yield* getState("A"))?.status).toEqual("updated");
2810 expect(output).toEqual("test-string-changed");
2811 }),
2812 );
2813
2814 test.provider(
2815 "continue updating when props have updatable changes",
2816 (stack) =>
2817 Effect.gen(function* () {
2818 yield* Effect.gen(function* () {
2819 yield* TestResource("A", {
2820 string: "test-string",
2821 });
2822 }).pipe(stack.deploy);
2823 expect((yield* getState("A"))?.status).toEqual("created");
2824
2825 yield* Effect.gen(function* () {
2826 yield* TestResource("A", {
2827 string: "test-string-changed",
2828 });
2829 }).pipe(
2830 stack.deploy,
2831 hook({
2832 update: () => Effect.fail(new ResourceFailure()),
2833 }),
2834 );
2835 expect((yield* getState("A"))?.status).toEqual("updating");
2836
2837 const output = yield* Effect.gen(function* () {
2838 const A = yield* TestResource("A", {
2839 string: "test-string-changed-again",
2840 });
2841 return A.string;
2842 }).pipe(stack.deploy);
2843 expect((yield* getState("A"))?.status).toEqual("updated");
2844 expect(output).toEqual("test-string-changed-again");
2845 }),
2846 );
2847
2848 test.provider("replace when props trigger replacement", (stack) =>
2849 Effect.gen(function* () {
2850 yield* Effect.gen(function* () {
2851 yield* TestResource("A", {
2852 string: "test-string",
2853 replaceString: "original",
2854 });
2855 }).pipe(stack.deploy);
2856 expect((yield* getState("A"))?.status).toEqual("created");
2857
2858 yield* Effect.gen(function* () {
2859 yield* TestResource("A", {
2860 string: "test-string-changed",
2861 replaceString: "original",
2862 });
2863 }).pipe(
2864 stack.deploy,
2865 hook({
2866 update: () => Effect.fail(new ResourceFailure()),
2867 }),
2868 );
2869 expect((yield* getState("A"))?.status).toEqual("updating");
2870
2871 const output = yield* Effect.gen(function* () {
2872 const A = yield* TestResource("A", {
2873 string: "test-string-changed",
2874 replaceString: "changed",
2875 });
2876 return A.replaceString;
2877 }).pipe(stack.deploy);
2878 expect((yield* getState("A"))?.status).toEqual("created");
2879 expect(output).toEqual("changed");
2880 }),
2881 );
2882});
2883
2884describe("from replacing state", { tags: ["unit", "local"] }, () => {
2885 test.provider("continue replacement when props unchanged", (stack) =>
2886 Effect.gen(function* () {
2887 // 1. Create initial resource
2888 yield* Effect.gen(function* () {
2889 yield* TestResource("A", {
2890 replaceString: "original",
2891 });
2892 }).pipe(stack.deploy);
2893 expect((yield* getState("A"))?.status).toEqual("created");
2894
2895 // 2. Trigger replacement but fail during create of replacement
2896 yield* Effect.gen(function* () {
2897 yield* TestResource("A", {
2898 replaceString: "new",
2899 });
2900 }).pipe(
2901 stack.deploy,
2902 hook({
2903 create: () => Effect.fail(new ResourceFailure()),
2904 }),
2905 );
2906 const state = yield* getState<ReplacingResourceState>("A");
2907 expect(state?.status).toEqual("replacing");
2908 expect(state?.old?.status).toEqual("created");
2909
2910 // 3. Re-apply with same props - should continue replacement
2911 const output = yield* Effect.gen(function* () {
2912 const A = yield* TestResource("A", {
2913 replaceString: "new",
2914 });
2915 return A.replaceString;
2916 }).pipe(stack.deploy);
2917 expect((yield* getState("A"))?.status).toEqual("created");
2918 expect(output).toEqual("new");
2919 }),
2920 );
2921
2922 test.provider(
2923 "continue replacement when props have updatable changes",
2924 (stack) =>
2925 Effect.gen(function* () {
2926 // 1. Create initial resource
2927 yield* Effect.gen(function* () {
2928 yield* TestResource("A", {
2929 replaceString: "original",
2930 string: "initial",
2931 });
2932 }).pipe(stack.deploy);
2933 expect((yield* getState("A"))?.status).toEqual("created");
2934
2935 // 2. Trigger replacement but fail during create
2936 yield* Effect.gen(function* () {
2937 yield* TestResource("A", {
2938 replaceString: "new",
2939 string: "initial",
2940 });
2941 }).pipe(
2942 stack.deploy,
2943 hook({
2944 create: () => Effect.fail(new ResourceFailure()),
2945 }),
2946 );
2947 expect((yield* getState("A"))?.status).toEqual("replacing");
2948
2949 // 3. Re-apply with changed props (updatable) - should continue replacement with new props
2950 const output = yield* Effect.gen(function* () {
2951 const A = yield* TestResource("A", {
2952 replaceString: "new",
2953 string: "changed",
2954 });
2955 return { replaceString: A.replaceString, string: A.string };
2956 }).pipe(stack.deploy);
2957 expect((yield* getState("A"))?.status).toEqual("created");
2958 expect(output.replaceString).toEqual("new");
2959 expect(output.string).toEqual("changed");
2960 }),
2961 );
2962
2963 test.provider(
2964 "continue replacement when props trigger another replacement",
2965 (stack) =>
2966 Effect.gen(function* () {
2967 // 1. Create initial resource
2968 yield* Effect.gen(function* () {
2969 yield* TestResource("A", {
2970 replaceString: "original",
2971 });
2972 }).pipe(stack.deploy);
2973 expect((yield* getState("A"))?.status).toEqual("created");
2974
2975 // 2. Trigger replacement but fail during create
2976 yield* Effect.gen(function* () {
2977 yield* TestResource("A", {
2978 replaceString: "new",
2979 });
2980 }).pipe(
2981 stack.deploy,
2982 hook({
2983 create: () => Effect.fail(new ResourceFailure()),
2984 }),
2985 );
2986 expect((yield* getState("A"))?.status).toEqual("replacing");
2987
2988 // 3. Replace again with another replacement - should converge
2989 const output = yield* Effect.gen(function* () {
2990 const A = yield* TestResource("A", {
2991 replaceString: "another-replacement",
2992 });
2993 return A.replaceString;
2994 }).pipe(stack.deploy);
2995 expectConvergedStatus((yield* getState("A"))?.status);
2996 expect(output).toEqual("another-replacement");
2997 }),
2998 );
2999});
3000
3001describe("from replaced state", { tags: ["unit", "local"] }, () => {
3002 test.provider("continue cleanup when props unchanged", (stack) =>
3003 Effect.gen(function* () {
3004 yield* Effect.gen(function* () {
3005 yield* TestResource("A", {
3006 replaceString: "test-string",
3007 });
3008 }).pipe(stack.deploy);
3009 expect((yield* getState("A"))?.status).toEqual("created");
3010
3011 yield* Effect.gen(function* () {
3012 yield* TestResource("A", {
3013 replaceString: "test-string-changed",
3014 });
3015 }).pipe(
3016 stack.deploy,
3017 hook({
3018 delete: () => Effect.fail(new ResourceFailure()),
3019 }),
3020 );
3021 const AState = yield* getState<ReplacedResourceState>("A");
3022 expect(AState?.status).toEqual("replaced");
3023 expect(AState?.old).toMatchObject({
3024 status: "created",
3025 props: {
3026 replaceString: "test-string",
3027 },
3028 });
3029
3030 yield* Effect.gen(function* () {
3031 yield* TestResource("A", {
3032 replaceString: "test-string-changed",
3033 });
3034 }).pipe(stack.deploy);
3035 expect((yield* getState("A"))?.status).toEqual("created");
3036 }),
3037 );
3038
3039 test.provider(
3040 "update replacement then cleanup when props have updatable changes",
3041 (stack) =>
3042 Effect.gen(function* () {
3043 // 1. Create initial resource
3044 yield* Effect.gen(function* () {
3045 yield* TestResource("A", {
3046 replaceString: "original",
3047 string: "initial",
3048 });
3049 }).pipe(stack.deploy);
3050 expect((yield* getState("A"))?.status).toEqual("created");
3051
3052 // 2. Trigger replacement and fail during delete of old resource
3053 yield* Effect.gen(function* () {
3054 yield* TestResource("A", {
3055 replaceString: "new",
3056 string: "initial",
3057 });
3058 }).pipe(
3059 stack.deploy,
3060 hook({
3061 delete: () => Effect.fail(new ResourceFailure()),
3062 }),
3063 );
3064 const state = yield* getState<ReplacedResourceState>("A");
3065 expect(state?.status).toEqual("replaced");
3066 expect(state?.old?.status).toEqual("created");
3067
3068 // 3. Change props again (updatable change) - should update the replacement then cleanup
3069 const output = yield* Effect.gen(function* () {
3070 const A = yield* TestResource("A", {
3071 replaceString: "new",
3072 string: "changed",
3073 });
3074 return { replaceString: A.replaceString, string: A.string };
3075 }).pipe(stack.deploy);
3076 expect((yield* getState("A"))?.status).toEqual("created");
3077 expect(output.replaceString).toEqual("new");
3078 expect(output.string).toEqual("changed");
3079 }),
3080 );
3081
3082 test.provider(
3083 "continue cleanup when props trigger another replacement",
3084 (stack) =>
3085 Effect.gen(function* () {
3086 // 1. Create initial resource
3087 yield* Effect.gen(function* () {
3088 yield* TestResource("A", {
3089 replaceString: "original",
3090 });
3091 }).pipe(stack.deploy);
3092 expect((yield* getState("A"))?.status).toEqual("created");
3093
3094 // 2. Trigger replacement and fail during delete of old resource
3095 yield* Effect.gen(function* () {
3096 yield* TestResource("A", {
3097 replaceString: "new",
3098 });
3099 }).pipe(
3100 stack.deploy,
3101 hook({
3102 delete: () => Effect.fail(new ResourceFailure()),
3103 }),
3104 );
3105 expect((yield* getState("A"))?.status).toEqual("replaced");
3106
3107 // 3. Replace again and continue cleanup of the older generations
3108 const output = yield* Effect.gen(function* () {
3109 const A = yield* TestResource("A", {
3110 replaceString: "another-replacement",
3111 });
3112 return A.replaceString;
3113 }).pipe(stack.deploy);
3114 expectConvergedStatus((yield* getState("A"))?.status);
3115 expect(output).toEqual("another-replacement");
3116 }),
3117 );
3118});
3119
3120describe(
3121 "retain removal policy on replace",
3122 { tags: ["unit", "local"] },
3123 () => {
3124 test.provider(
3125 "replace with retain does not delete the old generation",
3126 (stack) =>
3127 Effect.gen(function* () {
3128 const deleted: string[] = [];
3129
3130 // 1. Create initial resource with a retain removal policy.
3131 yield* Effect.gen(function* () {
3132 yield* TestResource("A", { replaceString: "v1" }).pipe(
3133 RemovalPolicy.retain(true),
3134 );
3135 }).pipe(stack.deploy);
3136
3137 const before = yield* getState("A");
3138 expect(before?.status).toEqual("created");
3139 const oldInstanceId = before?.instanceId;
3140
3141 // 2. Trigger a replacement (replaceString change). The old generation
3142 // must NOT be deleted because the resource is retained.
3143 const output = yield* Effect.gen(function* () {
3144 const A = yield* TestResource("A", { replaceString: "v2" }).pipe(
3145 RemovalPolicy.retain(true),
3146 );
3147 return A.replaceString;
3148 }).pipe(
3149 stack.deploy,
3150 hook({
3151 delete: (id) =>
3152 Effect.sync(() => {
3153 deleted.push(id);
3154 }),
3155 }),
3156 );
3157
3158 expect(output).toEqual("v2");
3159 // provider.delete must never fire for the retained old generation.
3160 expect(deleted).not.toContain("A");
3161
3162 const after = yield* getState("A");
3163 // Resource was genuinely replaced (fresh instance id) and the old
3164 // chain drained back to a terminal `created` state.
3165 expect(after?.status).toEqual("created");
3166 expect(after?.instanceId).not.toEqual(oldInstanceId);
3167 }),
3168 );
3169
3170 test.provider(
3171 "replace without retain deletes the old generation exactly once",
3172 (stack) =>
3173 Effect.gen(function* () {
3174 const deleted: string[] = [];
3175
3176 // 1. Create initial resource with the default (destroy) policy.
3177 yield* Effect.gen(function* () {
3178 yield* TestResource("A", { replaceString: "v1" });
3179 }).pipe(stack.deploy);
3180 expect((yield* getState("A"))?.status).toEqual("created");
3181
3182 // 2. Trigger a replacement. The old generation must be deleted since
3183 // the resource is not retained — guards the retain patch against
3184 // disabling normal replacement GC.
3185 const output = yield* Effect.gen(function* () {
3186 const A = yield* TestResource("A", { replaceString: "v2" });
3187 return A.replaceString;
3188 }).pipe(
3189 stack.deploy,
3190 hook({
3191 delete: (id) =>
3192 Effect.sync(() => {
3193 deleted.push(id);
3194 }),
3195 }),
3196 );
3197
3198 expect(output).toEqual("v2");
3199 expect(deleted.filter((id) => id === "A")).toHaveLength(1);
3200 expect((yield* getState("A"))?.status).toEqual("created");
3201 }),
3202 );
3203
3204 test.provider(
3205 "nested replacement chain with retain never deletes old generations",
3206 (stack) =>
3207 Effect.gen(function* () {
3208 const deleted: string[] = [];
3209
3210 // 1. Create with retain.
3211 yield* Effect.gen(function* () {
3212 yield* TestResource("A", { replaceString: "v1" }).pipe(
3213 RemovalPolicy.retain(true),
3214 );
3215 }).pipe(stack.deploy);
3216 expect((yield* getState("A"))?.status).toEqual("created");
3217
3218 // 2. Trigger a replacement but fail mid-create so a replacement chain
3219 // forms (replacing, with old=created still live).
3220 yield* Effect.gen(function* () {
3221 yield* TestResource("A", { replaceString: "v2" }).pipe(
3222 RemovalPolicy.retain(true),
3223 );
3224 }).pipe(
3225 stack.deploy,
3226 hook({ create: () => Effect.fail(new ResourceFailure()) }),
3227 );
3228 expect((yield* getState("A"))?.status).toEqual("replacing");
3229
3230 // 3. Replace again — converges and drains the entire old chain. Every
3231 // old generation must be retained (no provider.delete calls).
3232 const output = yield* Effect.gen(function* () {
3233 const A = yield* TestResource("A", { replaceString: "v3" }).pipe(
3234 RemovalPolicy.retain(true),
3235 );
3236 return A.replaceString;
3237 }).pipe(
3238 stack.deploy,
3239 hook({
3240 delete: (id) =>
3241 Effect.sync(() => {
3242 deleted.push(id);
3243 }),
3244 }),
3245 );
3246
3247 expect(output).toEqual("v3");
3248 expect(deleted).not.toContain("A");
3249 expect((yield* getState("A"))?.status).toEqual("created");
3250 }),
3251 );
3252
3253 test.provider(
3254 "orphan delete still honors retain (regression guard)",
3255 (stack) =>
3256 Effect.gen(function* () {
3257 const deleted: string[] = [];
3258 const events: Array<{ id: string; status: string }> = [];
3259
3260 yield* Effect.gen(function* () {
3261 yield* TestResource("A", { string: "v1" }).pipe(
3262 RemovalPolicy.retain(true),
3263 );
3264 }).pipe(stack.deploy);
3265 expect((yield* getState("A"))?.status).toEqual("created");
3266
3267 const plan = yield* Effect.void.pipe(stack.plan);
3268 expect(plan.deletions.A?.action).toBe("orphaned");
3269
3270 // Destroy removes the resource from the stack. The explicit orphaned
3271 // action must skip provider.delete and just drop state.
3272 yield* stack.destroy().pipe(
3273 hook({
3274 delete: (id) =>
3275 Effect.sync(() => {
3276 deleted.push(id);
3277 }),
3278 }),
3279 Effect.provide(Layer.succeed(Cli, recordingCli(events))),
3280 );
3281
3282 expect(deleted).not.toContain("A");
3283 expect(yield* getState("A")).toBeUndefined();
3284 expect(
3285 events
3286 .filter((event) => event.id === "A")
3287 .map((event) => event.status),
3288 ).toEqual(["orphaning", "orphaned"]);
3289 }),
3290 );
3291
3292 test.provider(
3293 "retain added to an already-created resource is persisted by the noop deploy",
3294 (stack) =>
3295 Effect.gen(function* () {
3296 // 1. Create with the default (destroy) policy.
3297 yield* Effect.gen(function* () {
3298 yield* TestResource("A", { string: "v1" });
3299 }).pipe(stack.deploy);
3300 expect((yield* getState("A"))?.removalPolicy).toEqual("destroy");
3301
3302 // 2. Add `retain` — props are otherwise identical, so the resource
3303 // plans as a noop. The policy is a declaration decoration, not a
3304 // prop, so nothing about it can produce a diff; the noop path is
3305 // the only pass that ever sees the change.
3306 const declaration = Effect.gen(function* () {
3307 yield* TestResource("A", { string: "v1" }).pipe(
3308 RemovalPolicy.retain(true),
3309 );
3310 });
3311 const plan = yield* declaration.pipe(stack.plan);
3312 expect(actionOfPlan(plan, "A")).toEqual("noop");
3313 yield* declaration.pipe(stack.deploy);
3314 expect((yield* getState("A"))?.removalPolicy).toEqual("retain");
3315
3316 // 3. Remove the declaration — the orphan sweep reads the policy from
3317 // state, so the provider's delete must never fire.
3318 const deleted: string[] = [];
3319 yield* stack.destroy().pipe(
3320 hook({
3321 delete: (id) =>
3322 Effect.sync(() => {
3323 deleted.push(id);
3324 }),
3325 }),
3326 );
3327 expect(deleted).not.toContain("A");
3328 expect(yield* getState("A")).toBeUndefined();
3329 }),
3330 );
3331
3332 test.provider(
3333 "retain removed from an already-created resource is persisted by the noop deploy",
3334 (stack) =>
3335 Effect.gen(function* () {
3336 // The inverse direction: a resource that was retained and is now
3337 // declared `destroy` must actually be deleted by the orphan sweep.
3338 yield* Effect.gen(function* () {
3339 yield* TestResource("A", { string: "v1" }).pipe(
3340 RemovalPolicy.retain(true),
3341 );
3342 }).pipe(stack.deploy);
3343 expect((yield* getState("A"))?.removalPolicy).toEqual("retain");
3344
3345 yield* Effect.gen(function* () {
3346 yield* TestResource("A", { string: "v1" });
3347 }).pipe(stack.deploy);
3348 expect((yield* getState("A"))?.removalPolicy).toEqual("destroy");
3349
3350 const deleted: string[] = [];
3351 yield* stack.destroy().pipe(
3352 hook({
3353 delete: (id) =>
3354 Effect.sync(() => {
3355 deleted.push(id);
3356 }),
3357 }),
3358 );
3359 expect(deleted).toContain("A");
3360 expect(yield* getState("A")).toBeUndefined();
3361 }),
3362 );
3363 },
3364);
3365
3366describe("from deleting state", { tags: ["unit", "local"] }, () => {
3367 test.provider(
3368 "create when props unchanged or have updatable changes",
3369 (stack) =>
3370 Effect.gen(function* () {
3371 yield* Effect.gen(function* () {
3372 yield* TestResource("A", {
3373 string: "test-string",
3374 });
3375 }).pipe(stack.deploy);
3376 expect((yield* getState("A"))?.status).toEqual("created");
3377
3378 yield* stack.destroy().pipe(
3379 hook({
3380 delete: () => Effect.fail(new ResourceFailure()),
3381 }),
3382 );
3383 expect((yield* getState("A"))?.status).toEqual("deleting");
3384
3385 // Now re-apply with the same props - should create the resource again
3386 const output = yield* Effect.gen(function* () {
3387 const A = yield* TestResource("A", {
3388 string: "test-string",
3389 });
3390 return A.string;
3391 }).pipe(stack.deploy);
3392 expect((yield* getState("A"))?.status).toEqual("created");
3393 expect(output).toEqual("test-string");
3394 }),
3395 );
3396
3397 // A destroy interrupted while `provider.delete` is still in flight (the
3398 // shape of a live delete stuck in a long provisioning wait — e.g.
3399 // CloudFront's disable→wait→delete — when the test runner's timeout fires
3400 // and teardown is abandoned) must keep the resource's state row. Deletes
3401 // are idempotent and resumable: the engine commits a `deleting` row BEFORE
3402 // calling `provider.delete` and only drops it after success, so the next
3403 // destroy sees the row and drains it. Losing the row here is an invisible
3404 // orphan — the next destroy plans "no changes" and the cloud resource
3405 // leaks forever.
3406 test.provider(
3407 "interrupting a destroy mid-delete keeps a resumable deleting row",
3408 (stack) =>
3409 Effect.gen(function* () {
3410 yield* Effect.gen(function* () {
3411 yield* TestResource("A", { string: "v1" });
3412 }).pipe(stack.deploy);
3413 expect((yield* getState("A"))?.status).toEqual("created");
3414
3415 // Destroy with a delete that signals entry and then never resolves,
3416 // then interrupt the destroy once the delete is in flight —
3417 // simulating the runner's timeout + teardown abandonment.
3418 const deleteStarted = yield* Deferred.make<void>();
3419 const fiber = yield* stack.destroy().pipe(
3420 hook({
3421 delete: () =>
3422 Deferred.succeed(deleteStarted, void 0).pipe(
3423 Effect.andThen(Effect.never),
3424 ),
3425 }),
3426 Effect.forkChild,
3427 );
3428 yield* Deferred.await(deleteStarted);
3429 yield* Fiber.interrupt(fiber);
3430
3431 // The row survives the interruption, parked at `deleting`.
3432 expect((yield* getState("A"))?.status).toEqual("deleting");
3433
3434 // The next destroy resumes the delete and drains the row.
3435 yield* stack.destroy();
3436 expect(yield* getState("A")).toBeUndefined();
3437 expect(yield* listState()).toEqual([]);
3438 }),
3439 );
3440
3441 test.provider("create when props trigger replacement", (stack) =>
3442 Effect.gen(function* () {
3443 // 1. Create initial resource
3444 yield* Effect.gen(function* () {
3445 yield* TestResource("A", {
3446 replaceString: "original",
3447 });
3448 }).pipe(stack.deploy);
3449 expect((yield* getState("A"))?.status).toEqual("created");
3450
3451 // 2. Try to delete but fail
3452 yield* stack.destroy().pipe(
3453 hook({
3454 delete: () => Effect.fail(new ResourceFailure()),
3455 }),
3456 );
3457 expect((yield* getState("A"))?.status).toEqual("deleting");
3458
3459 // 3. Re-apply with props that trigger replacement - should recreate
3460 const output = yield* Effect.gen(function* () {
3461 const A = yield* TestResource("A", {
3462 replaceString: "new",
3463 });
3464 return A.replaceString;
3465 }).pipe(stack.deploy);
3466 expect((yield* getState("A"))?.status).toEqual("created");
3467 expect(output).toEqual("new");
3468 }),
3469 );
3470});
3471
3472// =============================================================================
3473// DEPENDENT RESOURCES (A -> B where B depends on A.string)
3474// =============================================================================
3475
3476describe("dependent resources (A -> B)", { tags: ["unit", "local"] }, () => {
3477 describe("happy path", () => {
3478 test.provider("create A then B where B uses A.string", (stack) =>
3479 Effect.gen(function* () {
3480 const output = yield* Effect.gen(function* () {
3481 const A = yield* TestResource("A", { string: "a-value" });
3482 const B = yield* TestResource("B", { string: A.string });
3483 return { A, B };
3484 }).pipe(stack.deploy);
3485
3486 expect((yield* getState("A"))?.status).toEqual("created");
3487 expect((yield* getState("B"))?.status).toEqual("created");
3488 expect(output.A.string).toEqual("a-value");
3489 expect(output.B.string).toEqual("a-value");
3490 }),
3491 );
3492
3493 test.provider("update A propagates to B", (stack) =>
3494 Effect.gen(function* () {
3495 yield* Effect.gen(function* () {
3496 const A = yield* TestResource("A", { string: "a-value" });
3497 yield* TestResource("B", { string: A.string });
3498 }).pipe(stack.deploy);
3499 expect((yield* getState("A"))?.status).toEqual("created");
3500 expect((yield* getState("B"))?.status).toEqual("created");
3501
3502 // Update A's string - B should update with the new value
3503 const output = yield* Effect.gen(function* () {
3504 const A = yield* TestResource("A", { string: "a-value-updated" });
3505 const B = yield* TestResource("B", { string: A.string });
3506 return { A, B };
3507 }).pipe(stack.deploy);
3508
3509 expect((yield* getState("A"))?.status).toEqual("updated");
3510 expect((yield* getState("B"))?.status).toEqual("updated");
3511 expect(output.A.string).toEqual("a-value-updated");
3512 expect(output.B.string).toEqual("a-value-updated");
3513 }),
3514 );
3515
3516 test.provider("replace A, B updates to new A's output", (stack) =>
3517 Effect.gen(function* () {
3518 yield* Effect.gen(function* () {
3519 const A = yield* TestResource("A", {
3520 string: "a-value",
3521 replaceString: "original",
3522 });
3523 yield* TestResource("B", { string: A.string });
3524 }).pipe(stack.deploy);
3525 expect((yield* getState("A"))?.status).toEqual("created");
3526 expect((yield* getState("B"))?.status).toEqual("created");
3527
3528 // Replace A - B should update to point to new A's output
3529 const output = yield* Effect.gen(function* () {
3530 const A = yield* TestResource("A", {
3531 string: "a-value-new",
3532 replaceString: "changed",
3533 });
3534 const B = yield* TestResource("B", { string: A.string });
3535 return { A, B };
3536 }).pipe(stack.deploy);
3537
3538 expect((yield* getState("A"))?.status).toEqual("created");
3539 expect((yield* getState("B"))?.status).toEqual("updated");
3540 expect(output.A.string).toEqual("a-value-new");
3541 expect(output.B.string).toEqual("a-value-new");
3542 }),
3543 );
3544
3545 test.provider("delete both resources (B deleted first, then A)", (stack) =>
3546 Effect.gen(function* () {
3547 yield* Effect.gen(function* () {
3548 const A = yield* TestResource("A", { string: "a-value" });
3549 yield* TestResource("B", { string: A.string });
3550 }).pipe(stack.deploy);
3551 expect((yield* getState("A"))?.status).toEqual("created");
3552 expect((yield* getState("B"))?.status).toEqual("created");
3553
3554 yield* stack.destroy();
3555
3556 expect(yield* getState("A")).toBeUndefined();
3557 expectNotStarted(yield* getState("B"));
3558 expect(yield* listState()).toEqual([]);
3559 }),
3560 );
3561 });
3562
3563 describe("failures during expandAndPivot", () => {
3564 test.provider(
3565 "A create fails, B never starts - recovery creates both",
3566 (stack) =>
3567 Effect.gen(function* () {
3568 // A fails to create - B should never start
3569 yield* Effect.gen(function* () {
3570 const A = yield* TestResource("A", { string: "a-value" });
3571 yield* TestResource("B", { string: A.string });
3572 }).pipe(stack.deploy, hook(failOn("A", "create")));
3573
3574 expect((yield* getState("A"))?.status).toEqual("creating");
3575 expectNotStarted(yield* getState("B"));
3576
3577 // Recovery: re-apply should create both
3578 const output = yield* Effect.gen(function* () {
3579 const A = yield* TestResource("A", { string: "a-value" });
3580 const B = yield* TestResource("B", { string: A.string });
3581 return { A, B };
3582 }).pipe(stack.deploy);
3583
3584 expect((yield* getState("A"))?.status).toEqual("created");
3585 expect((yield* getState("B"))?.status).toEqual("created");
3586 expect(output.A.string).toEqual("a-value");
3587 expect(output.B.string).toEqual("a-value");
3588 }),
3589 );
3590
3591 test.provider("A creates, B create fails - recovery creates B", (stack) =>
3592 Effect.gen(function* () {
3593 // A succeeds, B fails to create
3594 yield* Effect.gen(function* () {
3595 const A = yield* TestResource("A", { string: "a-value" });
3596 yield* TestResource("B", { string: A.string });
3597 }).pipe(stack.deploy, hook(failOn("B", "create")));
3598
3599 expect((yield* getState("A"))?.status).toEqual("created");
3600 expect((yield* getState("B"))?.status).toEqual("creating");
3601
3602 // Recovery: re-apply should noop A and create B
3603 const output = yield* Effect.gen(function* () {
3604 const A = yield* TestResource("A", { string: "a-value" });
3605 const B = yield* TestResource("B", { string: A.string });
3606 return { A, B };
3607 }).pipe(stack.deploy);
3608
3609 expect((yield* getState("A"))?.status).toEqual("created");
3610 expect((yield* getState("B"))?.status).toEqual("created");
3611 expect(output.B.string).toEqual("a-value");
3612 }),
3613 );
3614
3615 test.provider("A update fails - recovery updates both", (stack) =>
3616 Effect.gen(function* () {
3617 yield* Effect.gen(function* () {
3618 const A = yield* TestResource("A", { string: "a-value" });
3619 yield* TestResource("B", { string: A.string });
3620 }).pipe(stack.deploy);
3621
3622 const program = Effect.gen(function* () {
3623 const A = yield* TestResource("A", { string: "a-value-updated" });
3624 const B = yield* TestResource("B", { string: A.string });
3625 return { A, B };
3626 });
3627
3628 // A fails to update - B should not start updating
3629 yield* program.pipe(stack.deploy, hook(failOn("A", "update")));
3630
3631 expect((yield* getState("A"))?.status).toEqual("updating");
3632 expect((yield* getState("B"))?.status).toEqual("created");
3633
3634 // Recovery: re-apply should update both
3635 const output = yield* program.pipe(stack.deploy);
3636
3637 expect((yield* getState("A"))?.status).toEqual("updated");
3638 expect((yield* getState("B"))?.status).toEqual("updated");
3639 expect(output.A.string).toEqual("a-value-updated");
3640 expect(output.B.string).toEqual("a-value-updated");
3641 }),
3642 );
3643
3644 test.provider("A updates, B update fails - recovery updates B", (stack) =>
3645 Effect.gen(function* () {
3646 yield* Effect.gen(function* () {
3647 const A = yield* TestResource("A", { string: "a-value" });
3648 yield* TestResource("B", { string: A.string });
3649 }).pipe(stack.deploy);
3650
3651 const program = Effect.gen(function* () {
3652 const A = yield* TestResource("A", { string: "a-value-updated" });
3653 const B = yield* TestResource("B", { string: A.string });
3654 return { A, B };
3655 });
3656
3657 // A succeeds, B fails to update
3658 yield* program.pipe(stack.deploy, hook(failOn("B", "update")));
3659
3660 expect((yield* getState("A"))?.status).toEqual("updated");
3661 expect((yield* getState("B"))?.status).toEqual("updating");
3662
3663 // Recovery: re-apply should noop A and update B
3664 const output = yield* program.pipe(stack.deploy);
3665
3666 expect((yield* getState("A"))?.status).toEqual("updated");
3667 expect((yield* getState("B"))?.status).toEqual("updated");
3668 expect(output.B.string).toEqual("a-value-updated");
3669 }),
3670 );
3671
3672 test.provider(
3673 "A replacement fails - recovery replaces A and updates B",
3674 (stack) =>
3675 Effect.gen(function* () {
3676 yield* Effect.gen(function* () {
3677 const A = yield* TestResource("A", {
3678 string: "a-value",
3679 replaceString: "original",
3680 });
3681 yield* TestResource("B", { string: A.string });
3682 }).pipe(stack.deploy);
3683
3684 const program = Effect.gen(function* () {
3685 const A = yield* TestResource("A", {
3686 string: "a-value-new",
3687 replaceString: "changed",
3688 });
3689 const B = yield* TestResource("B", { string: A.string });
3690 return { A, B };
3691 });
3692
3693 // A replacement fails (during create of new A) - B should not start
3694 yield* program.pipe(stack.deploy, hook(failOn("A", "create")));
3695
3696 expect(
3697 (yield* getState<ReplacingResourceState>("A"))?.status,
3698 ).toEqual("replacing");
3699 expect((yield* getState("B"))?.status).toEqual("created");
3700
3701 // Recovery: re-apply should complete A replacement and update B
3702 const output = yield* program.pipe(stack.deploy);
3703
3704 expect((yield* getState("A"))?.status).toEqual("created");
3705 expect((yield* getState("B"))?.status).toEqual("updated");
3706 expect(output.A.string).toEqual("a-value-new");
3707 expect(output.B.string).toEqual("a-value-new");
3708 }),
3709 );
3710
3711 test.provider(
3712 "A replaced, B update fails - recovery updates B then cleans up",
3713 (stack) =>
3714 Effect.gen(function* () {
3715 yield* Effect.gen(function* () {
3716 const A = yield* TestResource("A", {
3717 string: "a-value",
3718 replaceString: "original",
3719 });
3720 yield* TestResource("B", { string: A.string });
3721 }).pipe(stack.deploy);
3722
3723 const program = Effect.gen(function* () {
3724 const A = yield* TestResource("A", {
3725 string: "a-value-new",
3726 replaceString: "changed",
3727 });
3728 const B = yield* TestResource("B", { string: A.string });
3729 return { A, B };
3730 });
3731
3732 // A replacement succeeds, B fails to update
3733 yield* program.pipe(stack.deploy, hook(failOn("B", "update")));
3734
3735 // A should be in replaced state (new A created, old A pending cleanup)
3736 // B should be in updating state
3737 const aState = yield* getState<ReplacedResourceState>("A");
3738 expect(aState?.status).toEqual("replaced");
3739 expect((yield* getState("B"))?.status).toEqual("updating");
3740
3741 // Recovery: re-apply should update B and clean up old A
3742 const output = yield* program.pipe(stack.deploy);
3743
3744 expect((yield* getState("A"))?.status).toEqual("created");
3745 expect((yield* getState("B"))?.status).toEqual("updated");
3746 expect(output.B.string).toEqual("a-value-new");
3747 }),
3748 );
3749 });
3750
3751 describe("failures during collectGarbage", () => {
3752 test.provider(
3753 "A replaced, B updated, old A delete fails - recovery cleans up",
3754 (stack) =>
3755 Effect.gen(function* () {
3756 yield* Effect.gen(function* () {
3757 const A = yield* TestResource("A", {
3758 string: "a-value",
3759 replaceString: "original",
3760 });
3761 yield* TestResource("B", { string: A.string });
3762 }).pipe(stack.deploy);
3763
3764 const program = Effect.gen(function* () {
3765 const A = yield* TestResource("A", {
3766 string: "a-value-new",
3767 replaceString: "changed",
3768 });
3769 const B = yield* TestResource("B", { string: A.string });
3770 return { A, B };
3771 });
3772
3773 // A replacement and B update succeed, but old A delete fails
3774 yield* program.pipe(stack.deploy, hook(failOn("A", "delete")));
3775
3776 // A should be in replaced state (delete of old A failed)
3777 // B should have been updated successfully
3778 expect((yield* getState<ReplacedResourceState>("A"))?.status).toEqual(
3779 "replaced",
3780 );
3781 expect((yield* getState("B"))?.status).toEqual("updated");
3782
3783 // Recovery: re-apply should clean up old A
3784 const output = yield* program.pipe(stack.deploy);
3785
3786 expect((yield* getState("A"))?.status).toEqual("created");
3787 expect((yield* getState("B"))?.status).toEqual("updated");
3788 expect(output.A.string).toEqual("a-value-new");
3789 }),
3790 );
3791
3792 test.provider(
3793 "orphan B delete fails - recovery deletes B then A",
3794 (stack) =>
3795 Effect.gen(function* () {
3796 yield* Effect.gen(function* () {
3797 const A = yield* TestResource("A", { string: "a-value" });
3798 yield* TestResource("B", { string: A.string });
3799 }).pipe(stack.deploy);
3800 expect((yield* getState("A"))?.status).toEqual("created");
3801 expect((yield* getState("B"))?.status).toEqual("created");
3802
3803 // Orphan deletion: B delete fails
3804 yield* stack.destroy().pipe(hook(failOn("B", "delete")));
3805
3806 // B should be in deleting state, A should still be created (waiting for B)
3807 expect((yield* getState("B"))?.status).toEqual("deleting");
3808 expect((yield* getState("A"))?.status).toEqual("created");
3809
3810 // Recovery: re-apply destroy should delete B then A
3811 yield* stack.destroy();
3812
3813 expect(yield* getState("A")).toBeUndefined();
3814 expectNotStarted(yield* getState("B"));
3815 }),
3816 );
3817
3818 test.provider(
3819 "orphan A delete fails after B deleted - recovery deletes A",
3820 (stack) =>
3821 Effect.gen(function* () {
3822 yield* Effect.gen(function* () {
3823 const A = yield* TestResource("A", { string: "a-value" });
3824 yield* TestResource("B", { string: A.string });
3825 }).pipe(stack.deploy);
3826 expect((yield* getState("A"))?.status).toEqual("created");
3827 expect((yield* getState("B"))?.status).toEqual("created");
3828
3829 // Orphan deletion: B succeeds, A fails
3830 yield* stack.destroy().pipe(hook(failOn("A", "delete")));
3831
3832 // B should be deleted, A should be in deleting state
3833 expectNotStarted(yield* getState("B"));
3834 expect((yield* getState("A"))?.status).toEqual("deleting");
3835
3836 // Recovery: re-apply destroy should delete A
3837 yield* stack.destroy();
3838
3839 expect(yield* getState("A")).toBeUndefined();
3840 }),
3841 );
3842 });
3843});
3844
3845// =============================================================================
3846// THREE-LEVEL DEPENDENCY CHAIN (A -> B -> C where C depends on B, B depends on A)
3847// =============================================================================
3848
3849describe(
3850 "three-level dependency chain (A -> B -> C)",
3851 { tags: ["unit", "local"] },
3852 () => {
3853 describe("happy path", () => {
3854 test.provider("create A then B then C", (stack) =>
3855 Effect.gen(function* () {
3856 const output = yield* Effect.gen(function* () {
3857 const A = yield* TestResource("A", { string: "a-value" });
3858 const B = yield* TestResource("B", { string: A.string });
3859 const C = yield* TestResource("C", { string: B.string });
3860 return { A, B, C };
3861 }).pipe(stack.deploy);
3862
3863 expect((yield* getState("A"))?.status).toEqual("created");
3864 expect((yield* getState("B"))?.status).toEqual("created");
3865 expect((yield* getState("C"))?.status).toEqual("created");
3866 expect(output.A.string).toEqual("a-value");
3867 expect(output.B.string).toEqual("a-value");
3868 expect(output.C.string).toEqual("a-value");
3869 }),
3870 );
3871
3872 test.provider("update A propagates through B to C", (stack) =>
3873 Effect.gen(function* () {
3874 yield* Effect.gen(function* () {
3875 const A = yield* TestResource("A", { string: "a-value" });
3876 const B = yield* TestResource("B", { string: A.string });
3877 yield* TestResource("C", { string: B.string });
3878 }).pipe(stack.deploy);
3879
3880 const output = yield* Effect.gen(function* () {
3881 const A = yield* TestResource("A", { string: "a-value-updated" });
3882 const B = yield* TestResource("B", { string: A.string });
3883 const C = yield* TestResource("C", { string: B.string });
3884 return { A, B, C };
3885 }).pipe(stack.deploy);
3886
3887 expect((yield* getState("A"))?.status).toEqual("updated");
3888 expect((yield* getState("B"))?.status).toEqual("updated");
3889 expect((yield* getState("C"))?.status).toEqual("updated");
3890 expect(output.C.string).toEqual("a-value-updated");
3891 }),
3892 );
3893
3894 test.provider("replace A propagates through B to C", (stack) =>
3895 Effect.gen(function* () {
3896 yield* Effect.gen(function* () {
3897 const A = yield* TestResource("A", {
3898 string: "a-value",
3899 replaceString: "original",
3900 });
3901 const B = yield* TestResource("B", { string: A.string });
3902 yield* TestResource("C", { string: B.string });
3903 }).pipe(stack.deploy);
3904
3905 const output = yield* Effect.gen(function* () {
3906 const A = yield* TestResource("A", {
3907 string: "a-value-new",
3908 replaceString: "changed",
3909 });
3910 const B = yield* TestResource("B", { string: A.string });
3911 const C = yield* TestResource("C", { string: B.string });
3912 return { A, B, C };
3913 }).pipe(stack.deploy);
3914
3915 expect((yield* getState("A"))?.status).toEqual("created");
3916 expect((yield* getState("B"))?.status).toEqual("updated");
3917 expect((yield* getState("C"))?.status).toEqual("updated");
3918 expect(output.C.string).toEqual("a-value-new");
3919 }),
3920 );
3921
3922 test.provider("delete all three (C first, then B, then A)", (stack) =>
3923 Effect.gen(function* () {
3924 yield* Effect.gen(function* () {
3925 const A = yield* TestResource("A", { string: "a-value" });
3926 const B = yield* TestResource("B", { string: A.string });
3927 yield* TestResource("C", { string: B.string });
3928 }).pipe(stack.deploy);
3929
3930 yield* stack.destroy();
3931
3932 expect(yield* getState("A")).toBeUndefined();
3933 expectNotStarted(yield* getState("B"));
3934 expectNotStarted(yield* getState("C"));
3935 expect(yield* listState()).toEqual([]);
3936 }),
3937 );
3938 });
3939
3940 describe("creation failures", () => {
3941 test.provider("A create fails - B and C never start", (stack) =>
3942 Effect.gen(function* () {
3943 const program = Effect.gen(function* () {
3944 const A = yield* TestResource("A", { string: "a-value" });
3945 const B = yield* TestResource("B", { string: A.string });
3946 const C = yield* TestResource("C", { string: B.string });
3947 return { A, B, C };
3948 });
3949
3950 yield* program.pipe(stack.deploy, hook(failOn("A", "create")));
3951
3952 expect((yield* getState("A"))?.status).toEqual("creating");
3953 expectNotStarted(yield* getState("B"));
3954 expectNotStarted(yield* getState("C"));
3955
3956 // Recovery
3957 const output = yield* program.pipe(stack.deploy);
3958 expect((yield* getState("A"))?.status).toEqual("created");
3959 expect((yield* getState("B"))?.status).toEqual("created");
3960 expect((yield* getState("C"))?.status).toEqual("created");
3961 expect(output.C.string).toEqual("a-value");
3962 }),
3963 );
3964
3965 test.provider("A creates, B create fails - C never starts", (stack) =>
3966 Effect.gen(function* () {
3967 const program = Effect.gen(function* () {
3968 const A = yield* TestResource("A", { string: "a-value" });
3969 const B = yield* TestResource("B", { string: A.string });
3970 const C = yield* TestResource("C", { string: B.string });
3971 return { A, B, C };
3972 });
3973
3974 yield* program.pipe(stack.deploy, hook(failOn("B", "create")));
3975
3976 expect((yield* getState("A"))?.status).toEqual("created");
3977 expect((yield* getState("B"))?.status).toEqual("creating");
3978 expectNotStarted(yield* getState("C"));
3979
3980 // Recovery
3981 const output = yield* program.pipe(stack.deploy);
3982 expect((yield* getState("A"))?.status).toEqual("created");
3983 expect((yield* getState("B"))?.status).toEqual("created");
3984 expect((yield* getState("C"))?.status).toEqual("created");
3985 expect(output.C.string).toEqual("a-value");
3986 }),
3987 );
3988
3989 test.provider("A and B create, C create fails", (stack) =>
3990 Effect.gen(function* () {
3991 const program = Effect.gen(function* () {
3992 const A = yield* TestResource("A", { string: "a-value" });
3993 const B = yield* TestResource("B", { string: A.string });
3994 const C = yield* TestResource("C", { string: B.string });
3995 return { A, B, C };
3996 });
3997
3998 yield* program.pipe(stack.deploy, hook(failOn("C", "create")));
3999
4000 expect((yield* getState("A"))?.status).toEqual("created");
4001 expect((yield* getState("B"))?.status).toEqual("created");
4002 expect((yield* getState("C"))?.status).toEqual("creating");
4003
4004 // Recovery
4005 const output = yield* program.pipe(stack.deploy);
4006 expect((yield* getState("A"))?.status).toEqual("created");
4007 expect((yield* getState("B"))?.status).toEqual("created");
4008 expect((yield* getState("C"))?.status).toEqual("created");
4009 expect(output.C.string).toEqual("a-value");
4010 }),
4011 );
4012 });
4013
4014 describe("update failures", () => {
4015 test.provider("A update fails - B and C remain stable", (stack) =>
4016 Effect.gen(function* () {
4017 yield* Effect.gen(function* () {
4018 const A = yield* TestResource("A", { string: "a-value" });
4019 const B = yield* TestResource("B", { string: A.string });
4020 yield* TestResource("C", { string: B.string });
4021 }).pipe(stack.deploy);
4022
4023 const program = Effect.gen(function* () {
4024 const A = yield* TestResource("A", { string: "a-value-updated" });
4025 const B = yield* TestResource("B", { string: A.string });
4026 const C = yield* TestResource("C", { string: B.string });
4027 return { A, B, C };
4028 });
4029
4030 yield* program.pipe(stack.deploy, hook(failOn("A", "update")));
4031
4032 expect((yield* getState("A"))?.status).toEqual("updating");
4033 expect((yield* getState("B"))?.status).toEqual("created");
4034 expect((yield* getState("C"))?.status).toEqual("created");
4035
4036 // Recovery
4037 const output = yield* program.pipe(stack.deploy);
4038 expect((yield* getState("A"))?.status).toEqual("updated");
4039 expect((yield* getState("B"))?.status).toEqual("updated");
4040 expect((yield* getState("C"))?.status).toEqual("updated");
4041 expect(output.C.string).toEqual("a-value-updated");
4042 }),
4043 );
4044
4045 test.provider("A updates, B update fails - C remains stable", (stack) =>
4046 Effect.gen(function* () {
4047 yield* Effect.gen(function* () {
4048 const A = yield* TestResource("A", { string: "a-value" });
4049 const B = yield* TestResource("B", { string: A.string });
4050 yield* TestResource("C", { string: B.string });
4051 }).pipe(stack.deploy);
4052
4053 const program = Effect.gen(function* () {
4054 const A = yield* TestResource("A", { string: "a-value-updated" });
4055 const B = yield* TestResource("B", { string: A.string });
4056 const C = yield* TestResource("C", { string: B.string });
4057 return { A, B, C };
4058 });
4059
4060 yield* program.pipe(stack.deploy, hook(failOn("B", "update")));
4061
4062 expect((yield* getState("A"))?.status).toEqual("updated");
4063 expect((yield* getState("B"))?.status).toEqual("updating");
4064 expect((yield* getState("C"))?.status).toEqual("created");
4065
4066 // Recovery
4067 const output = yield* program.pipe(stack.deploy);
4068 expect((yield* getState("A"))?.status).toEqual("updated");
4069 expect((yield* getState("B"))?.status).toEqual("updated");
4070 expect((yield* getState("C"))?.status).toEqual("updated");
4071 expect(output.C.string).toEqual("a-value-updated");
4072 }),
4073 );
4074
4075 test.provider("A and B update, C update fails", (stack) =>
4076 Effect.gen(function* () {
4077 yield* Effect.gen(function* () {
4078 const A = yield* TestResource("A", { string: "a-value" });
4079 const B = yield* TestResource("B", { string: A.string });
4080 yield* TestResource("C", { string: B.string });
4081 }).pipe(stack.deploy);
4082
4083 const program = Effect.gen(function* () {
4084 const A = yield* TestResource("A", { string: "a-value-updated" });
4085 const B = yield* TestResource("B", { string: A.string });
4086 const C = yield* TestResource("C", { string: B.string });
4087 return { A, B, C };
4088 });
4089
4090 yield* program.pipe(stack.deploy, hook(failOn("C", "update")));
4091
4092 expect((yield* getState("A"))?.status).toEqual("updated");
4093 expect((yield* getState("B"))?.status).toEqual("updated");
4094 expect((yield* getState("C"))?.status).toEqual("updating");
4095
4096 // Recovery
4097 const output = yield* program.pipe(stack.deploy);
4098 expect((yield* getState("A"))?.status).toEqual("updated");
4099 expect((yield* getState("B"))?.status).toEqual("updated");
4100 expect((yield* getState("C"))?.status).toEqual("updated");
4101 expect(output.C.string).toEqual("a-value-updated");
4102 }),
4103 );
4104 });
4105
4106 describe("replace cascade failures", () => {
4107 test.provider("A replace fails - B and C remain stable", (stack) =>
4108 Effect.gen(function* () {
4109 yield* Effect.gen(function* () {
4110 const A = yield* TestResource("A", {
4111 string: "a-value",
4112 replaceString: "original",
4113 });
4114 const B = yield* TestResource("B", { string: A.string });
4115 yield* TestResource("C", { string: B.string });
4116 }).pipe(stack.deploy);
4117
4118 const program = Effect.gen(function* () {
4119 const A = yield* TestResource("A", {
4120 string: "a-value-new",
4121 replaceString: "changed",
4122 });
4123 const B = yield* TestResource("B", { string: A.string });
4124 const C = yield* TestResource("C", { string: B.string });
4125 return { A, B, C };
4126 });
4127
4128 yield* program.pipe(stack.deploy, hook(failOn("A", "create")));
4129
4130 expect(
4131 (yield* getState<ReplacingResourceState>("A"))?.status,
4132 ).toEqual("replacing");
4133 expect((yield* getState("B"))?.status).toEqual("created");
4134 expect((yield* getState("C"))?.status).toEqual("created");
4135
4136 // Recovery
4137 const output = yield* program.pipe(stack.deploy);
4138 expect((yield* getState("A"))?.status).toEqual("created");
4139 expect((yield* getState("B"))?.status).toEqual("updated");
4140 expect((yield* getState("C"))?.status).toEqual("updated");
4141 expect(output.C.string).toEqual("a-value-new");
4142 }),
4143 );
4144
4145 test.provider("A replaced, B update fails - C remains stable", (stack) =>
4146 Effect.gen(function* () {
4147 yield* Effect.gen(function* () {
4148 const A = yield* TestResource("A", {
4149 string: "a-value",
4150 replaceString: "original",
4151 });
4152 const B = yield* TestResource("B", { string: A.string });
4153 yield* TestResource("C", { string: B.string });
4154 }).pipe(stack.deploy);
4155
4156 const program = Effect.gen(function* () {
4157 const A = yield* TestResource("A", {
4158 string: "a-value-new",
4159 replaceString: "changed",
4160 });
4161 const B = yield* TestResource("B", { string: A.string });
4162 const C = yield* TestResource("C", { string: B.string });
4163 return { A, B, C };
4164 });
4165
4166 yield* program.pipe(stack.deploy, hook(failOn("B", "update")));
4167
4168 expect((yield* getState<ReplacedResourceState>("A"))?.status).toEqual(
4169 "replaced",
4170 );
4171 expect((yield* getState("B"))?.status).toEqual("updating");
4172 expect((yield* getState("C"))?.status).toEqual("created");
4173
4174 // Recovery
4175 const output = yield* program.pipe(stack.deploy);
4176 expect((yield* getState("A"))?.status).toEqual("created");
4177 expect((yield* getState("B"))?.status).toEqual("updated");
4178 expect((yield* getState("C"))?.status).toEqual("updated");
4179 expect(output.C.string).toEqual("a-value-new");
4180 }),
4181 );
4182
4183 test.provider("A replaced, B updated, C update fails", (stack) =>
4184 Effect.gen(function* () {
4185 yield* Effect.gen(function* () {
4186 const A = yield* TestResource("A", {
4187 string: "a-value",
4188 replaceString: "original",
4189 });
4190 const B = yield* TestResource("B", { string: A.string });
4191 yield* TestResource("C", { string: B.string });
4192 }).pipe(stack.deploy);
4193
4194 const program = Effect.gen(function* () {
4195 const A = yield* TestResource("A", {
4196 string: "a-value-new",
4197 replaceString: "changed",
4198 });
4199 const B = yield* TestResource("B", { string: A.string });
4200 const C = yield* TestResource("C", { string: B.string });
4201 return { A, B, C };
4202 });
4203
4204 yield* program.pipe(stack.deploy, hook(failOn("C", "update")));
4205
4206 expect((yield* getState<ReplacedResourceState>("A"))?.status).toEqual(
4207 "replaced",
4208 );
4209 expect((yield* getState("B"))?.status).toEqual("updated");
4210 expect((yield* getState("C"))?.status).toEqual("updating");
4211
4212 // Recovery
4213 const output = yield* program.pipe(stack.deploy);
4214 expect((yield* getState("A"))?.status).toEqual("created");
4215 expect((yield* getState("B"))?.status).toEqual("updated");
4216 expect((yield* getState("C"))?.status).toEqual("updated");
4217 expect(output.C.string).toEqual("a-value-new");
4218 }),
4219 );
4220
4221 test.provider(
4222 "A replaced, B and C updated, old A delete fails - recovery cleans up",
4223 (stack) =>
4224 Effect.gen(function* () {
4225 yield* Effect.gen(function* () {
4226 const A = yield* TestResource("A", {
4227 string: "a-value",
4228 replaceString: "original",
4229 });
4230 const B = yield* TestResource("B", { string: A.string });
4231 yield* TestResource("C", { string: B.string });
4232 }).pipe(stack.deploy);
4233
4234 const program = Effect.gen(function* () {
4235 const A = yield* TestResource("A", {
4236 string: "a-value-new",
4237 replaceString: "changed",
4238 });
4239 const B = yield* TestResource("B", { string: A.string });
4240 const C = yield* TestResource("C", { string: B.string });
4241 return { A, B, C };
4242 });
4243
4244 yield* program.pipe(stack.deploy, hook(failOn("A", "delete")));
4245
4246 expect(
4247 (yield* getState<ReplacedResourceState>("A"))?.status,
4248 ).toEqual("replaced");
4249 expect((yield* getState("B"))?.status).toEqual("updated");
4250 expect((yield* getState("C"))?.status).toEqual("updated");
4251
4252 // Recovery
4253 const output = yield* program.pipe(stack.deploy);
4254 expect((yield* getState("A"))?.status).toEqual("created");
4255 expect((yield* getState("B"))?.status).toEqual("updated");
4256 expect((yield* getState("C"))?.status).toEqual("updated");
4257 expect(output.C.string).toEqual("a-value-new");
4258 }),
4259 );
4260 });
4261
4262 describe("delete order failures", () => {
4263 test.provider("C delete fails - A and B waiting", (stack) =>
4264 Effect.gen(function* () {
4265 yield* Effect.gen(function* () {
4266 const A = yield* TestResource("A", { string: "a-value" });
4267 const B = yield* TestResource("B", { string: A.string });
4268 yield* TestResource("C", { string: B.string });
4269 }).pipe(stack.deploy);
4270
4271 yield* stack.destroy().pipe(hook(failOn("C", "delete")));
4272
4273 expect((yield* getState("C"))?.status).toEqual("deleting");
4274 expect((yield* getState("B"))?.status).toEqual("created");
4275 expect((yield* getState("A"))?.status).toEqual("created");
4276
4277 // Recovery
4278 yield* stack.destroy();
4279 expect(yield* getState("A")).toBeUndefined();
4280 expectNotStarted(yield* getState("B"));
4281 expectNotStarted(yield* getState("C"));
4282 }),
4283 );
4284
4285 test.provider("C deleted, B delete fails - A waiting", (stack) =>
4286 Effect.gen(function* () {
4287 yield* Effect.gen(function* () {
4288 const A = yield* TestResource("A", { string: "a-value" });
4289 const B = yield* TestResource("B", { string: A.string });
4290 yield* TestResource("C", { string: B.string });
4291 }).pipe(stack.deploy);
4292
4293 yield* stack.destroy().pipe(hook(failOn("B", "delete")));
4294
4295 expectNotStarted(yield* getState("C"));
4296 expect((yield* getState("B"))?.status).toEqual("deleting");
4297 expect((yield* getState("A"))?.status).toEqual("created");
4298
4299 // Recovery
4300 yield* stack.destroy();
4301 expect(yield* getState("A")).toBeUndefined();
4302 expectNotStarted(yield* getState("B"));
4303 }),
4304 );
4305
4306 test.provider("C and B deleted, A delete fails", (stack) =>
4307 Effect.gen(function* () {
4308 yield* Effect.gen(function* () {
4309 const A = yield* TestResource("A", { string: "a-value" });
4310 const B = yield* TestResource("B", { string: A.string });
4311 yield* TestResource("C", { string: B.string });
4312 }).pipe(stack.deploy);
4313
4314 yield* stack.destroy().pipe(hook(failOn("A", "delete")));
4315
4316 expectNotStarted(yield* getState("C"));
4317 expectNotStarted(yield* getState("B"));
4318 expect((yield* getState("A"))?.status).toEqual("deleting");
4319
4320 // Recovery
4321 yield* stack.destroy();
4322 expect(yield* getState("A")).toBeUndefined();
4323 }),
4324 );
4325 });
4326 },
4327);
4328
4329// =============================================================================
4330// DIAMOND DEPENDENCIES (D depends on B and C, both depend on A)
4331// A
4332// / \
4333// B C
4334// \ /
4335// D
4336// =============================================================================
4337
4338describe(
4339 "diamond dependencies (A -> B,C -> D)",
4340 { tags: ["unit", "local"] },
4341 () => {
4342 describe("happy path", () => {
4343 test.provider("create all four resources", (stack) =>
4344 Effect.gen(function* () {
4345 const output = yield* Effect.gen(function* () {
4346 const A = yield* TestResource("A", { string: "a-value" });
4347 const B = yield* TestResource("B", { string: A.string });
4348 const C = yield* TestResource("C", { string: A.string });
4349 const D = yield* TestResource("D", {
4350 string: Output.interpolate`${B.string}-${C.string}`,
4351 });
4352 return { A, B, C, D };
4353 }).pipe(stack.deploy);
4354
4355 expect((yield* getState("A"))?.status).toEqual("created");
4356 expect((yield* getState("B"))?.status).toEqual("created");
4357 expect((yield* getState("C"))?.status).toEqual("created");
4358 expect((yield* getState("D"))?.status).toEqual("created");
4359 expect(output.D.string).toEqual("a-value-a-value");
4360 }),
4361 );
4362
4363 test.provider("update A propagates to B, C, and D", (stack) =>
4364 Effect.gen(function* () {
4365 yield* Effect.gen(function* () {
4366 const A = yield* TestResource("A", { string: "a-value" });
4367 const B = yield* TestResource("B", { string: A.string });
4368 const C = yield* TestResource("C", { string: A.string });
4369 yield* TestResource("D", {
4370 string: Output.interpolate`${B.string}-${C.string}`,
4371 });
4372 }).pipe(stack.deploy);
4373
4374 const output = yield* Effect.gen(function* () {
4375 const A = yield* TestResource("A", { string: "updated" });
4376 const B = yield* TestResource("B", { string: A.string });
4377 const C = yield* TestResource("C", { string: A.string });
4378 const D = yield* TestResource("D", {
4379 string: Output.interpolate`${B.string}-${C.string}`,
4380 });
4381 return { A, B, C, D };
4382 }).pipe(stack.deploy);
4383
4384 expect((yield* getState("A"))?.status).toEqual("updated");
4385 expect((yield* getState("B"))?.status).toEqual("updated");
4386 expect((yield* getState("C"))?.status).toEqual("updated");
4387 expect((yield* getState("D"))?.status).toEqual("updated");
4388 expect(output.D.string).toEqual("updated-updated");
4389 }),
4390 );
4391
4392 test.provider("add D while B replaces and C noops", (stack) =>
4393 Effect.gen(function* () {
4394 yield* Effect.gen(function* () {
4395 const A = yield* TestResource("A", { string: "a-value" });
4396 yield* TestResource("B", {
4397 string: Output.interpolate`${A.string}-b`,
4398 replaceString: "b-original",
4399 });
4400 yield* TestResource("C", {
4401 string: Output.interpolate`${A.string}-c`,
4402 replaceString: "c-original",
4403 });
4404 }).pipe(stack.deploy);
4405
4406 const output = yield* Effect.gen(function* () {
4407 const A = yield* TestResource("A", { string: "a-value" });
4408 const B = yield* TestResource("B", {
4409 string: Output.interpolate`${A.string}-b-replaced`,
4410 replaceString: "b-changed",
4411 });
4412 const C = yield* TestResource("C", {
4413 string: Output.interpolate`${A.string}-c`,
4414 replaceString: "c-original",
4415 });
4416 const D = yield* TestResource("D", {
4417 string: Output.interpolate`${B.string}-${C.string}`,
4418 });
4419 return { A, B, C, D };
4420 }).pipe(stack.deploy);
4421
4422 expect((yield* getState("A"))?.status).toEqual("created");
4423 expect((yield* getState("B"))?.status).toEqual("created");
4424 expect((yield* getState("C"))?.status).toEqual("created");
4425 expect((yield* getState("D"))?.status).toEqual("created");
4426 expect(output.B.replaceString).toEqual("b-changed");
4427 expect(output.C.replaceString).toEqual("c-original");
4428 expect(output.D.string).toEqual("a-value-b-replaced-a-value-c");
4429 }),
4430 );
4431
4432 test.provider("add D while C replaces and B noops", (stack) =>
4433 Effect.gen(function* () {
4434 yield* Effect.gen(function* () {
4435 const A = yield* TestResource("A", { string: "a-value" });
4436 yield* TestResource("B", {
4437 string: Output.interpolate`${A.string}-b`,
4438 replaceString: "b-original",
4439 });
4440 yield* TestResource("C", {
4441 string: Output.interpolate`${A.string}-c`,
4442 replaceString: "c-original",
4443 });
4444 }).pipe(stack.deploy);
4445
4446 const output = yield* Effect.gen(function* () {
4447 const A = yield* TestResource("A", { string: "a-value" });
4448 const B = yield* TestResource("B", {
4449 string: Output.interpolate`${A.string}-b`,
4450 replaceString: "b-original",
4451 });
4452 const C = yield* TestResource("C", {
4453 string: Output.interpolate`${A.string}-c-replaced`,
4454 replaceString: "c-changed",
4455 });
4456 const D = yield* TestResource("D", {
4457 string: Output.interpolate`${B.string}-${C.string}`,
4458 });
4459 return { A, B, C, D };
4460 }).pipe(stack.deploy);
4461
4462 expect((yield* getState("A"))?.status).toEqual("created");
4463 expect((yield* getState("B"))?.status).toEqual("created");
4464 expect((yield* getState("C"))?.status).toEqual("created");
4465 expect((yield* getState("D"))?.status).toEqual("created");
4466 expect(output.B.replaceString).toEqual("b-original");
4467 expect(output.C.replaceString).toEqual("c-changed");
4468 expect(output.D.string).toEqual("a-value-b-a-value-c-replaced");
4469 }),
4470 );
4471
4472 test.provider("add D while both B and C replace", (stack) =>
4473 Effect.gen(function* () {
4474 yield* Effect.gen(function* () {
4475 const A = yield* TestResource("A", { string: "a-value" });
4476 yield* TestResource("B", {
4477 string: Output.interpolate`${A.string}-b`,
4478 replaceString: "b-original",
4479 });
4480 yield* TestResource("C", {
4481 string: Output.interpolate`${A.string}-c`,
4482 replaceString: "c-original",
4483 });
4484 }).pipe(stack.deploy);
4485
4486 const output = yield* Effect.gen(function* () {
4487 const A = yield* TestResource("A", { string: "a-value" });
4488 const B = yield* TestResource("B", {
4489 string: Output.interpolate`${A.string}-b-replaced`,
4490 replaceString: "b-changed",
4491 });
4492 const C = yield* TestResource("C", {
4493 string: Output.interpolate`${A.string}-c-replaced`,
4494 replaceString: "c-changed",
4495 });
4496 const D = yield* TestResource("D", {
4497 string: Output.interpolate`${B.string}-${C.string}`,
4498 });
4499 return { A, B, C, D };
4500 }).pipe(stack.deploy);
4501
4502 expect((yield* getState("A"))?.status).toEqual("created");
4503 expect((yield* getState("B"))?.status).toEqual("created");
4504 expect((yield* getState("C"))?.status).toEqual("created");
4505 expect((yield* getState("D"))?.status).toEqual("created");
4506 expect(output.B.replaceString).toEqual("b-changed");
4507 expect(output.C.replaceString).toEqual("c-changed");
4508 expect(output.D.string).toEqual(
4509 "a-value-b-replaced-a-value-c-replaced",
4510 );
4511 }),
4512 );
4513
4514 test.provider("delete all (D first, then B and C, then A)", (stack) =>
4515 Effect.gen(function* () {
4516 yield* Effect.gen(function* () {
4517 const A = yield* TestResource("A", { string: "a-value" });
4518 const B = yield* TestResource("B", { string: A.string });
4519 const C = yield* TestResource("C", { string: A.string });
4520 yield* TestResource("D", {
4521 string: Output.interpolate`${B.string}-${C.string}`,
4522 });
4523 }).pipe(stack.deploy);
4524
4525 yield* stack.destroy();
4526
4527 expect(yield* getState("A")).toBeUndefined();
4528 expectNotStarted(yield* getState("B"));
4529 expectNotStarted(yield* getState("C"));
4530 expectNotStarted(yield* getState("D"));
4531 }),
4532 );
4533 });
4534
4535 describe("creation failures", () => {
4536 test.provider("A create fails - B, C, D never start", (stack) =>
4537 Effect.gen(function* () {
4538 const program = Effect.gen(function* () {
4539 const A = yield* TestResource("A", { string: "a-value" });
4540 const B = yield* TestResource("B", { string: A.string });
4541 const C = yield* TestResource("C", { string: A.string });
4542 const D = yield* TestResource("D", {
4543 string: Output.interpolate`${B.string}-${C.string}`,
4544 });
4545 return { A, B, C, D };
4546 });
4547
4548 yield* program.pipe(stack.deploy, hook(failOn("A", "create")));
4549
4550 expect((yield* getState("A"))?.status).toEqual("creating");
4551 expectNotStarted(yield* getState("B"));
4552 expectNotStarted(yield* getState("C"));
4553 expectNotStarted(yield* getState("D"));
4554
4555 // Recovery
4556 const output = yield* program.pipe(stack.deploy);
4557 expect((yield* getState("A"))?.status).toEqual("created");
4558 expect((yield* getState("B"))?.status).toEqual("created");
4559 expect((yield* getState("C"))?.status).toEqual("created");
4560 expect((yield* getState("D"))?.status).toEqual("created");
4561 expect(output.D.string).toEqual("a-value-a-value");
4562 }),
4563 );
4564
4565 test.provider(
4566 "A creates, B create fails - C may create, D stuck",
4567 (stack) =>
4568 Effect.gen(function* () {
4569 const program = Effect.gen(function* () {
4570 const A = yield* TestResource("A", { string: "a-value" });
4571 const B = yield* TestResource("B", { string: A.string });
4572 const C = yield* TestResource("C", { string: A.string });
4573 const D = yield* TestResource("D", {
4574 string: Output.interpolate`${B.string}-${C.string}`,
4575 });
4576 return { A, B, C, D };
4577 });
4578
4579 yield* program.pipe(stack.deploy, hook(failOn("B", "create")));
4580
4581 expect((yield* getState("A"))?.status).toEqual("created");
4582 expect((yield* getState("B"))?.status).toEqual("creating");
4583 // C might have been created since it doesn't depend on B
4584 const cState = yield* getState("C");
4585 expect(cState === undefined || cState?.status === "created").toBe(
4586 true,
4587 );
4588 expectNotStarted(yield* getState("D"));
4589
4590 // Recovery
4591 const output = yield* program.pipe(stack.deploy);
4592 expect((yield* getState("A"))?.status).toEqual("created");
4593 expect((yield* getState("B"))?.status).toEqual("created");
4594 expect((yield* getState("C"))?.status).toEqual("created");
4595 expect((yield* getState("D"))?.status).toEqual("created");
4596 expect(output.D.string).toEqual("a-value-a-value");
4597 }),
4598 );
4599
4600 test.provider(
4601 "A creates, C create fails - B may create, D stuck",
4602 (stack) =>
4603 Effect.gen(function* () {
4604 const program = Effect.gen(function* () {
4605 const A = yield* TestResource("A", { string: "a-value" });
4606 const B = yield* TestResource("B", { string: A.string });
4607 const C = yield* TestResource("C", { string: A.string });
4608 const D = yield* TestResource("D", {
4609 string: Output.interpolate`${B.string}-${C.string}`,
4610 });
4611 return { A, B, C, D };
4612 });
4613
4614 yield* program.pipe(stack.deploy, hook(failOn("C", "create")));
4615
4616 expect((yield* getState("A"))?.status).toEqual("created");
4617 expect((yield* getState("C"))?.status).toEqual("creating");
4618 // B might have been created since it doesn't depend on C
4619 const bState = yield* getState("B");
4620 expect(bState === undefined || bState?.status === "created").toBe(
4621 true,
4622 );
4623 expectNotStarted(yield* getState("D"));
4624
4625 // Recovery
4626 const output = yield* program.pipe(stack.deploy);
4627 expect((yield* getState("A"))?.status).toEqual("created");
4628 expect((yield* getState("B"))?.status).toEqual("created");
4629 expect((yield* getState("C"))?.status).toEqual("created");
4630 expect((yield* getState("D"))?.status).toEqual("created");
4631 expect(output.D.string).toEqual("a-value-a-value");
4632 }),
4633 );
4634
4635 test.provider("A, B, C create - D create fails", (stack) =>
4636 Effect.gen(function* () {
4637 const program = Effect.gen(function* () {
4638 const A = yield* TestResource("A", { string: "a-value" });
4639 const B = yield* TestResource("B", { string: A.string });
4640 const C = yield* TestResource("C", { string: A.string });
4641 const D = yield* TestResource("D", {
4642 string: Output.interpolate`${B.string}-${C.string}`,
4643 });
4644 return { A, B, C, D };
4645 });
4646
4647 yield* program.pipe(stack.deploy, hook(failOn("D", "create")));
4648
4649 expect((yield* getState("A"))?.status).toEqual("created");
4650 expect((yield* getState("B"))?.status).toEqual("created");
4651 expect((yield* getState("C"))?.status).toEqual("created");
4652 expect((yield* getState("D"))?.status).toEqual("creating");
4653
4654 // Recovery
4655 const output = yield* program.pipe(stack.deploy);
4656 expect((yield* getState("D"))?.status).toEqual("created");
4657 expect(output.D.string).toEqual("a-value-a-value");
4658 }),
4659 );
4660
4661 test.provider("both B and C fail to create - D stuck", (stack) =>
4662 Effect.gen(function* () {
4663 const program = Effect.gen(function* () {
4664 const A = yield* TestResource("A", { string: "a-value" });
4665 const B = yield* TestResource("B", { string: A.string });
4666 const C = yield* TestResource("C", { string: A.string });
4667 const D = yield* TestResource("D", {
4668 string: Output.interpolate`${B.string}-${C.string}`,
4669 });
4670 return { A, B, C, D };
4671 });
4672
4673 yield* program.pipe(
4674 stack.deploy,
4675 hook(
4676 failOnMultiple([
4677 { id: "B", hook: "create" },
4678 { id: "C", hook: "create" },
4679 ]),
4680 ),
4681 );
4682
4683 expect((yield* getState("A"))?.status).toEqual("created");
4684 // effect terminates eagerly, so it's possible that B or C to run first and block C from running
4685 const BState = yield* getState("B");
4686 const CState = yield* getState("C");
4687 expect(BState?.status).toBeOneOf(["creating", undefined]);
4688 expect(CState?.status).toBeOneOf(["creating", undefined]);
4689 // at leasst one of B or C should have been created
4690 expect(BState?.status ?? CState?.status).toEqual("creating");
4691
4692 expectNotStarted(yield* getState("D"));
4693
4694 // Recovery
4695 const output = yield* program.pipe(stack.deploy);
4696 expect((yield* getState("B"))?.status).toEqual("created");
4697 expect((yield* getState("C"))?.status).toEqual("created");
4698 expect((yield* getState("D"))?.status).toEqual("created");
4699 expect(output.D.string).toEqual("a-value-a-value");
4700 }),
4701 );
4702 });
4703
4704 describe("update failures", () => {
4705 test.provider("A update fails - B, C, D remain stable", (stack) =>
4706 Effect.gen(function* () {
4707 yield* Effect.gen(function* () {
4708 const A = yield* TestResource("A", { string: "a-value" });
4709 const B = yield* TestResource("B", { string: A.string });
4710 const C = yield* TestResource("C", { string: A.string });
4711 yield* TestResource("D", {
4712 string: Output.interpolate`${B.string}-${C.string}`,
4713 });
4714 }).pipe(stack.deploy);
4715
4716 const program = Effect.gen(function* () {
4717 const A = yield* TestResource("A", { string: "updated" });
4718 const B = yield* TestResource("B", { string: A.string });
4719 const C = yield* TestResource("C", { string: A.string });
4720 const D = yield* TestResource("D", {
4721 string: Output.interpolate`${B.string}-${C.string}`,
4722 });
4723 return { A, B, C, D };
4724 });
4725
4726 yield* program.pipe(stack.deploy, hook(failOn("A", "update")));
4727
4728 expect((yield* getState("A"))?.status).toEqual("updating");
4729 expect((yield* getState("B"))?.status).toEqual("created");
4730 expect((yield* getState("C"))?.status).toEqual("created");
4731 expect((yield* getState("D"))?.status).toEqual("created");
4732
4733 // Recovery
4734 const output = yield* program.pipe(stack.deploy);
4735 expect((yield* getState("A"))?.status).toEqual("updated");
4736 expect((yield* getState("B"))?.status).toEqual("updated");
4737 expect((yield* getState("C"))?.status).toEqual("updated");
4738 expect((yield* getState("D"))?.status).toEqual("updated");
4739 expect(output.D.string).toEqual("updated-updated");
4740 }),
4741 );
4742
4743 test.provider(
4744 "A updates, B update fails - C may update, D stuck",
4745 (stack) =>
4746 Effect.gen(function* () {
4747 yield* Effect.gen(function* () {
4748 const A = yield* TestResource("A", { string: "a-value" });
4749 const B = yield* TestResource("B", { string: A.string });
4750 const C = yield* TestResource("C", { string: A.string });
4751 yield* TestResource("D", {
4752 string: Output.interpolate`${B.string}-${C.string}`,
4753 });
4754 }).pipe(stack.deploy);
4755
4756 const program = Effect.gen(function* () {
4757 const A = yield* TestResource("A", { string: "updated" });
4758 const B = yield* TestResource("B", { string: A.string });
4759 const C = yield* TestResource("C", { string: A.string });
4760 const D = yield* TestResource("D", {
4761 string: Output.interpolate`${B.string}-${C.string}`,
4762 });
4763 return { A, B, C, D };
4764 });
4765
4766 yield* program.pipe(stack.deploy, hook(failOn("B", "update")));
4767
4768 expect((yield* getState("A"))?.status).toEqual("updated");
4769 expect((yield* getState("B"))?.status).toEqual("updating");
4770 // C might have been updated since it doesn't depend on B
4771 const cState = yield* getState("C");
4772 expect(
4773 cState?.status === "created" || cState?.status === "updated",
4774 ).toBe(true);
4775 expect((yield* getState("D"))?.status).toEqual("created");
4776
4777 // Recovery
4778 const output = yield* program.pipe(stack.deploy);
4779 expect((yield* getState("B"))?.status).toEqual("updated");
4780 expect((yield* getState("C"))?.status).toEqual("updated");
4781 expect((yield* getState("D"))?.status).toEqual("updated");
4782 expect(output.D.string).toEqual("updated-updated");
4783 }),
4784 );
4785
4786 test.provider("A, B, C update - D update fails", (stack) =>
4787 Effect.gen(function* () {
4788 yield* Effect.gen(function* () {
4789 const A = yield* TestResource("A", { string: "a-value" });
4790 const B = yield* TestResource("B", { string: A.string });
4791 const C = yield* TestResource("C", { string: A.string });
4792 yield* TestResource("D", {
4793 string: Output.interpolate`${B.string}-${C.string}`,
4794 });
4795 }).pipe(stack.deploy);
4796
4797 const program = Effect.gen(function* () {
4798 const A = yield* TestResource("A", { string: "updated" });
4799 const B = yield* TestResource("B", { string: A.string });
4800 const C = yield* TestResource("C", { string: A.string });
4801 const D = yield* TestResource("D", {
4802 string: Output.interpolate`${B.string}-${C.string}`,
4803 });
4804 return { A, B, C, D };
4805 });
4806
4807 yield* program.pipe(stack.deploy, hook(failOn("D", "update")));
4808
4809 expect((yield* getState("A"))?.status).toEqual("updated");
4810 expect((yield* getState("B"))?.status).toEqual("updated");
4811 expect((yield* getState("C"))?.status).toEqual("updated");
4812 expect((yield* getState("D"))?.status).toEqual("updating");
4813
4814 // Recovery
4815 const output = yield* program.pipe(stack.deploy);
4816 expect((yield* getState("D"))?.status).toEqual("updated");
4817 expect(output.D.string).toEqual("updated-updated");
4818 }),
4819 );
4820 });
4821
4822 describe("delete failures", () => {
4823 test.provider("D delete fails - B, C, A waiting", (stack) =>
4824 Effect.gen(function* () {
4825 yield* Effect.gen(function* () {
4826 const A = yield* TestResource("A", { string: "a-value" });
4827 const B = yield* TestResource("B", { string: A.string });
4828 const C = yield* TestResource("C", { string: A.string });
4829 yield* TestResource("D", {
4830 string: Output.interpolate`${B.string}-${C.string}`,
4831 });
4832 }).pipe(stack.deploy);
4833
4834 yield* stack.destroy().pipe(hook(failOn("D", "delete")));
4835
4836 expect((yield* getState("D"))?.status).toEqual("deleting");
4837 expect((yield* getState("B"))?.status).toEqual("created");
4838 expect((yield* getState("C"))?.status).toEqual("created");
4839 expect((yield* getState("A"))?.status).toEqual("created");
4840
4841 // Recovery
4842 yield* stack.destroy();
4843 expect(yield* getState("A")).toBeUndefined();
4844 expectNotStarted(yield* getState("B"));
4845 expectNotStarted(yield* getState("C"));
4846 expectNotStarted(yield* getState("D"));
4847 }),
4848 );
4849
4850 test.provider(
4851 "D deleted, B delete fails - C may delete, A waiting",
4852 (stack) =>
4853 Effect.gen(function* () {
4854 yield* Effect.gen(function* () {
4855 const A = yield* TestResource("A", { string: "a-value" });
4856 const B = yield* TestResource("B", { string: A.string });
4857 const C = yield* TestResource("C", { string: A.string });
4858 yield* TestResource("D", {
4859 string: Output.interpolate`${B.string}-${C.string}`,
4860 });
4861 }).pipe(stack.deploy);
4862
4863 yield* stack.destroy().pipe(hook(failOn("B", "delete")));
4864
4865 expectNotStarted(yield* getState("D"));
4866 expect((yield* getState("B"))?.status).toEqual("deleting");
4867 // C may or may not be deleted depending on execution order
4868 const cState = yield* getState("C");
4869 expect(cState === undefined || cState?.status === "created").toBe(
4870 true,
4871 );
4872 expect((yield* getState("A"))?.status).toEqual("created");
4873
4874 // Recovery
4875 yield* stack.destroy();
4876 expect(yield* getState("A")).toBeUndefined();
4877 expectNotStarted(yield* getState("B"));
4878 expectNotStarted(yield* getState("C"));
4879 }),
4880 );
4881 });
4882 },
4883);
4884
4885// =============================================================================
4886// INDEPENDENT RESOURCES (no dependencies between them)
4887// =============================================================================
4888
4889describe(
4890 "independent resources (A, B with no dependencies)",
4891 { tags: ["unit", "local"] },
4892 () => {
4893 describe("parallel failures", () => {
4894 test.provider("both A and B fail to create", (stack) =>
4895 Effect.gen(function* () {
4896 const program = Effect.gen(function* () {
4897 const A = yield* TestResource("A", { string: "a-value" });
4898 const B = yield* TestResource("B", { string: "b-value" });
4899 return { A, B };
4900 });
4901
4902 yield* program.pipe(
4903 stack.deploy,
4904 hook(
4905 failOnMultiple([
4906 { id: "A", hook: "create" },
4907 { id: "B", hook: "create" },
4908 ]),
4909 ),
4910 );
4911
4912 // effect terminates eagerly, so it's possible that A or B runs first and blocks the other from running
4913 const AState = yield* getState("A");
4914 const BState = yield* getState("B");
4915 expect(AState?.status).toBeOneOf(["creating", undefined]);
4916 expect(BState?.status).toBeOneOf(["creating", undefined]);
4917 // at least one of A or B should have been creating
4918 expect(AState?.status ?? BState?.status).toEqual("creating");
4919
4920 // Recovery
4921 const output = yield* program.pipe(stack.deploy);
4922 expect((yield* getState("A"))?.status).toEqual("created");
4923 expect((yield* getState("B"))?.status).toEqual("created");
4924 expect(output.A.string).toEqual("a-value");
4925 expect(output.B.string).toEqual("b-value");
4926 }),
4927 );
4928
4929 test.provider("A creates, B fails - recovery creates B", (stack) =>
4930 Effect.gen(function* () {
4931 const program = Effect.gen(function* () {
4932 const A = yield* TestResource("A", { string: "a-value" });
4933 const B = yield* TestResource("B", { string: "b-value" });
4934 return { A, B };
4935 });
4936
4937 yield* program.pipe(stack.deploy, hook(failOn("B", "create")));
4938
4939 expect((yield* getState("A"))?.status).toEqual("created");
4940 expect((yield* getState("B"))?.status).toEqual("creating");
4941
4942 // Recovery
4943 const output = yield* program.pipe(stack.deploy);
4944 expect((yield* getState("A"))?.status).toEqual("created");
4945 expect((yield* getState("B"))?.status).toEqual("created");
4946 expect(output.B.string).toEqual("b-value");
4947 }),
4948 );
4949
4950 test.provider("A update fails, B update succeeds", (stack) =>
4951 Effect.gen(function* () {
4952 yield* Effect.gen(function* () {
4953 yield* TestResource("A", { string: "a-value" });
4954 yield* TestResource("B", { string: "b-value" });
4955 }).pipe(stack.deploy);
4956
4957 const program = Effect.gen(function* () {
4958 const A = yield* TestResource("A", { string: "a-updated" });
4959 const B = yield* TestResource("B", { string: "b-updated" });
4960 return { A, B };
4961 });
4962
4963 yield* program.pipe(stack.deploy, hook(failOn("A", "update")));
4964
4965 expect((yield* getState("A"))?.status).toEqual("updating");
4966 // B might have been updated
4967 const bState = yield* getState("B");
4968 expect(
4969 bState?.status === "created" || bState?.status === "updated",
4970 ).toBe(true);
4971
4972 // Recovery
4973 const output = yield* program.pipe(stack.deploy);
4974 expect((yield* getState("A"))?.status).toEqual("updated");
4975 expect((yield* getState("B"))?.status).toEqual("updated");
4976 expect(output.A.string).toEqual("a-updated");
4977 expect(output.B.string).toEqual("b-updated");
4978 }),
4979 );
4980 });
4981
4982 describe("mixed state recovery", () => {
4983 test.provider(
4984 "A in creating, B in updating state - recovery completes both",
4985 (stack) =>
4986 Effect.gen(function* () {
4987 // First create B successfully
4988 yield* Effect.gen(function* () {
4989 yield* TestResource("B", { string: "b-value" });
4990 }).pipe(stack.deploy);
4991 expect((yield* getState("B"))?.status).toEqual("created");
4992
4993 // Now try to create A and update B - A fails
4994 const program = Effect.gen(function* () {
4995 const A = yield* TestResource("A", { string: "a-value" });
4996 const B = yield* TestResource("B", { string: "b-updated" });
4997 return { A, B };
4998 });
4999
5000 yield* program.pipe(
5001 stack.deploy,
5002 hook(
5003 failOnMultiple([
5004 { id: "A", hook: "create" },
5005 { id: "B", hook: "update" },
5006 ]),
5007 ),
5008 );
5009
5010 // effect terminates eagerly, so it's possible that A or B runs first and blocks the other from running
5011 const AState = yield* getState("A");
5012 const BState = yield* getState("B");
5013 expect(AState?.status).toBeOneOf(["creating", undefined]);
5014 expect(BState?.status).toBeOneOf(["created", "updating"]);
5015 // at least one of A or B should have started their failing operation
5016 expect(
5017 AState?.status === "creating" || BState?.status === "updating",
5018 ).toBe(true);
5019
5020 // Recovery
5021 const output = yield* program.pipe(stack.deploy);
5022 expect((yield* getState("A"))?.status).toEqual("created");
5023 expect((yield* getState("B"))?.status).toEqual("updated");
5024 expect(output.A.string).toEqual("a-value");
5025 expect(output.B.string).toEqual("b-updated");
5026 }),
5027 );
5028
5029 test.provider(
5030 "A in replacing, B in deleting state - complex recovery",
5031 (stack) =>
5032 Effect.gen(function* () {
5033 // Create both
5034 yield* Effect.gen(function* () {
5035 yield* TestResource("A", { replaceString: "original" });
5036 yield* TestResource("B", { string: "b-value" });
5037 }).pipe(stack.deploy);
5038 expect((yield* getState("A"))?.status).toEqual("created");
5039 expect((yield* getState("B"))?.status).toEqual("created");
5040
5041 // Try to replace A and delete B (by not including B) - both fail
5042 const program = Effect.gen(function* () {
5043 yield* TestResource("A", { replaceString: "changed" });
5044 });
5045
5046 yield* program.pipe(
5047 stack.deploy,
5048 hook(
5049 failOnMultiple([
5050 { id: "A", hook: "create" },
5051 { id: "B", hook: "delete" },
5052 ]),
5053 ),
5054 );
5055
5056 // effect terminates eagerly, so it's possible that A or B runs first and blocks the other from running
5057 const AState = yield* getState<ReplacingResourceState>("A");
5058 const BState = yield* getState("B");
5059 expect(AState?.status).toBeOneOf(["created", "replacing"]);
5060 expect(BState?.status).toBeOneOf(["created", "deleting"]);
5061 // at least one of A or B should have started their failing operation
5062 expect(
5063 AState?.status === "replacing" || BState?.status === "deleting",
5064 ).toBe(true);
5065
5066 // Recovery - complete the replace and delete
5067 yield* program.pipe(stack.deploy);
5068 expect((yield* getState("A"))?.status).toEqual("created");
5069 expectNotStarted(yield* getState("B"));
5070 }),
5071 );
5072 });
5073 },
5074);
5075
5076// =============================================================================
5077// MULTIPLE RESOURCES REPLACING SIMULTANEOUSLY
5078// =============================================================================
5079
5080describe("multiple resources replacing", { tags: ["unit", "local"] }, () => {
5081 test.provider("two independent resources replace successfully", (stack) =>
5082 Effect.gen(function* () {
5083 yield* Effect.gen(function* () {
5084 yield* TestResource("A", { replaceString: "a-original" });
5085 yield* TestResource("B", { replaceString: "b-original" });
5086 }).pipe(stack.deploy);
5087
5088 const output = yield* Effect.gen(function* () {
5089 const A = yield* TestResource("A", { replaceString: "a-new" });
5090 const B = yield* TestResource("B", { replaceString: "b-new" });
5091 return { A, B };
5092 }).pipe(stack.deploy);
5093
5094 expect((yield* getState("A"))?.status).toEqual("created");
5095 expect((yield* getState("B"))?.status).toEqual("created");
5096 expect(output.A.replaceString).toEqual("a-new");
5097 expect(output.B.replaceString).toEqual("b-new");
5098 }),
5099 );
5100
5101 test.provider(
5102 "A replace fails, B replace succeeds - recovery completes A",
5103 (stack) =>
5104 Effect.gen(function* () {
5105 yield* Effect.gen(function* () {
5106 yield* TestResource("A", { replaceString: "a-original" });
5107 yield* TestResource("B", { replaceString: "b-original" });
5108 }).pipe(stack.deploy);
5109
5110 const program = Effect.gen(function* () {
5111 const A = yield* TestResource("A", { replaceString: "a-new" });
5112 const B = yield* TestResource("B", { replaceString: "b-new" });
5113 return { A, B };
5114 });
5115
5116 yield* program.pipe(stack.deploy, hook(failOn("A", "create")));
5117
5118 expect((yield* getState<ReplacingResourceState>("A"))?.status).toEqual(
5119 "replacing",
5120 );
5121 // B might have been replaced
5122 const bState = yield* getState("B");
5123 expect(
5124 bState?.status === "created" ||
5125 bState?.status === "replacing" ||
5126 bState?.status === "replaced",
5127 ).toBe(true);
5128
5129 // Recovery
5130 const output = yield* program.pipe(stack.deploy);
5131 expect((yield* getState("A"))?.status).toEqual("created");
5132 expect((yield* getState("B"))?.status).toEqual("created");
5133 expect(output.A.replaceString).toEqual("a-new");
5134 expect(output.B.replaceString).toEqual("b-new");
5135 }),
5136 );
5137
5138 test.provider(
5139 "both A and B replace fail - recovery completes both",
5140 (stack) =>
5141 Effect.gen(function* () {
5142 yield* Effect.gen(function* () {
5143 yield* TestResource("A", { replaceString: "a-original" });
5144 yield* TestResource("B", { replaceString: "b-original" });
5145 }).pipe(stack.deploy);
5146
5147 const program = Effect.gen(function* () {
5148 const A = yield* TestResource("A", { replaceString: "a-new" });
5149 const B = yield* TestResource("B", { replaceString: "b-new" });
5150 return { A, B };
5151 });
5152
5153 yield* program.pipe(
5154 stack.deploy,
5155 hook(
5156 failOnMultiple([
5157 { id: "A", hook: "create" },
5158 { id: "B", hook: "create" },
5159 ]),
5160 ),
5161 );
5162
5163 // effect terminates eagerly, so it's possible that A or B runs first and blocks the other from running
5164 const AState = yield* getState<ReplacingResourceState>("A");
5165 const BState = yield* getState<ReplacingResourceState>("B");
5166 expect(AState?.status).toBeOneOf(["created", "replacing"]);
5167 expect(BState?.status).toBeOneOf(["created", "replacing"]);
5168 // at least one of A or B should have started replacing
5169 expect(
5170 AState?.status === "replacing" || BState?.status === "replacing",
5171 ).toBe(true);
5172
5173 // Recovery
5174 const output = yield* program.pipe(stack.deploy);
5175 expect((yield* getState("A"))?.status).toEqual("created");
5176 expect((yield* getState("B"))?.status).toEqual("created");
5177 expect(output.A.replaceString).toEqual("a-new");
5178 expect(output.B.replaceString).toEqual("b-new");
5179 }),
5180 );
5181
5182 test.provider(
5183 "A replaced, B replacing - old A delete fails, B create fails - recovery completes both",
5184 (stack) =>
5185 Effect.gen(function* () {
5186 yield* Effect.gen(function* () {
5187 yield* TestResource("A", { replaceString: "a-original" });
5188 yield* TestResource("B", { replaceString: "b-original" });
5189 }).pipe(stack.deploy);
5190
5191 const program = Effect.gen(function* () {
5192 const A = yield* TestResource("A", { replaceString: "a-new" });
5193 const B = yield* TestResource("B", { replaceString: "b-new" });
5194 return { A, B };
5195 });
5196
5197 yield* program.pipe(
5198 stack.deploy,
5199 hook(
5200 failOnMultiple([
5201 { id: "A", hook: "delete" },
5202 { id: "B", hook: "create" },
5203 ]),
5204 ),
5205 );
5206
5207 // effect terminates eagerly, so it's possible that A or B runs first and blocks the other from running
5208 // A should be replaced (new created, old pending delete) or still replacing/created if B failed first
5209 // B should be replacing (new not yet created) or already created if A failed first
5210 const AState = yield* getState<ReplacedResourceState>("A");
5211 const BState = yield* getState<ReplacingResourceState>("B");
5212 expect(AState?.status).toBeOneOf(["created", "replacing", "replaced"]);
5213 expect(BState?.status).toBeOneOf(["created", "replacing"]);
5214 // at least one of A or B should have started their failing operation
5215 expect(
5216 AState?.status === "replaced" || BState?.status === "replacing",
5217 ).toBe(true);
5218
5219 // Recovery
5220 const output = yield* program.pipe(stack.deploy);
5221 expect((yield* getState("A"))?.status).toEqual("created");
5222 expect((yield* getState("B"))?.status).toEqual("created");
5223 expect(output.A.replaceString).toEqual("a-new");
5224 expect(output.B.replaceString).toEqual("b-new");
5225 }),
5226 );
5227});
5228
5229describe("repeated replacements", { tags: ["unit", "local"] }, () => {
5230 test.provider(
5231 "resource can be replaced again while still in replacing state",
5232 (stack) =>
5233 Effect.gen(function* () {
5234 yield* Effect.gen(function* () {
5235 yield* TestResource("A", { replaceString: "a-original" });
5236 }).pipe(stack.deploy);
5237
5238 const firstReplacement = Effect.gen(function* () {
5239 yield* TestResource("A", { replaceString: "a-first" });
5240 });
5241
5242 yield* firstReplacement.pipe(stack.deploy, hook(failOn("A", "create")));
5243
5244 const replacingState = yield* getState<ReplacingResourceState>("A");
5245 expect(replacingState?.status).toEqual("replacing");
5246
5247 const secondReplacement = Effect.gen(function* () {
5248 yield* TestResource("A", { replaceString: "a-second" });
5249 });
5250
5251 yield* secondReplacement.pipe(stack.deploy);
5252
5253 const finalState = yield* getState("A");
5254 expectConvergedStatus(finalState?.status);
5255 expect(finalState?.props?.replaceString).toEqual("a-second");
5256 }),
5257 );
5258
5259 test.provider(
5260 "resource can be replaced again while still in replaced state",
5261 (stack) =>
5262 Effect.gen(function* () {
5263 yield* Effect.gen(function* () {
5264 yield* TestResource("A", { replaceString: "a-original" });
5265 }).pipe(stack.deploy);
5266
5267 const firstReplacement = Effect.gen(function* () {
5268 yield* TestResource("A", { replaceString: "a-first" });
5269 });
5270
5271 yield* firstReplacement.pipe(stack.deploy, hook(failOn("A", "delete")));
5272
5273 const replacedState = yield* getState<ReplacedResourceState>("A");
5274 expect(replacedState?.status).toEqual("replaced");
5275
5276 const secondReplacement = Effect.gen(function* () {
5277 yield* TestResource("A", { replaceString: "a-second" });
5278 });
5279
5280 yield* secondReplacement.pipe(stack.deploy);
5281
5282 const finalState = yield* getState("A");
5283 expectConvergedStatus(finalState?.status);
5284 expect(finalState?.props?.replaceString).toEqual("a-second");
5285 }),
5286 );
5287});
5288
5289// =============================================================================
5290// ORPHAN CHAIN DELETION
5291// =============================================================================
5292
5293describe("orphan chain deletion", { tags: ["unit", "local"] }, () => {
5294 test.provider("three-level orphan chain deleted in correct order", (stack) =>
5295 Effect.gen(function* () {
5296 yield* Effect.gen(function* () {
5297 const A = yield* TestResource("A", { string: "a-value" });
5298 const B = yield* TestResource("B", { string: A.string });
5299 yield* TestResource("C", { string: B.string });
5300 }).pipe(stack.deploy);
5301 expect((yield* getState("A"))?.status).toEqual("created");
5302 expect((yield* getState("B"))?.status).toEqual("created");
5303 expect((yield* getState("C"))?.status).toEqual("created");
5304
5305 // Remove C from graph - should delete C only
5306 yield* Effect.gen(function* () {
5307 const A = yield* TestResource("A", { string: "a-value" });
5308 yield* TestResource("B", { string: A.string });
5309 }).pipe(stack.deploy);
5310 expect((yield* getState("A"))?.status).toEqual("created");
5311 expect((yield* getState("B"))?.status).toEqual("created");
5312 expectNotStarted(yield* getState("C"));
5313 }),
5314 );
5315
5316 test.provider(
5317 "orphan with intermediate failure recovers correctly",
5318 (stack) =>
5319 Effect.gen(function* () {
5320 yield* Effect.gen(function* () {
5321 const A = yield* TestResource("A", { string: "a-value" });
5322 const B = yield* TestResource("B", { string: A.string });
5323 yield* TestResource("C", { string: B.string });
5324 }).pipe(stack.deploy);
5325
5326 // Remove all three - C fails to delete
5327 yield* stack.destroy().pipe(hook(failOn("C", "delete")));
5328
5329 expect((yield* getState("C"))?.status).toEqual("deleting");
5330 expect((yield* getState("B"))?.status).toEqual("created");
5331 expect((yield* getState("A"))?.status).toEqual("created");
5332
5333 // Recovery
5334 yield* stack.destroy();
5335 expect(yield* getState("A")).toBeUndefined();
5336 expectNotStarted(yield* getState("B"));
5337 expectNotStarted(yield* getState("C"));
5338 }),
5339 );
5340
5341 test.provider("partial orphan - remove leaf, add new dependent", (stack) =>
5342 Effect.gen(function* () {
5343 yield* Effect.gen(function* () {
5344 const A = yield* TestResource("A", { string: "a-value" });
5345 yield* TestResource("B", { string: A.string });
5346 }).pipe(stack.deploy);
5347 expect((yield* getState("A"))?.status).toEqual("created");
5348 expect((yield* getState("B"))?.status).toEqual("created");
5349
5350 // Remove B, add C dependent on A
5351 const output = yield* Effect.gen(function* () {
5352 const A = yield* TestResource("A", { string: "a-value" });
5353 const C = yield* TestResource("C", { string: A.string });
5354 return { A, C };
5355 }).pipe(stack.deploy);
5356 expect((yield* getState("A"))?.status).toEqual("created");
5357 expectNotStarted(yield* getState("B"));
5358 expect((yield* getState("C"))?.status).toEqual("created");
5359 expect(output.C.string).toEqual("a-value");
5360 }),
5361 );
5362});
5363
5364// =============================================================================
5365// COMPLEX MIXED STATE SCENARIOS
5366// =============================================================================
5367
5368describe("complex mixed state scenarios", { tags: ["unit", "local"] }, () => {
5369 test.provider("replace upstream while creating downstream", (stack) =>
5370 Effect.gen(function* () {
5371 // Create A
5372 yield* Effect.gen(function* () {
5373 yield* TestResource("A", {
5374 string: "a-value",
5375 replaceString: "original",
5376 });
5377 }).pipe(stack.deploy);
5378 expect((yield* getState("A"))?.status).toEqual("created");
5379
5380 // Now add B dependent on A, and also replace A
5381 const output = yield* Effect.gen(function* () {
5382 const A = yield* TestResource("A", {
5383 string: "a-value-new",
5384 replaceString: "changed",
5385 });
5386 const B = yield* TestResource("B", { string: A.string });
5387 return { A, B };
5388 }).pipe(stack.deploy);
5389
5390 expect((yield* getState("A"))?.status).toEqual("created");
5391 expect((yield* getState("B"))?.status).toEqual("created");
5392 expect(output.A.string).toEqual("a-value-new");
5393 expect(output.B.string).toEqual("a-value-new");
5394 }),
5395 );
5396
5397 test.provider("update upstream, create and delete in same apply", (stack) =>
5398 Effect.gen(function* () {
5399 // Create A and B
5400 yield* Effect.gen(function* () {
5401 yield* TestResource("A", { string: "a-value" });
5402 yield* TestResource("B", { string: "b-value" });
5403 }).pipe(stack.deploy);
5404
5405 // Update A, delete B (by not including), create C
5406 const output = yield* Effect.gen(function* () {
5407 const A = yield* TestResource("A", { string: "a-updated" });
5408 const C = yield* TestResource("C", { string: A.string });
5409 return { A, C };
5410 }).pipe(stack.deploy);
5411
5412 expect((yield* getState("A"))?.status).toEqual("updated");
5413 expectNotStarted(yield* getState("B"));
5414 expect((yield* getState("C"))?.status).toEqual("created");
5415 expect(output.C.string).toEqual("a-updated");
5416 }),
5417 );
5418
5419 test.provider(
5420 "chain reaction: A replace triggers B update triggers C update",
5421 (stack) =>
5422 Effect.gen(function* () {
5423 yield* Effect.gen(function* () {
5424 const A = yield* TestResource("A", {
5425 string: "a-value",
5426 replaceString: "original",
5427 });
5428 const B = yield* TestResource("B", { string: A.string });
5429 yield* TestResource("C", { string: B.string });
5430 }).pipe(stack.deploy);
5431
5432 // Replace A - should cascade updates to B and C
5433 const output = yield* Effect.gen(function* () {
5434 const A = yield* TestResource("A", {
5435 string: "a-replaced",
5436 replaceString: "changed",
5437 });
5438 const B = yield* TestResource("B", { string: A.string });
5439 const C = yield* TestResource("C", { string: B.string });
5440 return { A, B, C };
5441 }).pipe(stack.deploy);
5442
5443 expect((yield* getState("A"))?.status).toEqual("created");
5444 expect((yield* getState("B"))?.status).toEqual("updated");
5445 expect((yield* getState("C"))?.status).toEqual("updated");
5446 expect(output.C.string).toEqual("a-replaced");
5447 }),
5448 );
5449
5450 test.provider("multiple failures across all operation types", (stack) =>
5451 Effect.gen(function* () {
5452 // Setup: A, B created; C, D will be added
5453 yield* Effect.gen(function* () {
5454 yield* TestResource("A", {
5455 string: "a-value",
5456 replaceString: "original",
5457 });
5458 yield* TestResource("B", { string: "b-value" });
5459 }).pipe(stack.deploy);
5460
5461 // Complex operation: A replace, B update, C create, D not included (nothing to delete)
5462 const program = Effect.gen(function* () {
5463 const A = yield* TestResource("A", {
5464 string: "a-replaced",
5465 replaceString: "changed",
5466 });
5467 const B = yield* TestResource("B", { string: "b-updated" });
5468 const C = yield* TestResource("C", { string: "c-value" });
5469 return { A, B, C };
5470 });
5471
5472 // Fail on A replace (create phase) and C create
5473 yield* program.pipe(
5474 stack.deploy,
5475 hook(
5476 failOnMultiple([
5477 { id: "A", hook: "create" },
5478 { id: "C", hook: "create" },
5479 ]),
5480 ),
5481 );
5482
5483 // effect terminates eagerly, so it's possible that A or C runs first and blocks the other from running
5484 const AState = yield* getState<ReplacingResourceState>("A");
5485 // B might have been updated
5486 const bState = yield* getState("B");
5487 expect(bState?.status === "created" || bState?.status === "updated").toBe(
5488 true,
5489 );
5490 const CState = yield* getState("C");
5491 expect(AState?.status).toBeOneOf(["created", "replacing"]);
5492 expect(CState?.status).toBeOneOf(["creating", undefined]);
5493 // at least one of A or C should have started their failing operation
5494 expect(
5495 AState?.status === "replacing" || CState?.status === "creating",
5496 ).toBe(true);
5497
5498 // Recovery
5499 const output = yield* program.pipe(stack.deploy);
5500 expect((yield* getState("A"))?.status).toEqual("created");
5501 expect((yield* getState("B"))?.status).toEqual("updated");
5502 expect((yield* getState("C"))?.status).toEqual("created");
5503 expect(output.A.replaceString).toEqual("changed");
5504 expect(output.B.string).toEqual("b-updated");
5505 expect(output.C.string).toEqual("c-value");
5506 }),
5507 );
5508});
5509
5510describe("artifacts", { tags: ["unit", "local"] }, () => {
5511 test.provider("shares artifacts from plan diff into apply update", (stack) =>
5512 Effect.gen(function* () {
5513 yield* Effect.gen(function* () {
5514 yield* ArtifactProbe("A", { value: "v1" });
5515 }).pipe(stack.deploy);
5516
5517 const updated = yield* Effect.gen(function* () {
5518 const A = yield* ArtifactProbe("A", { value: "v2" });
5519 return { A };
5520 }).pipe(stack.deploy);
5521
5522 expect(updated.A.value).toEqual("v2");
5523 expect(updated.A.artifactValue).toEqual("v2");
5524 expect((yield* getState("A"))?.status).toEqual("updated");
5525 }),
5526 );
5527
5528 test.provider(
5529 "isolates artifact bags by FQN for namespaced resources with the same leaf logical ID",
5530 (stack) =>
5531 Effect.gen(function* () {
5532 const Site = (id: string, props: { value: string }) =>
5533 Effect.gen(function* () {
5534 return yield* ArtifactProbe("Shared", { value: props.value });
5535 }).pipe(Namespace.push(id));
5536
5537 yield* Effect.gen(function* () {
5538 yield* Site("Left", { value: "left-v1" });
5539 yield* Site("Right", { value: "right-v1" });
5540 }).pipe(stack.deploy);
5541
5542 const updated = yield* Effect.gen(function* () {
5543 const left = yield* Site("Left", { value: "left-v2" });
5544 const right = yield* Site("Right", { value: "right-v2" });
5545 return { left, right };
5546 }).pipe(stack.deploy);
5547
5548 expect(updated.left.artifactValue).toEqual("left-v2");
5549 expect(updated.right.artifactValue).toEqual("right-v2");
5550 expect((yield* getState("Left/Shared"))?.status).toEqual("updated");
5551 expect((yield* getState("Right/Shared"))?.status).toEqual("updated");
5552 }),
5553 );
5554});
5555
5556describe(
5557 "resource identity (fqn) threading",
5558 { tags: ["unit", "local"] },
5559 () => {
5560 test.provider(
5561 "threads the resource's fully-qualified name into handler inputs, distinct from the logical id",
5562 (stack) =>
5563 Effect.gen(function* () {
5564 // Deploy the probe under a namespace so its FQN ("Parent/leaf") is
5565 // NOT its bare logical id ("leaf"). The provider echoes both the `id`
5566 // and `fqn` it received back out as attributes.
5567 const { probe } = yield* Effect.gen(function* () {
5568 const probe = yield* Effect.gen(function* () {
5569 return yield* FqnProbe("leaf", {});
5570 }).pipe(Namespace.push("Parent"));
5571 return { probe };
5572 }).pipe(stack.deploy);
5573
5574 // The engine passes the leaf logical id as `id` and the full
5575 // namespace-qualified name as `fqn`.
5576 expect(probe.id).toEqual("leaf");
5577 expect(probe.fqn).toEqual("Parent/leaf");
5578 expect((yield* getState("Parent/leaf"))?.status).toEqual("created");
5579 }),
5580 );
5581 },
5582);
5583
5584// =============================================================================
5585// WHOLE-RESOURCE REFS RE-RESOLVE FRESH ATTRS AT APPLY
5586// The plan materializes a whole-resource reference to an *updating* upstream
5587// into its stable attributes for the downstream's `diff` — but the node's
5588// props keep the evaluable reference, so `reconcile` receives the upstream's
5589// fresh post-reconcile attributes, non-stable ones included. Baking the
5590// stables-only snapshot into node.props left e.g. a Lambda Alias pointing at
5591// the previous Lambda Version forever (#993's alias promotion bug).
5592// =============================================================================
5593
5594describe(
5595 "whole-resource refs re-resolve fresh attrs at apply",
5596 { tags: ["unit", "local"] },
5597 () => {
5598 test.provider(
5599 "downstream reconcile sees the upstream's fresh non-stable attributes",
5600 (stack) =>
5601 Effect.gen(function* () {
5602 const observed: TestResourceProps[] = [];
5603 const capture = hook({
5604 create: () => Effect.void,
5605 update: (id, props) =>
5606 Effect.sync(() => {
5607 if (id === "B") {
5608 observed.push(props);
5609 }
5610 }),
5611 delete: () => Effect.void,
5612 });
5613
5614 const program = (version: string) =>
5615 Effect.gen(function* () {
5616 const A = yield* TestResource("A", { string: version });
5617 // B references the WHOLE upstream resource, not a single prop.
5618 return yield* TestResource("B", { object: A as any });
5619 });
5620
5621 yield* program("v1").pipe(stack.deploy, capture);
5622
5623 // A updates in place: the non-stable `string` changes while
5624 // `stableString` / `stableArray` stay put. B must re-reconcile
5625 // against A's FRESH attributes — not the stables-only snapshot the
5626 // plan hands B's diff.
5627 yield* program("v2").pipe(stack.deploy, capture);
5628
5629 expect(observed).toHaveLength(1);
5630 const object = observed[0]!.object as any;
5631 expect(object.string).toBe("v2");
5632 expect(object.stableString).toBe("A");
5633
5634 // The persisted props captured the fully-resolved attrs, so the next
5635 // no-op deploy diffs full-against-full instead of churning.
5636 const persisted = yield* getState("B");
5637 expect((persisted?.props as any).object.string).toBe("v2");
5638
5639 yield* stack.destroy().pipe(capture);
5640 }),
5641 );
5642
5643 test.provider(
5644 "host reconcile sees the upstream's fresh non-stable attributes through a binding",
5645 (stack) =>
5646 Effect.gen(function* () {
5647 // Captures the DIFF-facing binding rows the host provider observes
5648 // at plan time (materialized stables-only snapshots).
5649 const diffObserved: any[] = [];
5650 const capture = <A, Err, Req>(test: Effect.Effect<A, Err, Req>) =>
5651 test.pipe(
5652 Effect.provide(
5653 Layer.succeed(TestResourceHooks, {
5654 diff: (id, newBindings) =>
5655 Effect.sync(() => {
5656 if (id === "Host") {
5657 diffObserved.push(newBindings);
5658 }
5659 }),
5660 }),
5661 ),
5662 );
5663
5664 const program = (version: string) =>
5665 Effect.gen(function* () {
5666 const A = yield* TestResource("A", { string: version });
5667 const host = yield* BindingTarget("Host", { name: "host" });
5668 // The binding data embeds the WHOLE upstream resource.
5669 yield* host.bind("FromA", { env: { A } } as any);
5670 return host;
5671 });
5672
5673 yield* program("v1").pipe(stack.deploy, capture);
5674 const created = yield* getState("Host");
5675 expect((created?.bindings as any)[0].data.env.A.string).toBe("v1");
5676
5677 // A updates in place: the host's `diff` compares against the
5678 // materialized stables-only snapshot, but the binding payload the
5679 // host's `reconcile` receives must re-resolve to A's FRESH
5680 // post-reconcile attributes at apply.
5681 yield* program("v2").pipe(stack.deploy, capture);
5682
5683 // The plan-time diff saw the stables-only materialization.
5684 const lastDiff = diffObserved.at(-1);
5685 expect(lastDiff[0].data.env.A).toEqual({
5686 stableString: "A",
5687 stableArray: ["A"],
5688 });
5689
5690 // The reconciled attr merged the fresh payload...
5691 const updated = yield* getState("Host");
5692 expect((updated?.attr as any).env.A.string).toBe("v2");
5693 // ...and the terminal commit persisted the RESOLVED payload the
5694 // provider reconciled with (#874).
5695 const bound = (updated?.bindings as any)[0].data.env.A;
5696 expect(bound.string).toBe("v2");
5697 expect(bound.stableString).toBe("A");
5698
5699 // With full attrs persisted, the next plan diffs full-against-full
5700 // instead of churning on the stables-only snapshot.
5701 const rePlan = yield* program("v2").pipe(stack.plan, capture);
5702 expect((rePlan.resources as any).Host.action).toBe("noop");
5703
5704 yield* stack.destroy().pipe(capture);
5705 }),
5706 );
5707 },
5708);
5709
5710// =============================================================================
5711// STATIC STABLE PROPERTIES (provider.stables defined on provider, not in diff)
5712// This tests the bug where diff returns undefined but downstream resources
5713// depend on stable properties that should be preserved
5714// =============================================================================
5715
5716describe(
5717 "static stable properties (provider.stables)",
5718 { tags: ["unit", "local"] },
5719 () => {
5720 describe("diff returns undefined with tag-only changes", () => {
5721 test.provider(
5722 "upstream has static stables, diff returns undefined, downstream depends on stableId",
5723 (stack) =>
5724 Effect.gen(function* () {
5725 // Stage 1: Create A with no tags, B depends on A.stableId
5726 {
5727 const output = yield* Effect.gen(function* () {
5728 const A = yield* StaticStablesResource("A", {
5729 string: "value",
5730 });
5731 const B = yield* TestResource("B", { string: A.stableId });
5732 return { A, B };
5733 }).pipe(stack.deploy);
5734 expect(output.A.stableId).toEqual("stable-A");
5735 expect(output.B.string).toEqual("stable-A");
5736 expect((yield* getState("A"))?.status).toEqual("created");
5737 expect((yield* getState("B"))?.status).toEqual("created");
5738 }
5739
5740 // Stage 2: Add tags to A - diff returns undefined, but arePropsChanged is true
5741 // B depends on A.stableId which should remain stable
5742 {
5743 const output = yield* Effect.gen(function* () {
5744 const A = yield* StaticStablesResource("A", {
5745 string: "value",
5746 tags: { Name: "tagged-resource" },
5747 });
5748 const B = yield* TestResource("B", { string: A.stableId });
5749 return { A, B };
5750 }).pipe(stack.deploy);
5751 // A should be updated (tags changed)
5752 expect(output.A.tags).toEqual({ Name: "tagged-resource" });
5753 // B should NOT be updated because stableId didn't change
5754 expect(output.B.string).toEqual("stable-A");
5755 expect((yield* getState("A"))?.status).toEqual("updated");
5756 // B should remain "created" (noop) since its input (stableId) didn't change
5757 expect((yield* getState("B"))?.status).toEqual("created");
5758 }
5759 }),
5760 );
5761
5762 test.provider(
5763 "chain: A -> B -> C where B depends on A.stableId and C depends on B.stableString",
5764 (stack) =>
5765 Effect.gen(function* () {
5766 // Stage 1: Create chain
5767 {
5768 const output = yield* Effect.gen(function* () {
5769 const A = yield* StaticStablesResource("A", {
5770 string: "initial",
5771 });
5772 const B = yield* TestResource("B", { string: A.stableId });
5773 const C = yield* TestResource("C", { string: B.stableString });
5774 return { A, B, C };
5775 }).pipe(stack.deploy);
5776 expect(output.A.stableId).toEqual("stable-A");
5777 expect(output.B.string).toEqual("stable-A");
5778 expect(output.C.string).toEqual("B");
5779 }
5780
5781 // Stage 2: Change A's tags only - diff returns undefined
5782 // Neither B nor C should update since their inputs are stable
5783 {
5784 const output = yield* Effect.gen(function* () {
5785 const A = yield* StaticStablesResource("A", {
5786 string: "initial",
5787 tags: { Env: "production" },
5788 });
5789 const B = yield* TestResource("B", { string: A.stableId });
5790 const C = yield* TestResource("C", { string: B.stableString });
5791 return { A, B, C };
5792 }).pipe(stack.deploy);
5793 expect(output.A.tags).toEqual({ Env: "production" });
5794 expect((yield* getState("A"))?.status).toEqual("updated");
5795 // B and C should not change
5796 expect((yield* getState("B"))?.status).toEqual("created");
5797 expect((yield* getState("C"))?.status).toEqual("created");
5798 }
5799 }),
5800 );
5801
5802 test.provider(
5803 "diamond: A -> B,C -> D where all depend on stable properties",
5804 (stack) =>
5805 Effect.gen(function* () {
5806 // Stage 1: Create diamond
5807 {
5808 const output = yield* Effect.gen(function* () {
5809 const A = yield* StaticStablesResource("A", {
5810 string: "initial",
5811 });
5812 const B = yield* TestResource("B", { string: A.stableId });
5813 const C = yield* TestResource("C", { string: A.stableArn });
5814 const D = yield* TestResource("D", {
5815 string: Output.interpolate`${B.stableString}-${C.stableString}`,
5816 });
5817 return { A, B, C, D };
5818 }).pipe(stack.deploy);
5819 expect(output.A.stableId).toEqual("stable-A");
5820 expect(output.A.stableArn).toEqual(
5821 "arn:test:resource:us-east-1:123456789:A",
5822 );
5823 expect(output.B.string).toEqual("stable-A");
5824 expect(output.C.string).toEqual(
5825 "arn:test:resource:us-east-1:123456789:A",
5826 );
5827 expect(output.D.string).toEqual("B-C");
5828 }
5829
5830 // Stage 2: Change A's tags - should not affect B, C, or D
5831 {
5832 yield* Effect.gen(function* () {
5833 const A = yield* StaticStablesResource("A", {
5834 string: "initial",
5835 tags: { Team: "platform" },
5836 });
5837 const B = yield* TestResource("B", { string: A.stableId });
5838 const C = yield* TestResource("C", { string: A.stableArn });
5839 yield* TestResource("D", {
5840 string: Output.interpolate`${B.stableString}-${C.stableString}`,
5841 });
5842 }).pipe(stack.deploy);
5843 expect((yield* getState("A"))?.status).toEqual("updated");
5844 expect((yield* getState("B"))?.status).toEqual("created");
5845 expect((yield* getState("C"))?.status).toEqual("created");
5846 expect((yield* getState("D"))?.status).toEqual("created");
5847 }
5848 }),
5849 );
5850 });
5851
5852 describe("diff returns update action with static stables", () => {
5853 test.provider(
5854 "upstream has static stables and diff returns update, downstream depends on stableId",
5855 (stack) =>
5856 Effect.gen(function* () {
5857 // Stage 1: Create A and B
5858 {
5859 const output = yield* Effect.gen(function* () {
5860 const A = yield* StaticStablesResource("A", {
5861 string: "value-1",
5862 });
5863 const B = yield* TestResource("B", { string: A.stableId });
5864 return { A, B };
5865 }).pipe(stack.deploy);
5866 expect(output.A.stableId).toEqual("stable-A");
5867 expect(output.B.string).toEqual("stable-A");
5868 }
5869
5870 // Stage 2: Change A's string - diff returns "update", stableId still stable
5871 {
5872 const output = yield* Effect.gen(function* () {
5873 const A = yield* StaticStablesResource("A", {
5874 string: "value-2",
5875 });
5876 const B = yield* TestResource("B", { string: A.stableId });
5877 return { A, B };
5878 }).pipe(stack.deploy);
5879 expect(output.A.string).toEqual("value-2");
5880 expect(output.A.stableId).toEqual("stable-A");
5881 expect((yield* getState("A"))?.status).toEqual("updated");
5882 // B should not change since stableId is stable
5883 expect((yield* getState("B"))?.status).toEqual("created");
5884 }
5885 }),
5886 );
5887
5888 test.provider(
5889 "downstream depends on non-stable property, should update",
5890 (stack) =>
5891 Effect.gen(function* () {
5892 // Stage 1: Create A and B where B depends on A.string (non-stable)
5893 {
5894 const output = yield* Effect.gen(function* () {
5895 const A = yield* StaticStablesResource("A", {
5896 string: "value-1",
5897 });
5898 const B = yield* TestResource("B", { string: A.string });
5899 return { A, B };
5900 }).pipe(stack.deploy);
5901 expect(output.A.string).toEqual("value-1");
5902 expect(output.B.string).toEqual("value-1");
5903 }
5904
5905 // Stage 2: Change A's string - B should update
5906 {
5907 const output = yield* Effect.gen(function* () {
5908 const A = yield* StaticStablesResource("A", {
5909 string: "value-2",
5910 });
5911 const B = yield* TestResource("B", { string: A.string });
5912 return { A, B };
5913 }).pipe(stack.deploy);
5914 expect(output.A.string).toEqual("value-2");
5915 expect(output.B.string).toEqual("value-2");
5916 expect((yield* getState("A"))?.status).toEqual("updated");
5917 expect((yield* getState("B"))?.status).toEqual("updated");
5918 }
5919 }),
5920 );
5921 });
5922
5923 describe("replace action with static stables", () => {
5924 test.provider(
5925 "upstream replaces, downstream depends on stableId - should update with new value",
5926 (stack) =>
5927 Effect.gen(function* () {
5928 // Stage 1: Create A and B
5929 {
5930 const output = yield* Effect.gen(function* () {
5931 const A = yield* StaticStablesResource("A", {
5932 string: "value",
5933 replaceString: "original",
5934 });
5935 const B = yield* TestResource("B", { string: A.stableId });
5936 return { A, B };
5937 }).pipe(stack.deploy);
5938 expect(output.A.stableId).toEqual("stable-A");
5939 expect(output.B.string).toEqual("stable-A");
5940 }
5941
5942 // Stage 2: Replace A - stableId will change (new resource)
5943 {
5944 const output = yield* Effect.gen(function* () {
5945 const A = yield* StaticStablesResource("A", {
5946 string: "value",
5947 replaceString: "changed",
5948 });
5949 const B = yield* TestResource("B", { string: A.stableId });
5950 return { A, B };
5951 }).pipe(stack.deploy);
5952 // A was replaced, stableId is regenerated
5953 expect(output.A.stableId).toEqual("stable-A");
5954 expect(output.B.string).toEqual("stable-A");
5955 expect((yield* getState("A"))?.status).toEqual("created");
5956 expect((yield* getState("B"))?.status).toEqual("updated");
5957 }
5958 }),
5959 );
5960 });
5961 },
5962);
5963
5964describe(
5965 "Redacted props/outputs survive deploy",
5966 { tags: ["unit", "local"] },
5967 () => {
5968 test.provider(
5969 "preserves a Redacted prop end-to-end through create",
5970 (stack) =>
5971 Effect.gen(function* () {
5972 const secret = Redacted.make("hunter2");
5973 const created = yield* Effect.gen(function* () {
5974 return yield* TestResource("A", {
5975 string: "x",
5976 redacted: secret,
5977 });
5978 }).pipe(stack.deploy);
5979
5980 expect(Redacted.isRedacted(created.redacted)).toBe(true);
5981 expect(Redacted.value(created.redacted!)).toBe("hunter2");
5982
5983 const state = yield* getState("A");
5984 expect(state).toBeDefined();
5985 expect(Redacted.isRedacted((state!.props as any).redacted)).toBe(
5986 true,
5987 );
5988 expect(Redacted.value((state!.props as any).redacted)).toBe(
5989 "hunter2",
5990 );
5991 expect(Redacted.isRedacted((state!.attr as any).redacted)).toBe(true);
5992 expect(Redacted.value((state!.attr as any).redacted)).toBe("hunter2");
5993 }),
5994 );
5995
5996 test.provider(
5997 "preserves Redacted values nested inside an array end-to-end",
5998 (stack) =>
5999 Effect.gen(function* () {
6000 const created = yield* Effect.gen(function* () {
6001 return yield* TestResource("A", {
6002 string: "x",
6003 redactedArray: [Redacted.make("a"), Redacted.make("b")],
6004 });
6005 }).pipe(stack.deploy);
6006
6007 expect(created.redactedArray).toBeDefined();
6008 expect(created.redactedArray!.length).toBe(2);
6009 expect(Redacted.isRedacted(created.redactedArray![0]!)).toBe(true);
6010 expect(Redacted.value(created.redactedArray![0]!)).toBe("a");
6011 expect(Redacted.isRedacted(created.redactedArray![1]!)).toBe(true);
6012 expect(Redacted.value(created.redactedArray![1]!)).toBe("b");
6013 }),
6014 );
6015
6016 test.provider(
6017 "preserves a Redacted output flowing into a downstream resource prop",
6018 (stack) =>
6019 Effect.gen(function* () {
6020 const output = yield* Effect.gen(function* () {
6021 const A = yield* TestResource("A", {
6022 string: "x",
6023 redacted: Redacted.make("hunter2"),
6024 });
6025 const B = yield* TestResource("B", {
6026 string: "y",
6027 redacted: A.redacted as any,
6028 });
6029 return { A, B };
6030 }).pipe(stack.deploy);
6031
6032 expect(Redacted.isRedacted(output.B.redacted)).toBe(true);
6033 expect(Redacted.value(output.B.redacted!)).toBe("hunter2");
6034
6035 const bState = yield* getState("B");
6036 expect(Redacted.isRedacted((bState!.props as any).redacted)).toBe(
6037 true,
6038 );
6039 expect(Redacted.value((bState!.props as any).redacted)).toBe(
6040 "hunter2",
6041 );
6042 expect(Redacted.isRedacted((bState!.attr as any).redacted)).toBe(
6043 true,
6044 );
6045 expect(Redacted.value((bState!.attr as any).redacted)).toBe(
6046 "hunter2",
6047 );
6048 }),
6049 );
6050
6051 test.provider(
6052 "no-op redeploy when only Redacted prop is present and value unchanged",
6053 (stack) =>
6054 Effect.gen(function* () {
6055 const first = yield* Effect.gen(function* () {
6056 return yield* TestResource("A", {
6057 string: "x",
6058 redacted: Redacted.make("hunter2"),
6059 });
6060 }).pipe(stack.deploy);
6061 expect(Redacted.value(first.redacted!)).toBe("hunter2");
6062
6063 const before = yield* getState("A");
6064
6065 yield* Effect.gen(function* () {
6066 return yield* TestResource("A", {
6067 string: "x",
6068 redacted: Redacted.make("hunter2"),
6069 });
6070 }).pipe(stack.deploy);
6071
6072 const after = yield* getState("A");
6073 expect(after?.status).toBe("created");
6074 expect((before as any).updatedAt ?? null).toEqual(
6075 (after as any).updatedAt ?? null,
6076 );
6077 expect(Redacted.value((after!.attr as any).redacted)).toBe("hunter2");
6078 }),
6079 );
6080
6081 test.provider("update redeploy when Redacted prop value changes", (stack) =>
6082 Effect.gen(function* () {
6083 yield* Effect.gen(function* () {
6084 return yield* TestResource("A", {
6085 string: "x",
6086 redacted: Redacted.make("old"),
6087 });
6088 }).pipe(stack.deploy);
6089
6090 const updated = yield* Effect.gen(function* () {
6091 return yield* TestResource("A", {
6092 string: "x",
6093 redacted: Redacted.make("new"),
6094 });
6095 }).pipe(stack.deploy);
6096
6097 expect(Redacted.isRedacted(updated.redacted)).toBe(true);
6098 expect(Redacted.value(updated.redacted!)).toBe("new");
6099 const state = yield* getState("A");
6100 expect(state?.status).toBe("updated");
6101 expect(Redacted.value((state!.attr as any).redacted)).toBe("new");
6102 }),
6103 );
6104 },
6105);
6106
6107describe("stack output persistence", { tags: ["unit", "local"] }, () => {
6108 const getStackOutput = (stack: string, stage: string) =>
6109 Effect.gen(function* () {
6110 const state = yield* yield* State;
6111 return yield* state.getOutput({ stack, stage });
6112 });
6113
6114 test.provider(
6115 "apply persists the resolved stack output via state.setOutput",
6116 (stack) =>
6117 Effect.gen(function* () {
6118 const result = yield* Effect.gen(function* () {
6119 const A = yield* TestResource("A", { string: "hello" });
6120 return { url: A.string };
6121 }).pipe(stack.deploy);
6122 expect(result).toEqual({ url: "hello" });
6123
6124 const persisted = yield* getStackOutput(stack.name, stack.stage).pipe(
6125 Effect.provide(stack.state),
6126 );
6127 expect(persisted).toEqual({ url: "hello" });
6128 }),
6129 );
6130
6131 test.provider(
6132 "redeploys overwrite the persisted stack output with the new value",
6133 (stack) =>
6134 Effect.gen(function* () {
6135 yield* Effect.gen(function* () {
6136 const A = yield* TestResource("A", { string: "v1" });
6137 return { url: A.string };
6138 }).pipe(stack.deploy);
6139
6140 yield* Effect.gen(function* () {
6141 const A = yield* TestResource("A", { string: "v2" });
6142 return { url: A.string };
6143 }).pipe(stack.deploy);
6144
6145 const persisted = yield* getStackOutput(stack.name, stack.stage).pipe(
6146 Effect.provide(stack.state),
6147 );
6148 expect(persisted).toEqual({ url: "v2" });
6149 }),
6150 );
6151
6152 test.provider(
6153 "another stack can read the persisted output via Output.stackRef",
6154 (stack) =>
6155 Effect.gen(function* () {
6156 // First deploy: write the stack output we'll later reference.
6157 yield* Effect.gen(function* () {
6158 const A = yield* TestResource("A", { string: "shared" });
6159 return { url: A.string };
6160 }).pipe(stack.deploy);
6161
6162 // Second deploy: a downstream resource consumes the previously
6163 // persisted stack output via Output.stackRef. The deploy
6164 // succeeds because state.getOutput finds it.
6165 const result = yield* Effect.gen(function* () {
6166 const upstream = yield* Output.stackRef<{ url: string }>(stack.name);
6167 const B = yield* TestResource("B", {
6168 string: (upstream as any).url,
6169 });
6170 return { downstream: B.string };
6171 }).pipe(stack.deploy);
6172
6173 expect(result).toEqual({ downstream: "shared" });
6174 }),
6175 );
6176});
6177
6178describe(
6179 "Duration round-trip through state",
6180 { tags: ["unit", "local"] },
6181 () => {
6182 test.provider(
6183 "input Duration reaches reconcile as a real Duration and output Duration re-hydrates as a real Duration on the next deploy",
6184 (stack) =>
6185 Effect.gen(function* () {
6186 const first = yield* stack.deploy(
6187 DurationResource("Timer", { timeout: Duration.seconds(15) }),
6188 );
6189
6190 // Reconcile saw a real Duration: arithmetic worked.
6191 expect(Duration.isDuration(first.observedTimeout)).toBe(true);
6192 expect(Duration.toMillis(first.observedTimeout)).toBe(15_000);
6193 expect(Duration.isDuration(first.computedTimeout)).toBe(true);
6194 expect(Duration.toMillis(first.computedTimeout)).toBe(16_000);
6195
6196 // Second deploy: identical props. The engine reads the previous
6197 // output from state. If the Duration weren't revived, `output`
6198 // (a plain `{_id,_tag,millis}` shape) would fail `isDuration` and
6199 // `Duration.toMillis` would throw.
6200 const second = yield* stack.deploy(
6201 DurationResource("Timer", { timeout: Duration.seconds(15) }),
6202 );
6203 expect(Duration.isDuration(second.observedTimeout)).toBe(true);
6204 expect(Duration.toMillis(second.observedTimeout)).toBe(15_000);
6205 expect(Duration.isDuration(second.computedTimeout)).toBe(true);
6206 expect(Duration.toMillis(second.computedTimeout)).toBe(16_000);
6207
6208 // The persisted state itself should round-trip to a real Duration.
6209 const persisted = yield* getState<{
6210 attr: DurationResource["Attributes"];
6211 }>("Timer");
6212 expect(Duration.isDuration(persisted.attr.computedTimeout)).toBe(
6213 true,
6214 );
6215 expect(Duration.toMillis(persisted.attr.computedTimeout)).toBe(
6216 16_000,
6217 );
6218 }),
6219 );
6220 },
6221);
6222
6223describe("type aliases", { tags: ["unit", "local"] }, () => {
6224 // Simulate state written before a type rename: rewrite the persisted row's
6225 // resourceType to the legacy name ("Test.Widget") that the canonical type
6226 // ("Test.Widgets.Widget") carries as an alias.
6227 const rewriteTypeToLegacy = Effect.fn(function* (fqn: string) {
6228 const state = yield* yield* State;
6229 const stk = yield* Stack;
6230 const row = (yield* state.get({
6231 stack: stk.name,
6232 stage: stk.stage,
6233 fqn,
6234 })) as ResourceState;
6235 expect(row.resourceType).toEqual("Test.Widgets.Widget");
6236 yield* state.set({
6237 stack: stk.name,
6238 stage: stk.stage,
6239 fqn,
6240 value: { ...row, resourceType: "Test.Widget" },
6241 });
6242 });
6243
6244 describe("bare provider layer", () => {
6245 const { test } = Test.make({
6246 providers: Layer.mergeAll(TestLayers(), aliasedWidgetProvider()),
6247 });
6248
6249 test.provider(
6250 "a noop deploy migrates legacy-typed state to the canonical type",
6251 (stack) =>
6252 Effect.gen(function* () {
6253 yield* Effect.gen(function* () {
6254 yield* AliasedWidget("W1", { name: "w1" });
6255 }).pipe(stack.deploy);
6256
6257 yield* rewriteTypeToLegacy("W1");
6258
6259 // Unchanged props plan as a noop — Apply must still rewrite the
6260 // state row to the canonical type name.
6261 yield* Effect.gen(function* () {
6262 yield* AliasedWidget("W1", { name: "w1" });
6263 }).pipe(stack.deploy);
6264
6265 const row = yield* getState("W1");
6266 expect(row.resourceType).toEqual("Test.Widgets.Widget");
6267 expect(row.status).toEqual("created");
6268 expect(row.attr).toEqual({ name: "w1" });
6269 }),
6270 );
6271
6272 test.provider(
6273 "orphan persisted under a legacy type name is deleted via alias",
6274 (stack) =>
6275 Effect.gen(function* () {
6276 yield* Effect.gen(function* () {
6277 yield* AliasedWidget("W2", { name: "w2" });
6278 }).pipe(stack.deploy);
6279
6280 yield* rewriteTypeToLegacy("W2");
6281
6282 // Remove the resource from the stack — the orphan-deletion path
6283 // resolves the provider from the legacy type via its alias.
6284 yield* Effect.void.pipe(stack.deploy);
6285
6286 expect(aliasedWidgetDeletes).toContain("W2");
6287 expect(yield* getState("W2")).toBeUndefined();
6288 }),
6289 );
6290
6291 test.provider(
6292 "destroy resolves the provider for legacy-typed state via alias",
6293 (stack) =>
6294 Effect.gen(function* () {
6295 yield* Effect.gen(function* () {
6296 yield* AliasedWidget("W3", { name: "w3" });
6297 }).pipe(stack.deploy);
6298
6299 yield* rewriteTypeToLegacy("W3");
6300
6301 yield* stack.destroy();
6302
6303 expect(aliasedWidgetDeletes).toContain("W3");
6304 expect(yield* getState("W3")).toBeUndefined();
6305 }),
6306 );
6307 });
6308
6309 describe("provider collection", () => {
6310 class AliasApplyProviders extends Provider.ProviderCollection<AliasApplyProviders>()(
6311 "Test.AliasApplyProviders",
6312 ) {}
6313
6314 // The bare provider layer is consumed while building the collection and
6315 // is NOT exported — lookup can only succeed through the collection.
6316 const { test } = Test.make({
6317 providers: Layer.effect(
6318 AliasApplyProviders,
6319 Provider.collection([AliasedWidget]),
6320 ).pipe(Layer.provide(aliasedWidgetProvider())),
6321 });
6322
6323 test.provider(
6324 "orphan persisted under a legacy type name is deleted via alias",
6325 (stack) =>
6326 Effect.gen(function* () {
6327 yield* Effect.gen(function* () {
6328 yield* AliasedWidget("W4", { name: "w4" });
6329 }).pipe(stack.deploy);
6330
6331 yield* rewriteTypeToLegacy("W4");
6332
6333 yield* Effect.void.pipe(stack.deploy);
6334
6335 expect(aliasedWidgetDeletes).toContain("W4");
6336 expect(yield* getState("W4")).toBeUndefined();
6337 }),
6338 );
6339 });
6340});
6341
6342// Regression coverage for
6343// https://github.com/alchemy-run/alchemy/issues/793
6344//
6345// `Plan.make` used to `state.set(...)` the adopted `created` state during plan
6346// construction. Because `alchemy plan` / `deploy --dry-run` build a plan the
6347// exact same way a real deploy does, a read-only preview silently claimed
6348// ownership of an unowned cloud resource — arming a later, unrelated deploy to
6349// orphan-delete it. Plan construction must be side-effect-free: reading the
6350// cloud resource is fine (needed for an accurate diff), but persisting the
6351// adopted state may only happen when the plan node is applied.
6352describe(
6353 "engine-level adoption persists at apply, not plan (issue #793)",
6354 { tags: ["unit", "local"] },
6355 () => {
6356 // A pre-existing, foreign-owned cloud resource that `read` always discovers
6357 // — the exact shape that triggers an `--adopt` takeover.
6358 const ownedAttrs: TestResource["Attributes"] = {
6359 string: "hello",
6360 stringArray: [],
6361 stableString: "Adopted",
6362 stableArray: ["Adopted"],
6363 replaceString: undefined,
6364 redacted: undefined,
6365 redactedArray: undefined,
6366 };
6367
6368 test.provider(
6369 "a dry-run plan writes nothing to the state store; applying persists",
6370 (stack) =>
6371 Effect.gen(function* () {
6372 const events: Array<{ id: string; status: string }> = [];
6373 let creates = 0;
6374 let updates = 0;
6375 const hooks = Layer.succeed(TestResourceHooks, {
6376 read: () => Effect.succeed(Unowned(ownedAttrs)),
6377 create: () => Effect.sync(() => creates++),
6378 update: () => Effect.sync(() => updates++),
6379 });
6380 // ── dry-run: build a plan that adopts the unowned cloud resource ──
6381 const plan = yield* TestResource("Adopted", { string: "hello" }).pipe(
6382 adopt(true),
6383 stack.plan,
6384 Effect.provide(hooks),
6385 );
6386
6387 // The adopted state rides on an explicit plan node (which still
6388 // provider re-syncs ownership tags / config) — it is not persisted.
6389 expect(plan.resources.Adopted!.action).toBe("adopted");
6390 expect(plan.resources.Adopted!.state?.status).toBe("created");
6391
6392 // The critical invariant of #793: planning persisted nothing, so a
6393 // read-only `alchemy plan` / `--dry-run` cannot arm a later deploy to
6394 // orphan-delete the live resource.
6395 expect(yield* getState("Adopted")).toBeUndefined();
6396 expect(yield* listState()).toEqual([]);
6397
6398 // ── apply: the same config now deploys. Because plan didn't persist,
6399 // the resource is still adoptable here. ──
6400 yield* TestResource("Adopted", { string: "hello" }).pipe(
6401 adopt(true),
6402 stack.deploy,
6403 Effect.provide(hooks),
6404 Effect.provide(Layer.succeed(Cli, recordingCli(events))),
6405 );
6406
6407 // Applying DOES persist the adopted state.
6408 const persisted = yield* getState("Adopted");
6409 expect(["created", "updated"]).toContain(persisted?.status);
6410 expect(yield* listState()).toEqual(["Adopted"]);
6411 // Adoption is the reconciler's `output defined, olds undefined` path.
6412 expect(creates).toBe(1);
6413 expect(updates).toBe(0);
6414 const statuses = events
6415 .filter((event) => event.id === "Adopted")
6416 .map((event) => event.status);
6417 expect(statuses).toContain("adopting");
6418 expect(statuses).toContain("adopted");
6419 expect(statuses.indexOf("adopting")).toBeLessThan(
6420 statuses.indexOf("adopted"),
6421 );
6422 }),
6423 );
6424 },
6425);
6426
6427describe("deferred adoption", { tags: ["unit", "local"] }, () => {
6428 interface Singleton extends Resource<
6429 "Test.DeferredSingleton",
6430 { parent?: string; value?: string },
6431 { identity: string; value: string }
6432 > {}
6433 const Singleton = Resource<Singleton>("Test.DeferredSingleton");
6434 class Probe extends Context.Service<
6435 Probe,
6436 {
6437 ready: boolean;
6438 foreign: boolean;
6439 absent: boolean;
6440 fail: boolean;
6441 reads: string[];
6442 reconciles: Array<{
6443 id: string;
6444 olds: Singleton["Props"] | undefined;
6445 output: Singleton["Attributes"] | undefined;
6446 }>;
6447 deletes: string[];
6448 }
6449 >()("DeferredAdoptionProbe") {}
6450 const providers = Provider.succeed(Singleton, {
6451 read: Effect.fn(function* ({ id, olds, output }) {
6452 if (id === "Parent") return output;
6453 const probe = yield* Probe;
6454 if (!olds.parent) return undefined;
6455 expect(probe.ready).toBe(true);
6456 expect(olds.parent).toBe("child-branch");
6457 probe.reads.push(id);
6458 if (output) return output;
6459 if (probe.absent) return undefined;
6460 const attrs = { identity: olds.parent, value: "inherited" };
6461 return probe.foreign ? Unowned(attrs) : attrs;
6462 }),
6463 reconcile: Effect.fn(function* ({ id, news, olds, output }) {
6464 const probe = yield* Probe;
6465 if (id === "Parent") {
6466 probe.ready = true;
6467 return { identity: "child-branch", value: "parent" };
6468 }
6469 probe.reconciles.push({ id, olds, output });
6470 if (!output && !probe.absent)
6471 return yield* new OwnedBySomeoneElse({
6472 message: "Singleton requires engine adoption",
6473 });
6474 expect(Unowned.is(output)).toBe(false);
6475 if (probe.fail) return yield* new ResourceFailure();
6476 return { identity: news.parent!, value: news.value ?? "desired" };
6477 }),
6478 delete: Effect.fn(function* ({ id }) {
6479 (yield* Probe).deletes.push(id);
6480 }),
6481 });
6482 interface Stub extends Resource<
6483 "Test.DeferredStub",
6484 Singleton["Props"],
6485 Singleton["Attributes"]
6486 > {}
6487 const Stub = Resource<Stub>("Test.DeferredStub");
6488 const stubProvider = Provider.succeed(Stub, {
6489 read: Effect.fn(function* ({ id }) {
6490 (yield* Probe).reads.push(id);
6491 return Unowned({ identity: "stub", value: "stub" });
6492 }),
6493 precreate: () => Effect.succeed({ identity: "stub", value: "stub" }),
6494 reconcile: Effect.fn(function* ({ id, news, olds, output }) {
6495 const probe = yield* Probe;
6496 probe.reconciles.push({ id, olds, output });
6497 expect(output).toEqual({ identity: "stub", value: "stub" });
6498 if (probe.fail) return yield* new ResourceFailure();
6499 return { identity: news.parent!, value: "ready" };
6500 }),
6501 delete: Effect.fn(function* ({ id }) {
6502 (yield* Probe).deletes.push(id);
6503 }),
6504 });
6505 const makeProbe = (): Probe["Service"] => ({
6506 ready: false,
6507 foreign: true,
6508 absent: false,
6509 fail: false,
6510 reads: [],
6511 reconciles: [],
6512 deletes: [],
6513 });
6514 const { test } = Test.make({
6515 providers: Layer.mergeAll(providers, stubProvider).pipe(
6516 Layer.provideMerge(
6517 Layer.effect(
6518 Probe,
6519 // Reuse the test's probe across successive stack layer builds.
6520 Effect.serviceOption(Probe).pipe(
6521 Effect.map(Option.getOrElse(makeProbe)),
6522 ),
6523 ),
6524 ),
6525 ),
6526 });
6527 const program = (enabled?: boolean, sibling = false) =>
6528 Effect.gen(function* () {
6529 const parent = yield* Singleton("Parent", {});
6530 const child = Singleton("Child", { parent: parent.identity });
6531 const result = yield* enabled === undefined
6532 ? child
6533 : child.pipe(adopt(enabled));
6534 if (sibling) yield* Singleton("Sibling", { parent: parent.identity });
6535 return result;
6536 });
6537
6538 for (const kind of ["scoped", "default", "owned", "absent"] as const) {
6539 test.provider(
6540 `accepts ${kind} after resolving a new upstream without plan writes`,
6541 (stack) => {
6542 return Effect.gen(function* () {
6543 const probe = yield* Probe;
6544 probe.foreign = kind !== "owned";
6545 probe.absent = kind === "absent";
6546 yield* stack.destroy();
6547 const app = program(kind === "scoped" ? true : undefined);
6548 const plan = yield* stack.plan(app);
6549 expect(plan.resources.Child?.action).toBe("create");
6550 expect(probe.reads).toEqual([]);
6551 expect(yield* getState("Child")).toBeUndefined();
6552 const result = yield* stack.deploy(app);
6553 expect(result.identity).toBe("child-branch");
6554 expect(probe.reads).toEqual(["Child"]);
6555 expect(probe.reconciles[0]?.olds).toBeUndefined();
6556 expect(probe.reconciles[0]?.output).toEqual(
6557 kind === "absent"
6558 ? undefined
6559 : { identity: "child-branch", value: "inherited" },
6560 );
6561 expect(Unowned.is((yield* getState("Child")).attr)).toBe(false);
6562 yield* stack.destroy();
6563 }).pipe(Effect.provideService(AdoptPolicy, kind === "default"));
6564 },
6565 );
6566 }
6567
6568 for (const enabled of [undefined, false]) {
6569 test.provider(
6570 `refuses unowned resources before reconcile with scoped policy ${enabled}`,
6571 (stack) => {
6572 return Effect.gen(function* () {
6573 const probe = yield* Probe;
6574 yield* stack.destroy();
6575 const refused = yield* stack.deploy(program(enabled)).pipe(
6576 Effect.as(false),
6577 Effect.catchTag("OwnedBySomeoneElse", () => Effect.succeed(true)),
6578 );
6579 expect(refused).toBe(true);
6580 expect(probe.reads).toEqual(["Child"]);
6581 expect(probe.reconciles).toEqual([]);
6582 expect((yield* getState("Child")).attr).toBeUndefined();
6583 yield* stack.destroy();
6584 expect(probe.deletes).not.toContain("Child");
6585 }).pipe(Effect.provideService(AdoptPolicy, enabled === false));
6586 },
6587 );
6588 }
6589
6590 test.provider(
6591 "never probes a precreated stub, including interrupted reconciliation",
6592 (stack) => {
6593 const app = Effect.gen(function* () {
6594 const parent = yield* Singleton("Parent", {});
6595 return yield* Stub("Stub", { parent: parent.identity }).pipe(
6596 adopt(false),
6597 );
6598 });
6599 return Effect.gen(function* () {
6600 const probe = yield* Probe;
6601 probe.fail = true;
6602 yield* stack.destroy();
6603 yield* stack
6604 .deploy(app)
6605 .pipe(Effect.catchTag("ResourceFailure", () => Effect.void));
6606 expect((yield* getState("Stub")).attr).toEqual({
6607 identity: "stub",
6608 value: "stub",
6609 });
6610 probe.fail = false;
6611 expect((yield* stack.deploy(app)).identity).toBe("child-branch");
6612 expect(probe.reads).toEqual([]);
6613 expect(probe.reconciles).toHaveLength(2);
6614 expect(probe.reconciles.every(({ olds }) => olds === undefined)).toBe(
6615 true,
6616 );
6617 yield* stack.destroy();
6618 });
6619 },
6620 );
6621
6622 test.provider("resource adoption never authorizes a sibling", (stack) => {
6623 return Effect.gen(function* () {
6624 const probe = yield* Probe;
6625 yield* stack.destroy();
6626 const refused = yield* stack.deploy(program(true, true)).pipe(
6627 Effect.as(false),
6628 Effect.catchTag("OwnedBySomeoneElse", () => Effect.succeed(true)),
6629 );
6630 expect(refused).toBe(true);
6631 expect(probe.reads).toContain("Sibling");
6632 expect(probe.reconciles.some(({ id }) => id === "Sibling")).toBe(false);
6633 expect((yield* getState("Sibling")).attr).toBeUndefined();
6634 yield* stack.destroy();
6635 expect(probe.deletes).not.toContain("Sibling");
6636 });
6637 });
6638
6639 test.provider(
6640 "checkpoints accepted attributes and retries reconciliation with olds undefined",
6641 (stack) => {
6642 return Effect.gen(function* () {
6643 const probe = yield* Probe;
6644 probe.fail = true;
6645 yield* stack.destroy();
6646 const failed = yield* stack.deploy(program(true)).pipe(
6647 Effect.as(false),
6648 Effect.catchTag("ResourceFailure", () => Effect.succeed(true)),
6649 );
6650 expect(failed).toBe(true);
6651 const checkpoint = yield* getState<CreatingResourceState>("Child");
6652 expect(checkpoint.status).toBe("creating");
6653 expect(checkpoint.props).toEqual({ parent: "child-branch" });
6654 expect(checkpoint.attr).toEqual({
6655 identity: "child-branch",
6656 value: "inherited",
6657 });
6658 expect(Unowned.is(checkpoint.attr)).toBe(false);
6659 probe.fail = false;
6660 yield* stack.deploy(program(false));
6661 expect(probe.reconciles).toHaveLength(2);
6662 expect(probe.reconciles.every(({ olds }) => olds === undefined)).toBe(
6663 true,
6664 );
6665 expect(probe.reads).toEqual(["Child"]);
6666 yield* stack.destroy();
6667 });
6668 },
6669 );
6670
6671 test.provider(
6672 "retries an attr-less refusal using resolved desired identity",
6673 (stack) => {
6674 return Effect.gen(function* () {
6675 const probe = yield* Probe;
6676 yield* stack.destroy();
6677 yield* stack
6678 .deploy(program(false))
6679 .pipe(Effect.catchTag("OwnedBySomeoneElse", () => Effect.void));
6680 expect((yield* getState("Child")).attr).toBeUndefined();
6681 yield* stack.deploy(program(true));
6682 expect(probe.reconciles).toHaveLength(1);
6683 expect(probe.reconciles[0]?.olds).toBeUndefined();
6684 yield* stack.destroy();
6685 });
6686 },
6687 );
6688});
6689
6690describe(
6691 "interrupted create persists no unresolved Output exprs",
6692 { tags: ["unit", "local"] },
6693 () => {
6694 test.provider(
6695 "creating-state props are plain data and destroy converges",
6696 (stack) =>
6697 Effect.gen(function* () {
6698 const program = Effect.gen(function* () {
6699 const a = yield* TestResource("A", { string: "a-value" });
6700 const b = yield* TestResource("B", { string: a.string });
6701 return { a, b };
6702 });
6703
6704 // B fails after dependency resolution and the deferred-read checkpoint.
6705 yield* program.pipe(stack.deploy, hook(failOn("B", "create")));
6706
6707 const b = yield* getState("B");
6708 expect(b?.status).toEqual("creating");
6709 // Recovery receives the resolved identity, never a live Output proxy.
6710 expect((b?.props as TestResourceProps).string).toBe("a-value");
6711
6712 yield* stack.destroy();
6713 expect(yield* getState("B")).toBeUndefined();
6714 expect(yield* listState()).toEqual([]);
6715 }),
6716 );
6717 },
6718);
6719
6720describe(
6721 "interrupted replacement destruction",
6722 { tags: ["unit", "local"] },
6723 () => {
6724 type Attributes = {
6725 physicalId: string;
6726 revision: string;
6727 dependency?: string;
6728 };
6729 interface Generation extends Resource<
6730 "Test.DestructionGeneration",
6731 { revision: string; dependency?: string },
6732 Attributes
6733 > {}
6734 const Generation = Resource<Generation>("Test.DestructionGeneration");
6735 class Registry extends Context.Service<
6736 Registry,
6737 {
6738 physical: Map<string, Attributes>;
6739 calls: Array<{
6740 op: "read" | "delete";
6741 physicalId: string;
6742 mode: string;
6743 }>;
6744 reconcile?: (
6745 attrs: Attributes,
6746 create: Effect.Effect<void>,
6747 ) => Effect.Effect<void>;
6748 remove?: (attrs: Attributes) => Effect.Effect<void, ResourceFailure>;
6749 unowned?: boolean;
6750 }
6751 >()("DestructionGeneration.Registry") {}
6752 const variant = (mode: "live" | "local", precreate = false) =>
6753 Provider.succeed(Generation, {
6754 ...(precreate
6755 ? {
6756 precreate: Effect.fn(function* ({
6757 instanceId,
6758 news,
6759 }: {
6760 instanceId: string;
6761 news: Generation["Props"];
6762 }) {
6763 const registry = yield* Registry;
6764 const attrs = {
6765 physicalId: instanceId,
6766 revision: "stub",
6767 dependency: news.dependency,
6768 };
6769 registry.physical.set(instanceId, attrs);
6770 return attrs;
6771 }),
6772 }
6773 : {}),
6774 diff: Effect.fn(function* ({ news, olds }) {
6775 if (
6776 "revision" in news &&
6777 isResolved(news.revision) &&
6778 news.revision !== olds?.revision
6779 ) {
6780 return { action: "replace" };
6781 }
6782 }),
6783 read: Effect.fn(function* ({ instanceId }) {
6784 const registry = yield* Registry;
6785 registry.calls.push({ op: "read", physicalId: instanceId, mode });
6786 const attrs = registry.physical.get(instanceId);
6787 return attrs && registry.unowned ? Unowned(attrs) : attrs;
6788 }),
6789 reconcile: Effect.fn(function* ({ instanceId, news }) {
6790 const registry = yield* Registry;
6791 const attrs = { physicalId: instanceId, ...news };
6792 const create = Effect.sync(() => {
6793 registry.physical.set(instanceId, attrs);
6794 });
6795 yield* registry.reconcile
6796 ? registry.reconcile(attrs, create)
6797 : create;
6798 return attrs;
6799 }),
6800 delete: Effect.fn(function* ({ instanceId, output }) {
6801 const registry = yield* Registry;
6802 expect(output.physicalId).toBe(instanceId);
6803 registry.calls.push({ op: "delete", physicalId: instanceId, mode });
6804 if (registry.remove) yield* registry.remove(output);
6805 expect(
6806 [...registry.physical.values()].some(
6807 (value) => value.dependency === instanceId,
6808 ),
6809 ).toBe(false);
6810 registry.physical.delete(instanceId);
6811 }),
6812 });
6813 const makeRegistry = (): Registry["Service"] => ({
6814 physical: new Map(),
6815 calls: [],
6816 });
6817 const { test: generationTest } = Test.make({
6818 providers: ProviderLayer.dual(Generation, {
6819 live: () => variant("live"),
6820 local: () => variant("local"),
6821 }).pipe(
6822 Layer.provideMerge(
6823 Layer.effect(
6824 Registry,
6825 Effect.serviceOption(Registry).pipe(
6826 Effect.map(Option.getOrElse(makeRegistry)),
6827 ),
6828 ),
6829 ),
6830 ),
6831 });
6832
6833 generationTest.provider(
6834 "GC preserves dependency direction across pending generations",
6835 (stack) =>
6836 Effect.gen(function* () {
6837 const registry = yield* Registry;
6838 yield* stack.destroy();
6839 const first = yield* stack.deploy(
6840 Effect.gen(function* () {
6841 const A = yield* Generation("A", { revision: "one" });
6842 const B = yield* Generation("B", {
6843 revision: "one",
6844 dependency: A.physicalId,
6845 });
6846 return { A, B };
6847 }),
6848 );
6849 registry.remove = (attrs) =>
6850 attrs.physicalId === first.B.physicalId
6851 ? Effect.fail(new ResourceFailure())
6852 : Effect.void;
6853 const replacement = yield* stack
6854 .deploy(
6855 Effect.gen(function* () {
6856 const B = yield* Generation("B", { revision: "two" });
6857 return yield* Generation("A", {
6858 revision: "two",
6859 dependency: B.physicalId,
6860 });
6861 }),
6862 )
6863 .pipe(Effect.exit);
6864 expect(Exit.isFailure(replacement)).toBe(true);
6865 const A = yield* getState("A");
6866 const B = yield* getState("B");
6867 assert(A.status === "replaced" && B.status === "replaced");
6868 registry.remove = undefined;
6869 registry.calls.length = 0;
6870 const current = yield* stack.deploy(
6871 Effect.gen(function* () {
6872 const A = yield* Generation("A", { revision: "three" });
6873 const B = yield* Generation("B", { revision: "three" });
6874 return { A, B };
6875 }),
6876 );
6877 expect(
6878 registry.calls
6879 .filter((call) => call.op === "delete")
6880 .map((call) => call.physicalId),
6881 ).toEqual([
6882 A.instanceId,
6883 B.instanceId,
6884 first.B.physicalId,
6885 first.A.physicalId,
6886 ]);
6887 expect([...registry.physical.keys()].sort()).toEqual(
6888 [current.A.physicalId, current.B.physicalId].sort(),
6889 );
6890 yield* stack.destroy();
6891 expect([...registry.physical.values()]).toEqual([]);
6892 }),
6893 { timeout: 10_000 },
6894 );
6895
6896 generationTest.provider(
6897 "GC waits for a deferred physical deletion before deleting its dependency",
6898 (stack) =>
6899 Effect.gen(function* () {
6900 const registry = yield* Registry;
6901 yield* stack.destroy();
6902 const first = yield* stack.deploy(
6903 Effect.gen(function* () {
6904 const A = yield* Generation("A", { revision: "one" });
6905 const B = yield* Generation("B", {
6906 revision: "one",
6907 dependency: A.physicalId,
6908 });
6909 const C = yield* Generation("C", { revision: "one" });
6910 return { A, B, C };
6911 }),
6912 );
6913 registry.remove = (attrs) =>
6914 attrs.physicalId === first.C.physicalId
6915 ? Effect.fail(new ResourceFailure())
6916 : Effect.void;
6917 const replacement = yield* stack
6918 .deploy(
6919 Effect.gen(function* () {
6920 const A = yield* Generation("A", { revision: "one" });
6921 const B = yield* Generation("B", {
6922 revision: "one",
6923 dependency: A.physicalId,
6924 });
6925 return yield* Generation("C", {
6926 revision: "two",
6927 dependency: B.physicalId,
6928 });
6929 }),
6930 )
6931 .pipe(Effect.exit);
6932 assert(Exit.isFailure(replacement));
6933 const C = yield* getState("C");
6934 assert(C.status === "replaced");
6935 expect(C.old.instanceId).toBe(first.C.physicalId);
6936 registry.remove = undefined;
6937 registry.calls.length = 0;
6938 const current = yield* stack.deploy(
6939 Effect.gen(function* () {
6940 const A = yield* Generation("A", { revision: "two" });
6941 const C = yield* Generation("C", { revision: "three" });
6942 return { A, C };
6943 }),
6944 );
6945 const deleted = registry.calls
6946 .filter((call) => call.op === "delete")
6947 .map((call) => call.physicalId);
6948 expect(deleted).toEqual([
6949 C.instanceId,
6950 first.C.physicalId,
6951 first.B.physicalId,
6952 first.A.physicalId,
6953 ]);
6954 expect(yield* getState("B")).toBeUndefined();
6955 expect([...registry.physical.keys()].sort()).toEqual(
6956 [current.A.physicalId, current.C.physicalId].sort(),
6957 );
6958 yield* stack.destroy();
6959 expect([...registry.physical.values()]).toEqual([]);
6960 expect(yield* listState()).toEqual([]);
6961 }),
6962 { timeout: 10_000 },
6963 );
6964
6965 const { test: stubTest } = Test.make({
6966 providers: variant("live", true).pipe(
6967 Layer.provideMerge(
6968 Layer.effect(
6969 Registry,
6970 Effect.serviceOption(Registry).pipe(
6971 Effect.map(Option.getOrElse(makeRegistry)),
6972 ),
6973 ),
6974 ),
6975 ),
6976 });
6977 stubTest.provider(
6978 "unfinished precreate remains resumable after its deleting checkpoint fails",
6979 (stack) =>
6980 Effect.gen(function* () {
6981 const registry = yield* Registry;
6982 yield* stack.destroy();
6983 const first = yield* stack.deploy(
6984 Generation("R", { revision: "one" }),
6985 );
6986 const reached = yield* Deferred.make<void>();
6987 registry.reconcile = () =>
6988 Deferred.succeed(reached, undefined).pipe(
6989 Effect.andThen(Effect.never),
6990 );
6991 const fiber = yield* stack
6992 .deploy(Generation("R", { revision: "two" }))
6993 .pipe(Effect.forkChild);
6994 yield* Deferred.await(reached).pipe(Effect.timeout("2 seconds"));
6995 yield* Fiber.interrupt(fiber);
6996 const pending = yield* getState("R");
6997 assert(pending.status === "replacing");
6998 expect(pending.attr?.revision).toBe("stub");
6999 const plan = yield* stack.plan(Effect.void);
7000 const state = yield* yield* State;
7001 const exit = yield* apply(plan).pipe(
7002 Effect.provideService(
7003 State,
7004 Effect.succeed({
7005 ...state,
7006 set: (request) =>
7007 request.fqn === "R" && request.value.status === "deleting"
7008 ? Effect.fail(
7009 new StateStoreError({
7010 message: "Deleting checkpoint failed",
7011 }),
7012 )
7013 : state.set(request),
7014 }),
7015 ),
7016 Effect.exit,
7017 );
7018 assert(Exit.isFailure(exit));
7019 expect(registry.physical.has(first.physicalId)).toBe(false);
7020 const checkpoint = yield* getState("R");
7021 let reconciles = 0;
7022 registry.reconcile = (_, create) =>
7023 Effect.sync(() => {
7024 reconciles++;
7025 }).pipe(Effect.andThen(create));
7026 const output = yield* stack.deploy(
7027 Generation("R", { revision: "two" }),
7028 );
7029 expect(reconciles).toBe(1);
7030 expect(checkpoint.status).toBe("creating");
7031 expect(checkpoint.attr).toEqual(pending.attr);
7032 expect(output.revision).toBe("two");
7033 yield* stack.destroy();
7034 expect([...registry.physical.values()]).toEqual([]);
7035 }),
7036 { timeout: 10_000 },
7037 );
7038
7039 for (const phase of [
7040 "before-create",
7041 "after-create",
7042 "after-reconcile",
7043 ] as const) {
7044 generationTest.provider(
7045 `destroy drains generations interrupted ${phase}`,
7046 (stack) =>
7047 Effect.gen(function* () {
7048 const registry = yield* Registry;
7049 yield* stack.destroy();
7050 const first = yield* inDev(
7051 stack.deploy(Generation("R", { revision: "one" })),
7052 );
7053 const reached = yield* Deferred.make<void>();
7054 registry.reconcile = (_, create) =>
7055 Effect.gen(function* () {
7056 if (phase !== "before-create") yield* create;
7057 if (phase !== "after-reconcile") {
7058 yield* Deferred.succeed(reached, undefined);
7059 yield* Effect.never;
7060 }
7061 });
7062 registry.remove = () =>
7063 Deferred.succeed(reached, undefined).pipe(
7064 Effect.andThen(Effect.never),
7065 );
7066 const fiber = yield* stack
7067 .deploy(Generation("R", { revision: "two" }))
7068 .pipe(Effect.forkChild);
7069 yield* Deferred.await(reached).pipe(Effect.timeout("2 seconds"));
7070 yield* Fiber.interrupt(fiber);
7071 const pending = yield* getState("R");
7072 assert(
7073 pending?.status === "replacing" || pending?.status === "replaced",
7074 );
7075 expect(pending.instanceId).not.toBe(first.physicalId);
7076 expect(pending.old.instanceId).toBe(first.physicalId);
7077 registry.reconcile = undefined;
7078 registry.remove = undefined;
7079 registry.calls.length = 0;
7080 yield* stack.destroy();
7081 expect(yield* listState()).toEqual([]);
7082 expect([...registry.physical.values()]).toEqual([]);
7083 expect(registry.calls).toContainEqual({
7084 op: "delete",
7085 physicalId: first.physicalId,
7086 mode: "local",
7087 });
7088 expect(registry.calls).toContainEqual({
7089 op: phase === "before-create" ? "read" : "delete",
7090 physicalId: pending.instanceId,
7091 mode: "live",
7092 });
7093 yield* stack.destroy();
7094 }),
7095 { timeout: 10_000 },
7096 );
7097 }
7098
7099 generationTest.provider(
7100 "replacement GC blocks a planned dependency deletion until its entire old chain drains",
7101 (stack) =>
7102 Effect.gen(function* () {
7103 const registry = yield* Registry;
7104 yield* stack.destroy();
7105 const program = (revision: string) =>
7106 Effect.gen(function* () {
7107 const dependency = yield* Generation("Dependency", {
7108 revision: "dependency",
7109 });
7110 return yield* Generation("R", {
7111 revision,
7112 dependency: dependency.physicalId,
7113 });
7114 });
7115 const first = yield* stack.deploy(program("one"));
7116 const reached = yield* Deferred.make<void>();
7117 registry.reconcile = (attrs, create) =>
7118 create.pipe(
7119 Effect.andThen(
7120 attrs.revision === "two"
7121 ? Deferred.succeed(reached, undefined).pipe(
7122 Effect.andThen(Effect.never),
7123 )
7124 : Effect.void,
7125 ),
7126 );
7127 const fiber = yield* stack
7128 .deploy(program("two"))
7129 .pipe(Effect.forkChild);
7130 yield* Deferred.await(reached).pipe(Effect.timeout("2 seconds"));
7131 yield* Fiber.interrupt(fiber);
7132 registry.reconcile = undefined;
7133 registry.remove = () => Effect.fail(new ResourceFailure());
7134 expect(
7135 Exit.isFailure(
7136 yield* stack.deploy(program("three")).pipe(Effect.exit),
7137 ),
7138 ).toBe(true);
7139 const pending = yield* getState("R");
7140 assert(pending?.status === "replaced");
7141 assert(pending.old.status === "replacing");
7142 registry.remove = (attrs) =>
7143 attrs.physicalId === first.physicalId
7144 ? Effect.fail(new ResourceFailure())
7145 : Effect.void;
7146 const survivor = Generation("R", { revision: "three" });
7147 const exit = yield* stack.deploy(survivor).pipe(Effect.exit);
7148 assert(Exit.isFailure(exit));
7149 const error = exit.cause.reasons.find(Cause.isFailReason)?.error;
7150 assert(error instanceof DestroyError);
7151 expect(error.failures.map((entry) => entry.fqn)).toEqual(["R"]);
7152 expect(error.blocked.map((entry) => entry.fqn)).toEqual([
7153 "Dependency",
7154 ]);
7155 expect(registry.physical.has(pending.old.instanceId)).toBe(false);
7156 expect(registry.physical.has(first.physicalId)).toBe(true);
7157 expect(yield* getState("Dependency")).toBeDefined();
7158 registry.remove = undefined;
7159 yield* stack.deploy(survivor);
7160 expect([...registry.physical.keys()]).toEqual([pending.instanceId]);
7161 expect(yield* getState("Dependency")).toBeUndefined();
7162 yield* stack.destroy();
7163 expect([...registry.physical.values()]).toEqual([]);
7164 }),
7165 { timeout: 10_000 },
7166 );
7167
7168 generationTest.provider(
7169 "retries an old-generation delete when its cleanup checkpoint fails",
7170 (stack) =>
7171 Effect.gen(function* () {
7172 const registry = yield* Registry;
7173 yield* stack.destroy();
7174 const first = yield* stack.deploy(
7175 Generation("R", { revision: "one" }),
7176 );
7177 const reached = yield* Deferred.make<void>();
7178 registry.reconcile = (_, create) =>
7179 create.pipe(
7180 Effect.andThen(Deferred.succeed(reached, undefined)),
7181 Effect.andThen(Effect.never),
7182 );
7183 const fiber = yield* stack
7184 .deploy(Generation("R", { revision: "two" }))
7185 .pipe(Effect.forkChild);
7186 yield* Deferred.await(reached).pipe(Effect.timeout("2 seconds"));
7187 yield* Fiber.interrupt(fiber);
7188 const pending = yield* getState("R");
7189 assert(pending?.status === "replacing");
7190 const plan = yield* stack.plan(Effect.void);
7191 const state = yield* yield* State;
7192 const exit = yield* apply(plan).pipe(
7193 Effect.provideService(
7194 State,
7195 Effect.succeed({
7196 ...state,
7197 set: (request) =>
7198 request.fqn === "R" && request.value.status === "creating"
7199 ? Effect.fail(
7200 new StateStoreError({ message: "Checkpoint failed" }),
7201 )
7202 : state.set(request),
7203 }),
7204 ),
7205 Effect.exit,
7206 );
7207 assert(Exit.isFailure(exit));
7208 const error = exit.cause.reasons.find(Cause.isFailReason)?.error;
7209 assert(error instanceof DestroyError);
7210 expect(error.failures.map((entry) => entry.fqn)).toEqual(["R"]);
7211 expect(registry.physical.has(first.physicalId)).toBe(false);
7212 expect(registry.physical.has(pending.instanceId)).toBe(true);
7213 expect(yield* getState("R")).toEqual(pending);
7214 yield* stack.destroy();
7215 expect(
7216 registry.calls.filter(
7217 (call) =>
7218 call.op === "delete" && call.physicalId === first.physicalId,
7219 ),
7220 ).toHaveLength(2);
7221 expect([...registry.physical.values()]).toEqual([]);
7222 expect(yield* listState()).toEqual([]);
7223 }),
7224 { timeout: 10_000 },
7225 );
7226
7227 for (const policy of ["unowned", "retain"] as const) {
7228 generationTest.provider(
7229 `preserves ${policy} physical resources during interrupted replacement cleanup`,
7230 (stack) =>
7231 Effect.gen(function* () {
7232 const registry = yield* Registry;
7233 yield* stack.destroy();
7234 const program = (revision: string) =>
7235 Generation("R", { revision }).pipe(
7236 RemovalPolicy.retain(policy === "retain"),
7237 );
7238 const first = yield* stack.deploy(program("one"));
7239 const reached = yield* Deferred.make<void>();
7240 registry.reconcile = (_, create) =>
7241 create.pipe(
7242 Effect.andThen(Deferred.succeed(reached, undefined)),
7243 Effect.andThen(Effect.never),
7244 );
7245 const fiber = yield* stack
7246 .deploy(program("two"))
7247 .pipe(Effect.forkChild);
7248 yield* Deferred.await(reached).pipe(Effect.timeout("2 seconds"));
7249 yield* Fiber.interrupt(fiber);
7250 const pending = yield* getState("R");
7251 assert(pending?.status === "replacing");
7252 registry.unowned = policy === "unowned";
7253 registry.calls.length = 0;
7254 yield* stack.destroy();
7255 expect(yield* listState()).toEqual([]);
7256 expect(registry.physical.has(pending.instanceId)).toBe(true);
7257 expect(registry.physical.has(first.physicalId)).toBe(
7258 policy === "retain",
7259 );
7260 expect(
7261 registry.calls
7262 .filter((call) => call.op === "delete")
7263 .map((call) => call.physicalId),
7264 ).toEqual(policy === "retain" ? [] : [first.physicalId]);
7265 }),
7266 { timeout: 10_000 },
7267 );
7268 }
7269
7270 for (const failure of ["fail", "interrupt"] as const) {
7271 for (const target of ["newest", "oldest"] as const) {
7272 generationTest.provider(
7273 `${failure} deleting ${target} preserves the chain and blocks dependencies`,
7274 (stack) =>
7275 Effect.gen(function* () {
7276 const registry = yield* Registry;
7277 yield* stack.destroy();
7278 const program = (revision: string) =>
7279 Effect.gen(function* () {
7280 const dependency = yield* Generation("Dependency", {
7281 revision: "dependency",
7282 });
7283 yield* Generation("Sibling", { revision: "sibling" });
7284 const resource = Generation("R", {
7285 revision,
7286 dependency: dependency.physicalId,
7287 });
7288 return yield* revision === "two"
7289 ? resource.pipe(remote())
7290 : resource;
7291 });
7292 const first = yield* inDev(stack.deploy(program("one")));
7293 for (const revision of ["two", "three"]) {
7294 const reached = yield* Deferred.make<void>();
7295 registry.reconcile = (attrs, create) =>
7296 create.pipe(
7297 Effect.andThen(
7298 attrs.revision === revision
7299 ? Deferred.succeed(reached, undefined).pipe(
7300 Effect.andThen(Effect.never),
7301 )
7302 : Effect.void,
7303 ),
7304 );
7305 const fiber = yield* inDev(
7306 stack.deploy(program(revision)),
7307 ).pipe(Effect.forkChild);
7308 yield* Deferred.await(reached).pipe(
7309 Effect.timeout("2 seconds"),
7310 );
7311 yield* Fiber.interrupt(fiber);
7312 }
7313 const pending = yield* getState("R");
7314 assert(pending?.status === "replacing");
7315 assert(pending.old.status === "replacing");
7316 expect(
7317 new Set([
7318 first.physicalId,
7319 pending.old.instanceId,
7320 pending.instanceId,
7321 ]).size,
7322 ).toBe(3);
7323 const blocked = yield* Deferred.make<void>();
7324 registry.remove = (attrs) =>
7325 attrs.physicalId ===
7326 (target === "newest" ? pending.instanceId : first.physicalId)
7327 ? failure === "fail"
7328 ? Effect.fail(new ResourceFailure())
7329 : Deferred.succeed(blocked, undefined).pipe(
7330 Effect.andThen(Effect.never),
7331 )
7332 : Effect.void;
7333 if (failure === "fail") {
7334 const exit = yield* stack.destroy().pipe(Effect.exit);
7335 assert(Exit.isFailure(exit));
7336 const error = exit.cause.reasons.find(
7337 Cause.isFailReason,
7338 )?.error;
7339 assert(error instanceof DestroyError);
7340 expect(error.failures.map((entry) => entry.fqn)).toEqual(["R"]);
7341 expect(
7342 error.blocked.map((entry) => ({
7343 fqn: entry.fqn,
7344 blockedBy: entry.blockedBy,
7345 })),
7346 ).toEqual([{ fqn: "Dependency", blockedBy: ["R"] }]);
7347 } else {
7348 const fiber = yield* stack.destroy().pipe(Effect.forkChild);
7349 yield* Deferred.await(blocked).pipe(
7350 Effect.timeout("2 seconds"),
7351 );
7352 yield* Fiber.interrupt(fiber);
7353 }
7354 expect(yield* getState("Dependency")).toBeDefined();
7355 const checkpoint = yield* getState("R");
7356 assert(checkpoint !== undefined);
7357 if (target === "oldest") {
7358 assert(checkpoint.status === "replacing");
7359 expect(checkpoint.old.instanceId).toBe(first.physicalId);
7360 expect(registry.physical.has(first.physicalId)).toBe(true);
7361 } else {
7362 expect(checkpoint.status).toBe("deleting");
7363 expect(checkpoint.instanceId).toBe(pending.instanceId);
7364 expect(registry.physical.has(first.physicalId)).toBe(false);
7365 }
7366 if (failure === "fail")
7367 expect(yield* getState("Sibling")).toBeUndefined();
7368 registry.remove = undefined;
7369 yield* stack.destroy();
7370 expect([...registry.physical.values()]).toEqual([]);
7371 expect(yield* listState()).toEqual([]);
7372 for (const [physicalId, mode] of [
7373 [first.physicalId, "local"],
7374 [pending.old.instanceId, "live"],
7375 [pending.instanceId, "local"],
7376 ]) {
7377 expect(registry.calls).toContainEqual({
7378 op: "delete",
7379 physicalId,
7380 mode,
7381 });
7382 }
7383 }),
7384 { timeout: 10_000 },
7385 );
7386 }
7387 }
7388 },
7389);
7390
7391// A single failed provider.delete used to abort the whole destroy, stranding
7392// every not-yet-deleted resource — even ones whose deletes would have
7393// succeeded. The engine now attempts every delete in dependency order,
7394// collects the failures, skips only resources whose DEPENDENT failed to
7395// delete (they may be legitimately undeletable — "blocked", not a second
7396// error), and raises everything at the end as one typed DestroyError.
7397describe("error-aggregating destroy", { tags: ["unit", "local"] }, () => {
7398 const expectDestroyError = (exit: Exit.Exit<unknown, unknown>) => {
7399 expect(Exit.isFailure(exit)).toBe(true);
7400 assert(Exit.isFailure(exit));
7401 const reason = exit.cause.reasons.find(Cause.isFailReason);
7402 const error = reason?.error as DestroyError;
7403 expect(error._tag).toBe("DestroyError");
7404 return error;
7405 };
7406
7407 test.provider(
7408 "a failed delete does not abort sibling deletes and aggregates into DestroyError",
7409 (stack) =>
7410 Effect.gen(function* () {
7411 yield* stack.deploy(
7412 Effect.gen(function* () {
7413 yield* TestResource("A", { string: "a" });
7414 yield* TestResource("B", { string: "b" });
7415 yield* TestResource("C", { string: "c" });
7416 }),
7417 );
7418
7419 const deleted: string[] = [];
7420 const exit = yield* stack.destroy().pipe(
7421 Effect.provide(
7422 Layer.succeed(TestResourceHooks, {
7423 delete: (id: string) =>
7424 id === "B"
7425 ? Effect.fail(new ResourceFailure())
7426 : Effect.sync(() => void deleted.push(id)),
7427 }),
7428 ),
7429 Effect.exit,
7430 );
7431
7432 // The independent siblings were still deleted...
7433 expect(deleted.sort()).toEqual(["A", "C"]);
7434 expect(yield* getState("A")).toBeUndefined();
7435 expect(yield* getState("C")).toBeUndefined();
7436 // ...the failed resource stays behind for the next destroy...
7437 expect((yield* getState("B"))?.status).toEqual("deleting");
7438
7439 // ...and the destroy as a whole still fails, with a typed aggregate.
7440 const error = expectDestroyError(exit);
7441 expect(error.failures.map((f) => f.fqn)).toEqual(["B"]);
7442 expect(error.blocked).toEqual([]);
7443
7444 // A subsequent destroy (failure gone) converges.
7445 yield* stack.destroy();
7446 expect(yield* listState()).toEqual([]);
7447 }),
7448 );
7449
7450 test.provider(
7451 "a resource whose dependent failed to delete is skipped as blocked, not attempted",
7452 (stack) =>
7453 Effect.gen(function* () {
7454 // A <- B <- C is a dependency chain (deletes run C, B, A); D is
7455 // independent.
7456 yield* stack.deploy(
7457 Effect.gen(function* () {
7458 const A = yield* TestResource("A", { string: "a" });
7459 const B = yield* TestResource("B", { string: A.string });
7460 yield* TestResource("C", { string: B.string });
7461 yield* TestResource("D", { string: "d" });
7462 }),
7463 );
7464
7465 const deleted: string[] = [];
7466 const exit = yield* stack.destroy().pipe(
7467 Effect.provide(
7468 Layer.succeed(TestResourceHooks, {
7469 delete: (id: string) =>
7470 id === "C"
7471 ? Effect.fail(new ResourceFailure())
7472 : Effect.sync(() => void deleted.push(id)),
7473 }),
7474 ),
7475 Effect.exit,
7476 );
7477
7478 // Only the independent sibling was attempted and deleted. A and B
7479 // sit upstream of the failed C, so their deletes were never even
7480 // attempted — they may be legitimately undeletable while C exists.
7481 expect(deleted).toEqual(["D"]);
7482 expect(yield* getState("D")).toBeUndefined();
7483 expect((yield* getState("C"))?.status).toEqual("deleting");
7484 expect((yield* getState("B"))?.status).toEqual("created");
7485 expect((yield* getState("A"))?.status).toEqual("created");
7486
7487 // The aggregate reports exactly one FAILURE (C); B and A are
7488 // "blocked by" notes, not spurious errors.
7489 const error = expectDestroyError(exit);
7490 expect(error.failures.map((f) => f.fqn)).toEqual(["C"]);
7491 expect(
7492 error.blocked
7493 .map((b) => ({ fqn: b.fqn, blockedBy: b.blockedBy }))
7494 .sort((x, y) => x.fqn.localeCompare(y.fqn)),
7495 ).toEqual([
7496 { fqn: "A", blockedBy: ["B"] },
7497 { fqn: "B", blockedBy: ["C"] },
7498 ]);
7499
7500 // Once the blocker can be deleted, everything drains.
7501 yield* stack.destroy();
7502 expect(yield* listState()).toEqual([]);
7503 }),
7504 );
7505
7506 test.provider(
7507 "independent subtrees are unaffected by a failure in another subtree",
7508 (stack) =>
7509 Effect.gen(function* () {
7510 // Two disjoint chains: A <- B (B's delete fails) and X <- Y.
7511 yield* stack.deploy(
7512 Effect.gen(function* () {
7513 const A = yield* TestResource("A", { string: "a" });
7514 yield* TestResource("B", { string: A.string });
7515 const X = yield* TestResource("X", { string: "x" });
7516 yield* TestResource("Y", { string: X.string });
7517 }),
7518 );
7519
7520 const deleted: string[] = [];
7521 const exit = yield* stack.destroy().pipe(
7522 Effect.provide(
7523 Layer.succeed(TestResourceHooks, {
7524 delete: (id: string) =>
7525 id === "B"
7526 ? Effect.fail(new ResourceFailure())
7527 : Effect.sync(() => void deleted.push(id)),
7528 }),
7529 ),
7530 Effect.exit,
7531 );
7532
7533 // The X <- Y chain drained fully, in dependency order.
7534 expect(deleted).toEqual(["Y", "X"]);
7535 expect(yield* getState("X")).toBeUndefined();
7536 expect(yield* getState("Y")).toBeUndefined();
7537 // B failed; A is blocked behind it.
7538 expect((yield* getState("B"))?.status).toEqual("deleting");
7539 expect((yield* getState("A"))?.status).toEqual("created");
7540
7541 const error = expectDestroyError(exit);
7542 expect(error.failures.map((f) => f.fqn)).toEqual(["B"]);
7543 expect(error.blocked.map((b) => b.fqn)).toEqual(["A"]);
7544 }),
7545 );
7546
7547 test.provider(
7548 "a failed replaced-old-generation delete fails the deploy without spinning the drain loop",
7549 (stack) =>
7550 Effect.gen(function* () {
7551 yield* stack.deploy(
7552 Effect.gen(function* () {
7553 yield* TestResource("A", { replaceString: "v1" });
7554 }),
7555 );
7556
7557 // Replacement create succeeds; GC then fails to delete the old
7558 // generation. The drain loop must terminate (the still-`replaced`
7559 // row is excluded from retry) and surface the typed aggregate.
7560 const exit = yield* stack
7561 .deploy(
7562 Effect.gen(function* () {
7563 yield* TestResource("A", { replaceString: "v2" });
7564 }),
7565 )
7566 .pipe(
7567 Effect.provide(
7568 Layer.succeed(TestResourceHooks, {
7569 delete: () => Effect.fail(new ResourceFailure()),
7570 }),
7571 ),
7572 Effect.exit,
7573 );
7574
7575 const error = expectDestroyError(exit);
7576 expect(error.failures.map((f) => f.fqn)).toEqual(["A"]);
7577
7578 // The replacement chain is preserved for a later deploy to drain.
7579 const state = yield* getState<ReplacedResourceState>("A");
7580 expect(state?.status).toEqual("replaced");
7581 expect(state?.old?.status).toEqual("created");
7582
7583 // Next deploy (delete healthy again) drains the old chain.
7584 yield* stack.deploy(
7585 Effect.gen(function* () {
7586 yield* TestResource("A", { replaceString: "v2" });
7587 }),
7588 );
7589 expect((yield* getState("A"))?.status).toEqual("created");
7590 }),
7591 { timeout: 15_000 },
7592 );
7593});
7594
7595describe("provider modes (local ⇄ live)", { tags: ["unit", "local"] }, () => {
7596 // ModalResource registers via `ProviderLayer.dual` with distinct live and
7597 // local implementations that record lifecycle calls per variant into
7598 // `modalCalls` (tagged with the scratch stack name so concurrent tests can
7599 // filter to their own activity). These tests cover the APPLY-level
7600 // semantics: `providerMode` stamping across every lifecycle commit,
7601 // mode-correct deletes of old generations and orphans, replaced-chain
7602 // draining, destroy, and legacy-row re-stamping.
7603
7604 const callsFor = (stackName: string) =>
7605 modalCalls.filter((c) => c.stack === stackName);
7606
7607 const setState = Effect.fn(function* (fqn: string, value: ResourceState) {
7608 const state = yield* yield* State;
7609 const stk = yield* Stack;
7610 yield* state.set({ stack: stk.name, stage: stk.stage, fqn, value });
7611 });
7612
7613 const modal = (id: string, props: ModalResourceProps) =>
7614 Effect.gen(function* () {
7615 const a = yield* ModalResource(id, props);
7616 return { runtime: a.runtime };
7617 });
7618
7619 test.provider(
7620 "providerMode is stamped through create → update → mode-switch replace → same-mode replace",
7621 (stack) =>
7622 Effect.gen(function* () {
7623 // ── create (dev run → local) ──
7624 const created = yield* inDev(
7625 modal("A", { value: "v1" }).pipe(stack.deploy),
7626 );
7627 expect(created.runtime).toEqual("local");
7628 const afterCreate = yield* getState("A");
7629 expect(afterCreate?.status).toEqual("created");
7630 expect(afterCreate?.providerMode).toEqual("local");
7631 const localInstanceId = afterCreate?.instanceId;
7632
7633 // ── update (still a dev run → local) ──
7634 yield* inDev(modal("A", { value: "v2" }).pipe(stack.deploy));
7635 const afterUpdate = yield* getState("A");
7636 expect(afterUpdate?.status).toEqual("updated");
7637 expect(afterUpdate?.providerMode).toEqual("local");
7638 expect(afterUpdate?.instanceId).toEqual(localInstanceId);
7639
7640 // ── mode switch (local → live): replacement; the LOCAL provider
7641 // deletes the old generation, the LIVE provider creates the new ──
7642 const before = callsFor(stack.name).length;
7643 const switched = yield* modal("A", { value: "v2" }).pipe(stack.deploy);
7644 expect(switched.runtime).toEqual("live");
7645 const afterSwitch = yield* getState("A");
7646 expect(afterSwitch?.status).toEqual("created");
7647 expect(afterSwitch?.providerMode).toEqual("live");
7648 expect(afterSwitch?.instanceId).not.toEqual(localInstanceId);
7649 const switchCalls = callsFor(stack.name).slice(before);
7650 expect(switchCalls).toContainEqual({
7651 stack: stack.name,
7652 mode: "live",
7653 op: "reconcile",
7654 id: "A",
7655 });
7656 expect(switchCalls).toContainEqual({
7657 stack: stack.name,
7658 mode: "local",
7659 op: "delete",
7660 id: "A",
7661 });
7662
7663 // ── ordinary (same-mode) replacement via the provider diff:
7664 // providerMode survives, and the old generation is deleted with
7665 // its own (live) mode ──
7666 const beforeReplace = callsFor(stack.name).length;
7667 yield* modal("A", { value: "v2", replaceValue: "r2" }).pipe(
7668 stack.deploy,
7669 );
7670 const afterReplace = yield* getState("A");
7671 expect(afterReplace?.status).toEqual("created");
7672 expect(afterReplace?.providerMode).toEqual("live");
7673 expect(afterReplace?.instanceId).not.toEqual(afterSwitch?.instanceId);
7674 expect(callsFor(stack.name).slice(beforeReplace)).toContainEqual({
7675 stack: stack.name,
7676 mode: "live",
7677 op: "delete",
7678 id: "A",
7679 });
7680
7681 yield* stack.destroy();
7682 expect(yield* getState("A")).toBeUndefined();
7683 }),
7684 );
7685
7686 test.provider(
7687 "a replaced chain drains each old generation with ITS stamped mode",
7688 (stack) =>
7689 Effect.gen(function* () {
7690 // Simulate an interrupted mode-switch deploy: the live replacement
7691 // was created and committed as `replaced`, but the apply died before
7692 // GC drained the old (local) generation. The recovery deploy's GC
7693 // must delete that generation with the LOCAL provider.
7694 const oldInstanceId = "11111111111111111111111111111111";
7695 const newInstanceId = "22222222222222222222222222222222";
7696 yield* setState("A", {
7697 status: "replaced",
7698 fqn: "A",
7699 logicalId: "A",
7700 namespace: undefined,
7701 instanceId: newInstanceId,
7702 resourceType: "Test.ModalResource",
7703 providerVersion: 0,
7704 props: { value: "v1" },
7705 attr: { value: "v1", runtime: "live" },
7706 bindings: [],
7707 downstream: [],
7708 deleteFirst: false,
7709 providerMode: "live",
7710 old: {
7711 status: "created",
7712 fqn: "A",
7713 logicalId: "A",
7714 namespace: undefined,
7715 instanceId: oldInstanceId,
7716 resourceType: "Test.ModalResource",
7717 providerVersion: 0,
7718 props: { value: "v1" },
7719 attr: { value: "v1", runtime: "local" },
7720 bindings: [],
7721 downstream: [],
7722 providerMode: "local",
7723 },
7724 } as ResourceState);
7725
7726 const before = callsFor(stack.name).length;
7727 // Identical props, live-default run: the top generation noops and
7728 // GC drains the pending old chain.
7729 yield* modal("A", { value: "v1" }).pipe(stack.deploy);
7730
7731 expect(callsFor(stack.name).slice(before)).toContainEqual({
7732 stack: stack.name,
7733 mode: "local",
7734 op: "delete",
7735 id: "A",
7736 });
7737 const settled = yield* getState("A");
7738 expect(settled?.status).toEqual("created");
7739 expect(settled?.providerMode).toEqual("live");
7740 expect(settled?.instanceId).toEqual(newInstanceId);
7741
7742 yield* stack.destroy();
7743 }),
7744 );
7745
7746 test.provider(
7747 "stack.destroy tears down a local row with the local provider during a live-default run",
7748 (stack) =>
7749 Effect.gen(function* () {
7750 yield* inDev(modal("A", { value: "v1" }).pipe(stack.deploy));
7751 expect((yield* getState("A"))?.providerMode).toEqual("local");
7752
7753 // `stack.destroy()` runs without any mode policy — the run default
7754 // is live — but the orphaned row must still be deleted by the
7755 // provider that created it.
7756 const before = callsFor(stack.name).length;
7757 yield* stack.destroy();
7758
7759 expect(yield* getState("A")).toBeUndefined();
7760 expect(yield* listState()).toEqual([]);
7761 expect(callsFor(stack.name).slice(before)).toContainEqual({
7762 stack: stack.name,
7763 mode: "local",
7764 op: "delete",
7765 id: "A",
7766 });
7767 }),
7768 );
7769
7770 test.provider(
7771 "legacy rows (no persisted mode) are re-stamped on their next write",
7772 (stack) =>
7773 Effect.gen(function* () {
7774 yield* modal("A", { value: "v1" }).pipe(stack.deploy);
7775 const row = yield* getState("A");
7776 expect(row?.providerMode).toEqual("live");
7777
7778 // Simulate a row written before providerMode existed.
7779 yield* setState("A", { ...row!, providerMode: undefined });
7780
7781 // Same-mode update: no replacement churn (assumed current mode) and
7782 // the row comes out stamped.
7783 yield* modal("A", { value: "v2" }).pipe(stack.deploy);
7784 const restamped = yield* getState("A");
7785 expect(restamped?.status).toEqual("updated");
7786 expect(restamped?.providerMode).toEqual("live");
7787 // Same instance — the legacy row was updated, not replaced.
7788 expect(restamped?.instanceId).toEqual(row?.instanceId);
7789
7790 yield* stack.destroy();
7791 }),
7792 );
7793
7794 test.provider(
7795 "a deleteFirst replacement tears down a mixed-mode old chain, each generation with its own provider",
7796 (stack) =>
7797 Effect.gen(function* () {
7798 // An interrupted local → live switch left a `replaced` row: live top
7799 // generation, undrained local old generation. A deleteFirst
7800 // replacement (deleteFirstValue change) now restarts a new outer
7801 // generation and must tear the WHOLE chain down before creating —
7802 // the live generation with the LIVE provider, the local generation
7803 // with the LOCAL provider (`deleteOldGenerations`).
7804 const oldInstanceId = "11111111111111111111111111111111";
7805 const newInstanceId = "22222222222222222222222222222222";
7806 yield* setState("A", {
7807 status: "replaced",
7808 fqn: "A",
7809 logicalId: "A",
7810 namespace: undefined,
7811 instanceId: newInstanceId,
7812 resourceType: "Test.ModalResource",
7813 providerVersion: 0,
7814 props: { value: "v1" },
7815 attr: { value: "v1", runtime: "live" },
7816 bindings: [],
7817 downstream: [],
7818 deleteFirst: false,
7819 providerMode: "live",
7820 old: {
7821 status: "created",
7822 fqn: "A",
7823 logicalId: "A",
7824 namespace: undefined,
7825 instanceId: oldInstanceId,
7826 resourceType: "Test.ModalResource",
7827 providerVersion: 0,
7828 props: { value: "v1" },
7829 attr: { value: "v1", runtime: "local" },
7830 bindings: [],
7831 downstream: [],
7832 providerMode: "local",
7833 },
7834 } as ResourceState);
7835
7836 const before = callsFor(stack.name).length;
7837 yield* modal("A", { value: "v1", deleteFirstValue: "df" }).pipe(
7838 stack.deploy,
7839 );
7840
7841 const calls = callsFor(stack.name).slice(before);
7842 const liveDelete = calls.findIndex(
7843 (c) => c.op === "delete" && c.mode === "live",
7844 );
7845 const localDelete = calls.findIndex(
7846 (c) => c.op === "delete" && c.mode === "local",
7847 );
7848 const create = calls.findIndex(
7849 (c) => c.op === "reconcile" && c.mode === "live",
7850 );
7851 expect(liveDelete).toBeGreaterThanOrEqual(0);
7852 expect(localDelete).toBeGreaterThanOrEqual(0);
7853 // deleteFirst: the whole old chain is reclaimed BEFORE the new
7854 // generation is created.
7855 expect(create).toBeGreaterThan(liveDelete);
7856 expect(create).toBeGreaterThan(localDelete);
7857
7858 const settled = yield* getState("A");
7859 expect(settled?.status).toEqual("created");
7860 expect(settled?.providerMode).toEqual("live");
7861 expect(settled?.instanceId).not.toEqual(newInstanceId);
7862
7863 yield* stack.destroy();
7864 }),
7865 );
7866
7867 test.provider(
7868 "a failed mode-switch create leaves the old runtime's instance intact, then converges on retry",
7869 (stack) =>
7870 Effect.gen(function* () {
7871 yield* inDev(modal("A", { value: "v1" }).pipe(stack.deploy));
7872 const localRow = yield* getState("A");
7873 expect(localRow?.providerMode).toEqual("local");
7874
7875 // The live replacement's create fails. Replacements are
7876 // create-first, so the local generation must survive (it is only
7877 // reclaimed by GC after a successful create — which never runs on a
7878 // failed apply).
7879 const before = callsFor(stack.name).length;
7880 const failCreate = Layer.succeed(TestResourceHooks, {
7881 create: () => Effect.fail(new ResourceFailure()),
7882 });
7883 const exit = yield* modal("A", { value: "v1" }).pipe(
7884 stack.deploy,
7885 Effect.provide(failCreate),
7886 Effect.exit,
7887 );
7888 expect(Exit.isFailure(exit)).toBe(true);
7889
7890 const failedCalls = callsFor(stack.name).slice(before);
7891 expect(
7892 failedCalls.some((c) => c.op === "delete" && c.mode === "local"),
7893 ).toBe(false);
7894 const interrupted = yield* getState("A");
7895 expect(interrupted?.status).toEqual("replacing");
7896 expect(interrupted?.providerMode).toEqual("live");
7897 expect(
7898 (interrupted as ReplacingResourceState).old.providerMode,
7899 ).toEqual("local");
7900
7901 // Retry (same live mode): the interrupted replacement resumes, the
7902 // live create succeeds, and GC finally reclaims the local instance
7903 // with the LOCAL provider.
7904 const beforeRetry = callsFor(stack.name).length;
7905 const retried = yield* modal("A", { value: "v1" }).pipe(stack.deploy);
7906 expect(retried.runtime).toEqual("live");
7907 expect(callsFor(stack.name).slice(beforeRetry)).toContainEqual({
7908 stack: stack.name,
7909 mode: "local",
7910 op: "delete",
7911 id: "A",
7912 });
7913 const settled = yield* getState("A");
7914 expect(settled?.status).toEqual("created");
7915 expect(settled?.providerMode).toEqual("live");
7916
7917 yield* stack.destroy();
7918 }),
7919 );
7920
7921 test.provider(
7922 "GC drains a multi-generation chain with per-generation modes",
7923 (stack) =>
7924 Effect.gen(function* () {
7925 // Two undrained generations with DIFFERENT modes: the outer old is a
7926 // replaced local generation whose own old is a live generation
7927 // (live → local → live churn interrupted twice). GC pops one
7928 // generation per pass — each must be deleted by its own provider.
7929 const inner = "00000000000000000000000000000000";
7930 const middle = "11111111111111111111111111111111";
7931 const top = "22222222222222222222222222222222";
7932 yield* setState("A", {
7933 status: "replaced",
7934 fqn: "A",
7935 logicalId: "A",
7936 namespace: undefined,
7937 instanceId: top,
7938 resourceType: "Test.ModalResource",
7939 providerVersion: 0,
7940 props: { value: "v1" },
7941 attr: { value: "v1", runtime: "live" },
7942 bindings: [],
7943 downstream: [],
7944 deleteFirst: false,
7945 providerMode: "live",
7946 old: {
7947 status: "replaced",
7948 fqn: "A",
7949 logicalId: "A",
7950 namespace: undefined,
7951 instanceId: middle,
7952 resourceType: "Test.ModalResource",
7953 providerVersion: 0,
7954 props: { value: "v1" },
7955 attr: { value: "v1", runtime: "local" },
7956 bindings: [],
7957 downstream: [],
7958 deleteFirst: false,
7959 providerMode: "local",
7960 old: {
7961 status: "created",
7962 fqn: "A",
7963 logicalId: "A",
7964 namespace: undefined,
7965 instanceId: inner,
7966 resourceType: "Test.ModalResource",
7967 providerVersion: 0,
7968 props: { value: "v1" },
7969 attr: { value: "v1", runtime: "live" },
7970 bindings: [],
7971 downstream: [],
7972 providerMode: "live",
7973 },
7974 },
7975 } as ResourceState);
7976
7977 const before = callsFor(stack.name).length;
7978 yield* modal("A", { value: "v1" }).pipe(stack.deploy);
7979
7980 const deletes = callsFor(stack.name)
7981 .slice(before)
7982 .filter((c) => c.op === "delete");
7983 // Outer-in: the middle (local) generation pops first, then the
7984 // inner (live) one — each with its own stamped mode.
7985 expect(deletes.map((c) => c.mode)).toEqual(["local", "live"]);
7986
7987 const settled = yield* getState("A");
7988 expect(settled?.status).toEqual("created");
7989 expect(settled?.providerMode).toEqual("live");
7990 expect(settled?.instanceId).toEqual(top);
7991
7992 yield* stack.destroy();
7993 }),
7994 );
7995
7996 test.provider(
7997 "a mode switch flows through downstream dependents end-to-end",
7998 (stack) =>
7999 Effect.gen(function* () {
8000 const program = Effect.gen(function* () {
8001 const a = yield* ModalResource("A", { value: "v1" });
8002 const b = yield* TestResource("B", { string: a.value });
8003 return { runtime: a.runtime, string: b.string };
8004 });
8005
8006 const dev = yield* inDev(program.pipe(stack.deploy));
8007 expect(dev.runtime).toEqual("local");
8008 expect(dev.string).toEqual("v1");
8009
8010 // Switching A to live replaces it; B (mode-agnostic) re-reconciles
8011 // against the replacement's fresh attrs instead of nooping on stale
8012 // ones, and both settle in a terminal state.
8013 const promoted = yield* program.pipe(stack.deploy);
8014 expect(promoted.runtime).toEqual("live");
8015 expect(promoted.string).toEqual("v1");
8016
8017 const a = yield* getState("A");
8018 expect(a?.status).toEqual("created");
8019 expect(a?.providerMode).toEqual("live");
8020 const b = yield* getState("B");
8021 expect(["created", "updated"]).toContain(b?.status);
8022 expect(b?.providerMode).toBeUndefined();
8023
8024 yield* stack.destroy();
8025 expect(yield* listState()).toEqual([]);
8026 }),
8027 );
8028});
8029
8030describe(
8031 "binding client data-plane routing (apply)",
8032 { tags: ["unit", "local"] },
8033 () => {
8034 // Action bodies invoke Binding.Service clients at apply time. In a
8035 // `dev` run the wrap must route local resources to the emulator plane
8036 // and `Alchemy.remote()` resources to the live plane — the inverse of
8037 // each other, and never ambient.
8038
8039 test.provider(
8040 "dev Action on a local resource hits the emulator plane",
8041 (stack) =>
8042 Effect.gen(function* () {
8043 const out = yield* inDev(
8044 Effect.gen(function* () {
8045 const resource = yield* ModalResource("A", { value: "v1" });
8046 const Probe = Action(
8047 "ProbeLocal",
8048 Effect.gen(function* () {
8049 const read = yield* ProbeBinding(resource);
8050 return () => read();
8051 }),
8052 );
8053 return yield* Probe({});
8054 }).pipe(stack.deploy),
8055 );
8056 expect(out).toBe("local");
8057 }),
8058 );
8059
8060 test.provider(
8061 "dev Action on a remote() resource hits the live plane",
8062 (stack) =>
8063 Effect.gen(function* () {
8064 const out = yield* inDev(
8065 Effect.gen(function* () {
8066 const resource = yield* ModalResource("A", { value: "v1" }).pipe(
8067 remote(),
8068 );
8069 const Probe = Action(
8070 "ProbeRemote",
8071 Effect.gen(function* () {
8072 const read = yield* ProbeBinding(resource);
8073 return () => read();
8074 }),
8075 );
8076 return yield* Probe({});
8077 }).pipe(stack.deploy),
8078 );
8079 expect(out).toBe("live");
8080 }),
8081 );
8082 },
8083);
8084
8085// Apply must honor dependency ORDER and resolve values for references at ANY
8086// nesting depth of plain data — objects in arrays, arrays in objects, arrays
8087// of arrays, whole-resource refs (#1082 hardened the walkers with a
8088// plain-data gate + cycle guards; these pin end-to-end that no nesting shape
8089// lost its edge or its resolution).
8090describe(
8091 "deeply nested dependencies (order + resolution)",
8092 { tags: ["unit", "local"] },
8093 () => {
8094 test.provider(
8095 "creates upstream first and resolves refs at every nesting depth",
8096 (stack) =>
8097 Effect.gen(function* () {
8098 const createOrder: string[] = [];
8099 let bCreateProps: any;
8100 const createHooks = {
8101 create: (id: string, props: any) =>
8102 Effect.sync(() => {
8103 createOrder.push(id);
8104 if (id === "B") bCreateProps = props;
8105 }),
8106 update: () => Effect.succeed(undefined),
8107 delete: () => Effect.succeed(undefined),
8108 read: () => Effect.succeed(undefined),
8109 };
8110
8111 const nestedProps = (a: any) =>
8112 ({
8113 layers: [{ config: { hosts: [{ url: a.string }] } }],
8114 matrix: [[a.string]],
8115 whole: { list: [a] },
8116 mixed: [1, "x", { deep: [a.string] }, null],
8117 }) as any;
8118
8119 yield* Effect.gen(function* () {
8120 const A = yield* TestResource("A", { string: "deep-a" });
8121 const B = yield* TestResource("B", nestedProps(A));
8122 return { A, B };
8123 }).pipe(stack.deploy, hook(createHooks));
8124
8125 // Order: the ONLY references to A are deeply nested — A must still
8126 // be created before B.
8127 expect(createOrder).toEqual(["A", "B"]);
8128
8129 // Resolution: every nested position received the concrete value.
8130 expect(bCreateProps.layers[0].config.hosts[0].url).toBe("deep-a");
8131 expect(bCreateProps.matrix[0][0]).toBe("deep-a");
8132 expect(bCreateProps.mixed[2].deep[0]).toBe("deep-a");
8133 // A whole-resource reference resolves to the upstream's attributes.
8134 expect(bCreateProps.whole.list[0].string).toBe("deep-a");
8135
8136 // Second deploy: the upstream value changes; the change must
8137 // propagate through every nested position, again upstream-first.
8138 const updateOrder: string[] = [];
8139 let bUpdateProps: any;
8140 const updateHooks = {
8141 create: () => Effect.succeed(undefined),
8142 update: (id: string, props: any) =>
8143 Effect.sync(() => {
8144 updateOrder.push(id);
8145 if (id === "B") bUpdateProps = props;
8146 }),
8147 delete: () => Effect.succeed(undefined),
8148 read: () => Effect.succeed(undefined),
8149 };
8150
8151 yield* Effect.gen(function* () {
8152 const A = yield* TestResource("A", { string: "deep-a2" });
8153 const B = yield* TestResource("B", nestedProps(A));
8154 return { A, B };
8155 }).pipe(stack.deploy, hook(updateHooks));
8156
8157 expect(updateOrder).toEqual(["A", "B"]);
8158 expect(bUpdateProps.layers[0].config.hosts[0].url).toBe("deep-a2");
8159 expect(bUpdateProps.matrix[0][0]).toBe("deep-a2");
8160 expect(bUpdateProps.mixed[2].deep[0]).toBe("deep-a2");
8161 expect(bUpdateProps.whole.list[0].string).toBe("deep-a2");
8162
8163 yield* stack.destroy();
8164 expect(yield* listState()).toEqual([]);
8165 }),
8166 );
8167
8168 test.provider(
8169 "a fan-in of deeply nested deps creates ALL upstreams before the dependent",
8170 (stack) =>
8171 Effect.gen(function* () {
8172 const order: string[] = [];
8173 let cProps: any;
8174 const hooks = {
8175 create: (id: string, props: any) =>
8176 Effect.sync(() => {
8177 order.push(id);
8178 if (id === "C") cProps = props;
8179 }),
8180 update: () => Effect.succeed(undefined),
8181 delete: () => Effect.succeed(undefined),
8182 read: () => Effect.succeed(undefined),
8183 };
8184
8185 yield* Effect.gen(function* () {
8186 const A = yield* TestResource("A", { string: "fan-a" });
8187 const B = yield* TestResource("B", { string: "fan-b" });
8188 const C = yield* TestResource("C", {
8189 fromA: { arr: [{ v: A.string }] },
8190 fromB: [[{ v: B.string }]],
8191 } as any);
8192 return { A, B, C };
8193 }).pipe(stack.deploy, hook(hooks));
8194
8195 // A and B may create in either order (they're independent), but C
8196 // must come last.
8197 expect(order).toHaveLength(3);
8198 expect(order[2]).toBe("C");
8199 expect(order.slice(0, 2).sort()).toEqual(["A", "B"]);
8200 expect(cProps.fromA.arr[0].v).toBe("fan-a");
8201 expect(cProps.fromB[0][0].v).toBe("fan-b");
8202
8203 yield* stack.destroy();
8204 expect(yield* listState()).toEqual([]);
8205 }),
8206 );
8207
8208 test.provider(
8209 "a dependency chain through nested containers applies in topological order",
8210 (stack) =>
8211 Effect.gen(function* () {
8212 const order: string[] = [];
8213 const hooks = {
8214 create: (id: string) =>
8215 Effect.sync(() => {
8216 order.push(id);
8217 }),
8218 update: () => Effect.succeed(undefined),
8219 delete: () => Effect.succeed(undefined),
8220 read: () => Effect.succeed(undefined),
8221 };
8222
8223 yield* Effect.gen(function* () {
8224 const A = yield* TestResource("A", { string: "chain-a" });
8225 const B = yield* TestResource("B", {
8226 nested: [{ from: A.string }],
8227 } as any);
8228 const C = yield* TestResource("C", {
8229 nested: { deep: [[B.string]] },
8230 } as any);
8231 return { A, B, C };
8232 }).pipe(stack.deploy, hook(hooks));
8233
8234 expect(order).toEqual(["A", "B", "C"]);
8235
8236 yield* stack.destroy();
8237 expect(yield* listState()).toEqual([]);
8238 }),
8239 );
8240 },
8241);
8242
8243// End-to-end pins for the #1082 leaf rules: class instances in props reach
8244// `reconcile` by identity (prototype intact), are stripped from persisted
8245// state, and never churn a diff; cyclic plain data deploys and re-deploys
8246// without hanging or phantom updates.
8247describe(
8248 "non-plain and cyclic props through deploy",
8249 { tags: ["unit", "local"] },
8250 () => {
8251 test.provider(
8252 "a Date prop reaches reconcile intact and re-deploys without churn",
8253 (stack) =>
8254 Effect.gen(function* () {
8255 const seen: Date[] = [];
8256 const updates: string[] = [];
8257 const hooks = {
8258 create: (_id: string, props: any) =>
8259 Effect.sync(() => {
8260 seen.push(props.expires);
8261 }),
8262 update: (id: string, props: any) =>
8263 Effect.sync(() => {
8264 updates.push(id);
8265 seen.push(props.expires);
8266 }),
8267 delete: () => Effect.succeed(undefined),
8268 read: () => Effect.succeed(undefined),
8269 };
8270
8271 const program = (iso: string) =>
8272 Effect.gen(function* () {
8273 return yield* TestResource("A", {
8274 string: "date-holder",
8275 expires: new Date(iso),
8276 } as any);
8277 });
8278
8279 yield* program("2027-01-01").pipe(stack.deploy, hook(hooks));
8280 // The Date arrives in reconcile as a real Date, not `{}`.
8281 expect(seen[0]).toBeInstanceOf(Date);
8282 expect(seen[0]!.toISOString()).toBe("2027-01-01T00:00:00.000Z");
8283
8284 // And it ROUND-TRIPS: read back out of the (durable, on-disk)
8285 // store, the persisted prop is a real Date again — the DATE_MARKER
8286 // envelope in StateEncoding, not a bare ISO string. This is what
8287 // provider diff/delete/read receive as `olds` on a later run.
8288 const persisted = (yield* getState("A"))?.props as {
8289 expires: Date;
8290 };
8291 expect(persisted.expires).toBeInstanceOf(Date);
8292 expect(persisted.expires.toISOString()).toBe(
8293 "2027-01-01T00:00:00.000Z",
8294 );
8295
8296 // Same date again — no phantom update from Date handling.
8297 yield* program("2027-01-01").pipe(stack.deploy, hook(hooks));
8298 expect(updates).toEqual([]);
8299
8300 // Changed date — must be detected and delivered.
8301 yield* program("2028-06-15").pipe(stack.deploy, hook(hooks));
8302 expect(updates).toEqual(["A"]);
8303 const last = seen[seen.length - 1]!;
8304 expect(last).toBeInstanceOf(Date);
8305 expect(last.toISOString()).toBe("2028-06-15T00:00:00.000Z");
8306
8307 yield* stack.destroy();
8308 expect(yield* listState()).toEqual([]);
8309 }),
8310 );
8311
8312 test.provider(
8313 "a class-instance prop reaches reconcile by identity and never churns",
8314 (stack) =>
8315 Effect.gen(function* () {
8316 class SdkConfig {
8317 constructor(readonly region: string) {}
8318 }
8319 const received: any[] = [];
8320 const updates: string[] = [];
8321 const hooks = {
8322 create: (_id: string, props: any) =>
8323 Effect.sync(() => {
8324 received.push(props.config);
8325 }),
8326 update: (id: string) =>
8327 Effect.sync(() => {
8328 updates.push(id);
8329 }),
8330 delete: () => Effect.succeed(undefined),
8331 read: () => Effect.succeed(undefined),
8332 };
8333
8334 const program = () =>
8335 Effect.gen(function* () {
8336 // A fresh instance every deploy — identity differs run to run.
8337 return yield* TestResource("A", {
8338 string: "sdk-holder",
8339 config: new SdkConfig("us-east-1"),
8340 } as any);
8341 });
8342
8343 yield* program().pipe(stack.deploy, hook(hooks));
8344 // Prototype intact all the way into reconcile.
8345 expect(received[0]).toBeInstanceOf(SdkConfig);
8346 expect(received[0].region).toBe("us-east-1");
8347
8348 // Persisted state holds plain data only — the instance is stripped.
8349 const persisted = yield* getState("A");
8350 expect((persisted?.props as any).config).toBeUndefined();
8351 expect(() => JSON.stringify(persisted?.props)).not.toThrow();
8352
8353 // A fresh (different-identity) instance must not cause an update:
8354 // runtime-only wiring is invisible to the diff.
8355 yield* program().pipe(stack.deploy, hook(hooks));
8356 expect(updates).toEqual([]);
8357
8358 yield* stack.destroy();
8359 expect(yield* listState()).toEqual([]);
8360 }),
8361 );
8362
8363 test.provider(
8364 "cyclic plain props deploy, persist truncated, and re-deploy as noop",
8365 (stack) =>
8366 Effect.gen(function* () {
8367 const updates: string[] = [];
8368 const hooks = {
8369 create: () => Effect.succeed(undefined),
8370 update: (id: string) =>
8371 Effect.sync(() => {
8372 updates.push(id);
8373 }),
8374 delete: () => Effect.succeed(undefined),
8375 read: () => Effect.succeed(undefined),
8376 };
8377
8378 const program = () =>
8379 Effect.gen(function* () {
8380 const cyclic: any = { name: "cfg" };
8381 cyclic.self = cyclic;
8382 const A = yield* TestResource("A", { string: "up" });
8383 const B = yield* TestResource("B", {
8384 config: cyclic,
8385 url: A.string,
8386 } as any);
8387 return { A, B };
8388 });
8389
8390 yield* program().pipe(stack.deploy, hook(hooks));
8391
8392 // Persisted with the cycle cut — still JSON-serializable.
8393 const persisted = yield* getState("B");
8394 expect((persisted?.props as any).config.name).toBe("cfg");
8395 expect((persisted?.props as any).config.self).toBeUndefined();
8396 expect(() => JSON.stringify(persisted?.props)).not.toThrow();
8397
8398 // Identical (still-cyclic) props — a clean noop.
8399 yield* program().pipe(stack.deploy, hook(hooks));
8400 expect(updates).toEqual([]);
8401
8402 yield* stack.destroy();
8403 expect(yield* listState()).toEqual([]);
8404 }),
8405 );
8406 },
8407);
8408
8409describe("renamed resources (renamedFrom)", { tags: ["unit", "local"] }, () => {
8410 const setState = Effect.fn(function* (fqn: string, value: ResourceState) {
8411 const state = yield* yield* State;
8412 const stk = yield* Stack;
8413 yield* state.set({ stack: stk.name, stage: stk.stage, fqn, value });
8414 });
8415
8416 test.provider(
8417 "migrates the state row from a former FQN without recreating the resource",
8418 (stack) =>
8419 Effect.gen(function* () {
8420 yield* stack.deploy(
8421 Effect.gen(function* () {
8422 yield* TestResource("Old", { string: "v1" });
8423 }),
8424 );
8425 const before = yield* getState("Old");
8426 expect(before?.status).toEqual("created");
8427
8428 // Redeploy under the new id: no create, no delete — the state row
8429 // moves and exactly ONE update reconcile runs to re-brand the
8430 // physical resource (its tags still carry the old logical id).
8431 const touched: string[] = [];
8432 const track = (op: string) => (id: string) =>
8433 Effect.sync(() => void touched.push(`${op}:${id}`));
8434 yield* stack
8435 .deploy(
8436 Effect.gen(function* () {
8437 yield* TestResource("New", { string: "v1" }).pipe(
8438 renamedFrom("Old"),
8439 );
8440 }),
8441 )
8442 .pipe(
8443 hook({
8444 create: track("create"),
8445 update: track("update"),
8446 delete: track("delete"),
8447 }),
8448 );
8449 expect(touched).toEqual(["update:New"]);
8450
8451 const after = yield* getState("New");
8452 expect(after?.instanceId).toEqual(before?.instanceId);
8453 expect(after?.logicalId).toEqual("New");
8454 expect(yield* getState("Old")).toBeUndefined();
8455
8456 // A second deploy is a clean noop — the migration is done.
8457 const touchedAgain: string[] = [];
8458 const trackAgain = (op: string) => (id: string) =>
8459 Effect.sync(() => void touchedAgain.push(`${op}:${id}`));
8460 yield* stack
8461 .deploy(
8462 Effect.gen(function* () {
8463 yield* TestResource("New", { string: "v1" }).pipe(
8464 renamedFrom("Old"),
8465 );
8466 }),
8467 )
8468 .pipe(
8469 hook({
8470 create: trackAgain("create"),
8471 update: trackAgain("update"),
8472 delete: trackAgain("delete"),
8473 }),
8474 );
8475 expect(touchedAgain).toEqual([]);
8476
8477 yield* stack.destroy();
8478 expect(yield* listState()).toEqual([]);
8479 }),
8480 );
8481
8482 test.provider(
8483 "the old id can be reused by a new resource without stealing the migrated physical",
8484 (stack) =>
8485 Effect.gen(function* () {
8486 yield* stack.deploy(
8487 Effect.gen(function* () {
8488 yield* TestResource("Old", { string: "v1" });
8489 }),
8490 );
8491 const before = yield* getState("Old");
8492
8493 // One deploy renames Old → New AND declares a brand-new resource
8494 // reusing the id `Old`. The read hook simulates a tag-based
8495 // adoption probe that would FIND the migrated physical resource
8496 // (its cloud tags still say `Old`) — the engine must not consult
8497 // it for the reuser.
8498 const touched: string[] = [];
8499 const track = (op: string) => (id: string) =>
8500 Effect.sync(() => void touched.push(`${op}:${id}`));
8501 yield* stack
8502 .deploy(
8503 Effect.gen(function* () {
8504 yield* TestResource("New", { string: "v1" }).pipe(
8505 renamedFrom("Old"),
8506 );
8507 yield* TestResource("Old", { string: "fresh" });
8508 }),
8509 )
8510 .pipe(
8511 hook({
8512 create: track("create"),
8513 update: track("update"),
8514 delete: track("delete"),
8515 read: () =>
8516 Effect.succeed({ string: "v1", urn: "stolen-physical" }),
8517 }),
8518 );
8519
8520 // The renamed resource kept its identity...
8521 const renamed = yield* getState("New");
8522 expect(renamed?.instanceId).toEqual(before?.instanceId);
8523 // ...and the reuser was created FRESH: new instanceId, a real
8524 // create call, and no adoption of the migrated physical.
8525 const reuser = yield* getState("Old");
8526 expect(reuser?.instanceId).not.toEqual(before?.instanceId);
8527 expect(touched).toContain("create:Old");
8528 expect(touched).toContain("update:New");
8529 expect(touched.filter((t) => t.startsWith("delete"))).toEqual([]);
8530
8531 yield* stack.destroy();
8532 expect(yield* listState()).toEqual([]);
8533 }),
8534 );
8535
8536 test.provider(
8537 "a persisted renamer excludes its former FQN before the fresh create has a checkpoint",
8538 (stack) =>
8539 Effect.gen(function* () {
8540 yield* stack.destroy();
8541 const predecessor = yield* stack.deploy(
8542 TestResource("Old", { string: "original" }),
8543 );
8544 const renamed = TestResource("New", { string: "original" }).pipe(
8545 renamedFrom("Old"),
8546 );
8547 yield* stack.deploy(renamed).pipe(hook(failOn("New", "update")));
8548 expect(yield* getState("Old")).toBeUndefined();
8549 expect((yield* getState("New")).status).toBe("updating");
8550 let reads = 0;
8551 const freshCreated = yield* Deferred.make<void>();
8552 yield* stack
8553 .deploy(
8554 Effect.gen(function* () {
8555 yield* renamed;
8556 const upstream = yield* Function("Upstream", { name: "fresh" });
8557 return yield* TestResource("Old", { string: upstream.name }).pipe(
8558 adopt(true),
8559 );
8560 }),
8561 )
8562 .pipe(
8563 Effect.provideService(TestResourceHooks, {
8564 read: (id) =>
8565 Effect.sync(() => {
8566 if (id !== "Old") return undefined;
8567 reads++;
8568 return predecessor;
8569 }),
8570 update: (id) =>
8571 id === "New" ? Deferred.await(freshCreated) : Effect.void,
8572 create: (id) =>
8573 id === "Old"
8574 ? Deferred.succeed(freshCreated, undefined).pipe(
8575 Effect.asVoid,
8576 )
8577 : Effect.void,
8578 }),
8579 );
8580 expect(reads).toBe(0);
8581 yield* stack.destroy();
8582 }),
8583 { timeout: 10_000 },
8584 );
8585
8586 for (const finish of ["destroy", "gc"] as const) {
8587 test.provider(
8588 `blocked create replacement retains migration exclusion through interruption and ${finish}`,
8589 (stack) =>
8590 Effect.gen(function* () {
8591 yield* stack.destroy();
8592 const predecessor = yield* stack.deploy(
8593 TestResource("Old", { string: "original" }),
8594 );
8595 const app = (replaceString: string) =>
8596 Effect.gen(function* () {
8597 yield* TestResource("New", { string: "original" }).pipe(
8598 renamedFrom("Old"),
8599 RemovalPolicy.retain(),
8600 );
8601 const upstream = yield* TestResource("Upstream", {
8602 string: "fresh",
8603 });
8604 return yield* TestResource("Old", {
8605 string: replaceString === "first" ? upstream.string : "fresh",
8606 replaceString,
8607 }).pipe(adopt(true));
8608 });
8609 const interruptAtCheckpoint = (version: string) =>
8610 Effect.gen(function* () {
8611 const newStarted = yield* Deferred.make<void>();
8612 const upstreamStarted = yield* Deferred.make<void>();
8613 const oldCheckpointed = yield* Deferred.make<void>();
8614 const fiber = yield* stack.deploy(app(version)).pipe(
8615 Effect.provideService(TestResourceHooks, {
8616 update: (id) =>
8617 id === "New"
8618 ? Deferred.succeed(newStarted, undefined).pipe(
8619 Effect.andThen(Effect.never),
8620 )
8621 : Effect.void,
8622 create: (id) =>
8623 id === "Upstream"
8624 ? Deferred.succeed(upstreamStarted, undefined).pipe(
8625 Effect.andThen(Effect.never),
8626 )
8627 : Effect.void,
8628 }),
8629 Effect.provideService(Cli, {
8630 ...recordingCli([]),
8631 startApplySession: () =>
8632 Effect.succeed({
8633 done: () => Effect.void,
8634 emit: (event) =>
8635 event._tag === "apply.resource.status" &&
8636 event.id === "Old" &&
8637 event.status === "pending"
8638 ? Deferred.succeed(oldCheckpointed, undefined).pipe(
8639 Effect.andThen(Effect.never),
8640 )
8641 : Effect.void,
8642 }),
8643 }),
8644 Effect.forkChild,
8645 );
8646 yield* Deferred.await(newStarted);
8647 yield* Deferred.await(upstreamStarted);
8648 yield* Deferred.await(oldCheckpointed);
8649 yield* Fiber.interrupt(fiber);
8650 });
8651 yield* interruptAtCheckpoint("first");
8652 const creating = yield* getState("Old");
8653 expect(creating.status).toBe("creating");
8654 expect(creating.adoptionBlocked).toBe("migrated-fqn");
8655 yield* interruptAtCheckpoint("second");
8656 const replacing = yield* getState("Old");
8657 expect(replacing.status).toBe("replacing");
8658 expect(replacing.attr).toBeUndefined();
8659 expect(replacing.instanceId).not.toBe(creating.instanceId);
8660 const reads: string[] = [];
8661 const deleted: string[] = [];
8662 const hooks = TestResourceHooks.of({
8663 read: (id) =>
8664 Effect.sync(() => {
8665 if (id !== "Old") return undefined;
8666 reads.push(id);
8667 return predecessor;
8668 }),
8669 delete: (id) =>
8670 Effect.sync(() => {
8671 deleted.push(id);
8672 }),
8673 });
8674 if (finish === "gc") {
8675 yield* stack
8676 .deploy(app("second"))
8677 .pipe(Effect.provideService(TestResourceHooks, hooks));
8678 const completed = yield* getState("Old");
8679 expect(completed.status).toBe("created");
8680 expect(completed.adoptionBlocked).toBe("migrated-fqn");
8681 expect(reads).toEqual([]);
8682 expect(deleted).toEqual([]);
8683 const next = yield* stack.plan(app("third"));
8684 expect(next.resources.Old?.action).toBe("replace");
8685 expect(next.resources.Old?.adoptionBlocked).toBe("migrated-fqn");
8686 }
8687 yield* stack
8688 .destroy()
8689 .pipe(Effect.provideService(TestResourceHooks, hooks));
8690 expect(reads).toEqual([]);
8691 expect(deleted.sort()).toEqual(
8692 finish === "gc" ? ["Old", "Upstream"] : [],
8693 );
8694 expect(replacing.adoptionBlocked).toBe("migrated-fqn");
8695 expect(yield* listState()).toEqual([]);
8696 }),
8697 { timeout: 10_000 },
8698 );
8699 }
8700
8701 for (const recovery of ["plan", "deferred", "destroy"] as const) {
8702 test.provider(
8703 `interrupted migrated FQN reuse suppresses ${recovery} recovery of the predecessor`,
8704 (stack) =>
8705 Effect.gen(function* () {
8706 yield* stack.destroy();
8707 const predecessorAttrs = yield* stack.deploy(
8708 TestResource("Old", { string: "original" }),
8709 );
8710 const predecessor = yield* getState("Old");
8711 const newStarted = yield* Deferred.make<void>();
8712 const upstreamStarted = yield* Deferred.make<void>();
8713 const oldCheckpointed = yield* Deferred.make<void>();
8714 let renaming = true;
8715 const app = Effect.gen(function* () {
8716 const target = TestResource("New", { string: "original" });
8717 yield* renaming ? target.pipe(renamedFrom("Old")) : target;
8718 const upstream = yield* TestResource("Upstream", {
8719 string: "fresh",
8720 });
8721 return yield* TestResource("Old", { string: upstream.string }).pipe(
8722 adopt(true),
8723 );
8724 });
8725 const fiber = yield* stack.deploy(app).pipe(
8726 Effect.provideService(TestResourceHooks, {
8727 update: (id) =>
8728 id === "New"
8729 ? Deferred.succeed(newStarted, undefined).pipe(
8730 Effect.andThen(Effect.never),
8731 )
8732 : Effect.void,
8733 create: (id) =>
8734 id === "Upstream"
8735 ? Deferred.succeed(upstreamStarted, undefined).pipe(
8736 Effect.andThen(Effect.never),
8737 )
8738 : Effect.void,
8739 }),
8740 Effect.provideService(Cli, {
8741 ...recordingCli([]),
8742 startApplySession: () =>
8743 Effect.succeed({
8744 done: () => Effect.void,
8745 emit: (event) =>
8746 event._tag === "apply.resource.status" &&
8747 event.id === "Old" &&
8748 event.status === "pending"
8749 ? Deferred.succeed(oldCheckpointed, undefined).pipe(
8750 Effect.asVoid,
8751 )
8752 : Effect.void,
8753 }),
8754 }),
8755 Effect.forkChild,
8756 );
8757 yield* Deferred.await(newStarted);
8758 yield* Deferred.await(upstreamStarted);
8759 yield* Deferred.await(oldCheckpointed);
8760 yield* Fiber.interrupt(fiber);
8761 expect((yield* getState("New")).instanceId).toBe(
8762 predecessor.instanceId,
8763 );
8764 const fresh = yield* getState("Old");
8765 expect(fresh.status).toBe("creating");
8766 expect(fresh.attr).toBeUndefined();
8767 expect(fresh.props?.string).toBeUndefined();
8768 expect(fresh.instanceId).not.toBe(predecessor.instanceId);
8769 expect(fresh.adoptionBlocked).toBe("migrated-fqn");
8770 renaming = false;
8771 let reads = 0;
8772 if (recovery === "destroy") {
8773 const deleted: string[] = [];
8774 yield* stack.destroy().pipe(
8775 Effect.provideService(TestResourceHooks, {
8776 read: (id) =>
8777 Effect.sync(() => {
8778 if (id !== "Old") return undefined;
8779 reads++;
8780 return predecessorAttrs;
8781 }),
8782 delete: (id) =>
8783 Effect.sync(() => {
8784 deleted.push(id);
8785 }),
8786 }),
8787 );
8788 expect(reads).toBe(0);
8789 expect(deleted).toEqual(["New"]);
8790 return;
8791 }
8792 const freshCreated = yield* Deferred.make<void>();
8793 const createAttrs: Array<unknown> = [];
8794 const store = yield* yield* State;
8795 yield* stack.deploy(app).pipe(
8796 Effect.provideService(TestResourceHooks, {
8797 read: (id) =>
8798 Effect.sync(() => {
8799 if (id !== "Old") return undefined;
8800 reads++;
8801 return recovery === "plan" || reads > 1
8802 ? predecessorAttrs
8803 : undefined;
8804 }),
8805 create: (id) =>
8806 id === "Old"
8807 ? Effect.gen(function* () {
8808 const current = yield* store.get({
8809 stack: stack.name,
8810 stage: stack.stage,
8811 fqn: "Old",
8812 });
8813 assert(
8814 current !== undefined && current.kind !== "action",
8815 );
8816 createAttrs.push(current.attr);
8817 yield* Deferred.succeed(freshCreated, undefined);
8818 })
8819 : Effect.void,
8820 update: (id) =>
8821 id === "New" ? Deferred.await(freshCreated) : Effect.void,
8822 }),
8823 );
8824 expect(reads).toBe(0);
8825 expect(createAttrs).toEqual([undefined]);
8826 expect((yield* getState("New")).instanceId).toBe(
8827 predecessor.instanceId,
8828 );
8829 expect((yield* getState("Old")).instanceId).toBe(fresh.instanceId);
8830 yield* stack.destroy();
8831 }),
8832 { timeout: 10_000 },
8833 );
8834 }
8835
8836 test.provider(
8837 "a reused migrated FQN skips deferred adoption even with unresolved upstream props",
8838 (stack) =>
8839 Effect.gen(function* () {
8840 yield* stack.destroy();
8841 yield* stack.deploy(TestResource("Old", { string: "original" }));
8842 const previous = yield* getState("Old");
8843 const reads: string[] = [];
8844 yield* stack
8845 .deploy(
8846 Effect.gen(function* () {
8847 yield* TestResource("New", { string: "original" }).pipe(
8848 renamedFrom("Old"),
8849 );
8850 const upstream = yield* Function("Upstream", { name: "fresh" });
8851 return yield* TestResource("Old", { string: upstream.name }).pipe(
8852 adopt(true),
8853 );
8854 }),
8855 )
8856 .pipe(
8857 Effect.provideService(TestResourceHooks, {
8858 read: (id) =>
8859 Effect.sync(() => {
8860 reads.push(id);
8861 return undefined;
8862 }),
8863 }),
8864 );
8865 expect(reads).toEqual([]);
8866 expect((yield* getState("New")).instanceId).toBe(previous.instanceId);
8867 expect((yield* getState("Old")).instanceId).not.toBe(
8868 previous.instanceId,
8869 );
8870 yield* stack.destroy();
8871 }),
8872 );
8873
8874 test.provider(
8875 "migrates a namespaced row (StaticSite's <id>/Worker → <id> shape)",
8876 (stack) =>
8877 Effect.gen(function* () {
8878 // The pre-rename shape: `Worker` declared under the `App/Site`
8879 // namespace chain (fqn `App/Site/Worker`).
8880 yield* stack.deploy(
8881 Effect.gen(function* () {
8882 yield* Effect.gen(function* () {
8883 yield* TestResource("Worker", { string: "v1" });
8884 }).pipe(Namespace.push("Site"), Namespace.push("App"));
8885 }),
8886 );
8887 const before = yield* getState("App/Site/Worker");
8888 expect(before?.status).toEqual("created");
8889
8890 // The post-rename shape: the resource is `Site` itself, still under
8891 // `App`, claiming its former namespace-RELATIVE id — exactly what
8892 // StaticSite does with `renamedFrom(`${id}/Worker`)`.
8893 const touched: string[] = [];
8894 const track = (op: string) => (id: string) =>
8895 Effect.sync(() => void touched.push(`${op}:${id}`));
8896 yield* stack
8897 .deploy(
8898 Effect.gen(function* () {
8899 yield* TestResource("Site", { string: "v1" }).pipe(
8900 renamedFrom("Site/Worker"),
8901 Namespace.push("App"),
8902 );
8903 }),
8904 )
8905 .pipe(
8906 hook({
8907 create: track("create"),
8908 update: track("update"),
8909 delete: track("delete"),
8910 }),
8911 );
8912 expect(touched).toEqual(["update:Site"]);
8913
8914 const after = yield* getState("App/Site");
8915 expect(after?.instanceId).toEqual(before?.instanceId);
8916 expect(after?.logicalId).toEqual("Site");
8917 expect(yield* getState("App/Site/Worker")).toBeUndefined();
8918
8919 yield* stack.destroy();
8920 expect(yield* listState()).toEqual([]);
8921 }),
8922 );
8923
8924 test.provider(
8925 "finishes an interrupted migration state-only (rows at both FQNs, same instanceId)",
8926 (stack) =>
8927 Effect.gen(function* () {
8928 yield* stack.deploy(
8929 Effect.gen(function* () {
8930 yield* TestResource("New", { string: "v1" }).pipe(
8931 renamedFrom("Old"),
8932 );
8933 }),
8934 );
8935 const row = yield* getState("New");
8936
8937 // Simulate a crash between apply's `state.set` (new FQN) and
8938 // `state.delete` (former FQN): a stale copy remains at `Old`.
8939 yield* setState("Old", { ...row!, fqn: "Old", logicalId: "Old" });
8940
8941 const deleted: string[] = [];
8942 yield* stack
8943 .deploy(
8944 Effect.gen(function* () {
8945 yield* TestResource("New", { string: "v1" }).pipe(
8946 renamedFrom("Old"),
8947 );
8948 }),
8949 )
8950 .pipe(
8951 hook({
8952 delete: (id: string) => Effect.sync(() => void deleted.push(id)),
8953 }),
8954 );
8955
8956 // The leftover row was dropped WITHOUT a provider.delete.
8957 expect(deleted).toEqual([]);
8958 expect(yield* getState("Old")).toBeUndefined();
8959 expect((yield* getState("New"))?.instanceId).toEqual(row?.instanceId);
8960
8961 yield* stack.destroy();
8962 expect(yield* listState()).toEqual([]);
8963 }),
8964 );
8965
8966 test.provider(
8967 "a rename chain with repeated partial failures converges in one deploy",
8968 (stack) =>
8969 Effect.gen(function* () {
8970 // A → B rename, then B → C, with the A→B migration's delete having
8971 // failed (a leftover copy of the row remains at A).
8972 yield* stack.deploy(
8973 Effect.gen(function* () {
8974 yield* TestResource("A", { string: "v1" });
8975 }),
8976 );
8977 yield* stack.deploy(
8978 Effect.gen(function* () {
8979 yield* TestResource("B", { string: "v1" }).pipe(renamedFrom("A"));
8980 }),
8981 );
8982 const row = yield* getState("B");
8983 yield* setState("A", { ...row!, fqn: "A", logicalId: "A" });
8984
8985 const touched: string[] = [];
8986 const track = (op: string) => (id: string) =>
8987 Effect.sync(() => void touched.push(`${op}:${id}`));
8988 yield* stack
8989 .deploy(
8990 Effect.gen(function* () {
8991 // Most recent former id first.
8992 yield* TestResource("C", { string: "v1" }).pipe(
8993 renamedFrom("B", "A"),
8994 );
8995 }),
8996 )
8997 .pipe(
8998 hook({
8999 create: track("create"),
9000 update: track("update"),
9001 delete: track("delete"),
9002 }),
9003 );
9004
9005 // One deploy: migrated from B AND dropped the stale copy at A —
9006 // one re-branding update, no create, no delete.
9007 expect(touched).toEqual(["update:C"]);
9008 expect((yield* getState("C"))?.instanceId).toEqual(row?.instanceId);
9009 expect(yield* getState("B")).toBeUndefined();
9010 expect(yield* getState("A")).toBeUndefined();
9011
9012 yield* stack.destroy();
9013 expect(yield* listState()).toEqual([]);
9014 }),
9015 );
9016
9017 test.provider(
9018 "a same-deploy rename shift (A→B while B→C) moves both rows end-to-end",
9019 (stack) =>
9020 Effect.gen(function* () {
9021 yield* stack.deploy(
9022 Effect.gen(function* () {
9023 yield* TestResource("A", { string: "a" });
9024 yield* TestResource("B", { string: "b" });
9025 }),
9026 );
9027 const oldA = yield* getState("A");
9028 const oldB = yield* getState("B");
9029
9030 // One deploy shifts both names: A→B and B→C. B's migration write
9031 // and C's former-row cleanup target the same FQN — the apply-side
9032 // discipline must never let C's delete destroy B's migrated row.
9033 const touched: string[] = [];
9034 const track = (op: string) => (id: string) =>
9035 Effect.sync(() => void touched.push(`${op}:${id}`));
9036 yield* stack
9037 .deploy(
9038 Effect.gen(function* () {
9039 yield* TestResource("B", { string: "a" }).pipe(renamedFrom("A"));
9040 yield* TestResource("C", { string: "b" }).pipe(renamedFrom("B"));
9041 }),
9042 )
9043 .pipe(
9044 hook({
9045 create: track("create"),
9046 delete: track("delete"),
9047 }),
9048 );
9049
9050 // No physical resource was created or deleted — both rows moved.
9051 expect(touched).toEqual([]);
9052 expect((yield* getState("B"))?.instanceId).toEqual(oldA?.instanceId);
9053 expect((yield* getState("C"))?.instanceId).toEqual(oldB?.instanceId);
9054 expect(yield* getState("A")).toBeUndefined();
9055
9056 yield* stack.destroy();
9057 expect(yield* listState()).toEqual([]);
9058 }),
9059 );
9060
9061 test.provider(
9062 "a pending replacement backlog drains after a rename",
9063 (stack) =>
9064 Effect.gen(function* () {
9065 yield* stack.deploy(
9066 Effect.gen(function* () {
9067 yield* TestResource("A", { string: "x", replaceString: "1" });
9068 }),
9069 );
9070
9071 // A replacement whose old-generation delete FAILS: the new
9072 // generation is live but the old one stays queued in the row's
9073 // `old` chain (status `replaced`). The failed cleanup is soft at
9074 // deploy time — the deploy may still report success.
9075 yield* stack
9076 .deploy(
9077 Effect.gen(function* () {
9078 yield* TestResource("A", { string: "x", replaceString: "2" });
9079 }),
9080 )
9081 .pipe(
9082 hook({
9083 delete: () => Effect.fail(new ResourceFailure()),
9084 }),
9085 Effect.exit,
9086 );
9087 const mid = yield* getState("A");
9088 expect(mid?.status).toEqual("replaced");
9089 expect((mid as any).old).toBeDefined();
9090
9091 // Rename while the backlog is pending: the chain must ride the
9092 // migration and STILL drain — the old generation's physical
9093 // resource is deleted during the rename deploy.
9094 const deleted: string[] = [];
9095 yield* stack
9096 .deploy(
9097 Effect.gen(function* () {
9098 yield* TestResource("B", {
9099 string: "x",
9100 replaceString: "2",
9101 }).pipe(renamedFrom("A"));
9102 }),
9103 )
9104 .pipe(
9105 hook({
9106 delete: (id: string) => Effect.sync(() => void deleted.push(id)),
9107 }),
9108 );
9109
9110 // Exactly one physical delete: the queued old generation.
9111 expect(deleted).toHaveLength(1);
9112 const after = yield* getState("B");
9113 // The new generation survived under the new identity, chain drained.
9114 expect(after?.instanceId).toEqual(mid?.instanceId);
9115 expect(["created", "updated"]).toContain(after?.status);
9116 expect((after as any).old).toBeUndefined();
9117 expect(yield* getState("A")).toBeUndefined();
9118
9119 yield* stack.destroy();
9120 expect(yield* listState()).toEqual([]);
9121 }),
9122 );
9123
9124 test.provider(
9125 "ignores the alias when the new FQN row exists with a different instanceId",
9126 (stack) =>
9127 Effect.gen(function* () {
9128 // Both resources exist independently.
9129 yield* stack.deploy(
9130 Effect.gen(function* () {
9131 yield* TestResource("Old", { string: "old" });
9132 yield* TestResource("New", { string: "new" });
9133 }),
9134 );
9135 const oldRow = yield* getState("Old");
9136 const newRow = yield* getState("New");
9137 expect(oldRow?.instanceId).not.toEqual(newRow?.instanceId);
9138
9139 // `New` claims `Old` as a former FQN, but already has its own row —
9140 // the alias is ignored and `Old` is a normal orphan delete (the
9141 // physical resource IS deleted).
9142 const deleted: string[] = [];
9143 yield* stack
9144 .deploy(
9145 Effect.gen(function* () {
9146 yield* TestResource("New", { string: "new" }).pipe(
9147 renamedFrom("Old"),
9148 );
9149 }),
9150 )
9151 .pipe(
9152 hook({
9153 delete: (id: string) => Effect.sync(() => void deleted.push(id)),
9154 }),
9155 );
9156
9157 expect(deleted).toEqual(["Old"]);
9158 expect(yield* getState("Old")).toBeUndefined();
9159 expect((yield* getState("New"))?.instanceId).toEqual(
9160 newRow?.instanceId,
9161 );
9162
9163 yield* stack.destroy();
9164 expect(yield* listState()).toEqual([]);
9165 }),
9166 );
9167});
9168
9169describe("filtered reconciliation", { tags: ["unit", "local"] }, () => {
9170 for (const selection of [
9171 { include: ["Branch"] },
9172 { exclude: ["Worker", "NewUnselected", "SkippedAction", "Undeclared"] },
9173 {
9174 include: ["**"],
9175 exclude: ["Worker", "NewUnselected", "SkippedAction", "Undeclared"],
9176 },
9177 ]) {
9178 test.provider(
9179 `preserves unselected declared and undeclared rows and full-stack outputs ${JSON.stringify(selection)}`,
9180 (stack) =>
9181 Effect.gen(function* () {
9182 yield* stack.destroy();
9183 const Compute = Action("SkippedAction", (_: { value: string }) =>
9184 Effect.succeed({ value: "old" }),
9185 );
9186 yield* stack.deploy(
9187 Effect.gen(function* () {
9188 yield* TestResource("Branch", { string: "v1" });
9189 yield* TestResource("Worker", { string: "old" });
9190 yield* TestResource("Undeclared", {});
9191 yield* Compute({ value: "old" });
9192 return { full: "original" };
9193 }),
9194 );
9195 const worker = yield* getState("Worker");
9196 const undeclared = yield* getState("Undeclared");
9197 const skipped = yield* getState("SkippedAction");
9198 const calls: string[] = [];
9199 const result = yield* stack
9200 .deploy(
9201 Effect.gen(function* () {
9202 const branch = yield* TestResource("Branch", { string: "v2" });
9203 yield* TestResource("Worker", { string: "changed" });
9204 yield* TestResource("NewUnselected", {});
9205 const Fail = Action("SkippedAction", (_: { value: string }) =>
9206 Effect.die("unselected action ran"),
9207 );
9208 yield* Fail({ value: "changed" });
9209 return Output.map(branch.string, () => {
9210 throw new Error("filtered output evaluated");
9211 });
9212 }),
9213 selection,
9214 )
9215 .pipe(
9216 Effect.provideService(TestResourceHooks, {
9217 update: (id) =>
9218 Effect.sync(() => {
9219 calls.push(id);
9220 }),
9221 create: () => Effect.die("unselected create"),
9222 delete: () => Effect.die("unselected delete"),
9223 }),
9224 );
9225 expect(result).toBeUndefined();
9226 expect(calls).toEqual(["Branch"]);
9227 expect(yield* getState("Worker")).toEqual(worker);
9228 expect(yield* getState("Undeclared")).toEqual(undeclared);
9229 expect(yield* getState("SkippedAction")).toEqual(skipped);
9230 expect(yield* getState("NewUnselected")).toBeUndefined();
9231 const state = yield* yield* State;
9232 expect(
9233 yield* state.getOutput({ stack: stack.name, stage: stack.stage }),
9234 ).toEqual({ full: "original" });
9235 yield* stack.destroy();
9236 }),
9237 );
9238 }
9239
9240 test.provider(
9241 "applies transitive bindings and captured Actions while retaining pure reuse",
9242 (stack) =>
9243 Effect.gen(function* () {
9244 yield* stack.destroy();
9245 let runs = 0;
9246 const program = Effect.gen(function* () {
9247 const source = yield* TestResource("Source", { string: "input" });
9248 const captured = yield* TestResource("Captured", {
9249 string: "capture",
9250 });
9251 const Compute = Action(
9252 "Compute",
9253 Effect.gen(function* () {
9254 const value = yield* captured.string;
9255 return (input: { value: string }) =>
9256 Effect.gen(function* () {
9257 runs++;
9258 return { value: `${input.value}:${yield* value}` };
9259 });
9260 }),
9261 );
9262 const result = yield* Compute({ value: source.string });
9263 const host = yield* BindingTarget("Host", {});
9264 yield* host.bind("Compute", { env: { RESULT: result.value } });
9265 yield* TestResource("Other", {});
9266 return host.env.RESULT;
9267 });
9268 yield* stack.deploy(program, { include: ["Host"] });
9269 const host = yield* getState("Host");
9270 assert(host.attr !== undefined);
9271 expect(host.attr.env).toEqual({
9272 RESULT: "input:capture",
9273 });
9274 expect(yield* getState("Other")).toBeUndefined();
9275 yield* stack.deploy(program, { include: ["Host"] });
9276 expect(runs).toBe(1);
9277 expect(yield* stack.deploy(program)).toBe("input:capture");
9278 expect(runs).toBe(1);
9279 yield* stack.destroy();
9280 }),
9281 );
9282
9283 test.provider(
9284 "preserves prior downstream edges owned by unselected resources and Actions",
9285 (stack) =>
9286 Effect.gen(function* () {
9287 yield* stack.destroy();
9288 const Compute = Action("Compute", (input: { value: string }) =>
9289 Effect.succeed({ value: input.value }),
9290 );
9291 yield* stack.deploy(
9292 Effect.gen(function* () {
9293 const a = yield* TestResource("A", {});
9294 const result = yield* Compute({ value: a.string });
9295 yield* TestResource("B", { string: result.value });
9296 }),
9297 );
9298 const b = yield* getState("B");
9299 const plan = yield* stack.plan(
9300 Effect.gen(function* () {
9301 const a = yield* TestResource("A", {});
9302 yield* Compute({ value: a.string });
9303 }),
9304 { include: ["Compute"] },
9305 );
9306 expect(plan.resources.A.downstream).toEqual(["Compute"]);
9307 expect(plan.actions.Compute.downstream).toEqual(["B"]);
9308 yield* apply(plan);
9309 expect((yield* getState("A")).downstream).toEqual(["Compute"]);
9310 expect((yield* getState("Compute")).downstream).toEqual(["B"]);
9311 expect(yield* getState("B")).toEqual(b);
9312 yield* stack.deploy(
9313 Effect.gen(function* () {
9314 const a = yield* TestResource("A", {});
9315 yield* TestResource("B", { string: a.string });
9316 }),
9317 );
9318 expect((yield* getState("A")).downstream).toEqual(["B"]);
9319 yield* stack.deploy(TestResource("A", {}), { include: ["A"] });
9320 expect((yield* getState("A")).downstream).toEqual(["B"]);
9321 const deleted: string[] = [];
9322 yield* stack.destroy().pipe(
9323 Effect.provideService(TestResourceHooks, {
9324 delete: (id) =>
9325 Effect.sync(() => {
9326 deleted.push(id);
9327 }),
9328 }),
9329 );
9330 expect(deleted).toEqual(["B", "A"]);
9331 }),
9332 );
9333
9334 test.provider("does not switch unselected provider modes", (stack) =>
9335 Effect.gen(function* () {
9336 yield* stack.destroy();
9337 const program = Effect.gen(function* () {
9338 yield* ModalResource("Worker", { value: "same" });
9339 yield* TestResource("Branch", {});
9340 });
9341 yield* stack.deploy(program);
9342 const worker = yield* getState("Worker");
9343 const before = modalCalls.filter(
9344 (call) => call.stack === stack.name,
9345 ).length;
9346 yield* inDev(stack.deploy(program, { include: ["Branch"] }));
9347 expect(yield* getState("Worker")).toEqual(worker);
9348 expect(
9349 modalCalls.filter((call) => call.stack === stack.name).length,
9350 ).toBe(before);
9351 yield* inDev(stack.deploy(program));
9352 expect((yield* getState("Worker")).providerMode).toBe("local");
9353 yield* stack.destroy();
9354 }),
9355 );
9356
9357 test.provider(
9358 "rejects selected renames without moving persisted identities",
9359 (stack) =>
9360 Effect.gen(function* () {
9361 yield* stack.destroy();
9362 yield* stack.deploy(TestResource("Old", {}));
9363 const old = yield* getState("Old");
9364 const exit = yield* stack
9365 .deploy(TestResource("New", {}).pipe(renamedFrom("Old")), {
9366 include: ["New"],
9367 })
9368 .pipe(Effect.exit);
9369 expect(Exit.isFailure(exit)).toBe(true);
9370 if (Exit.isFailure(exit))
9371 expect(Cause.pretty(exit.cause)).toContain("UnsafeSelectionBoundary");
9372 expect(yield* getState("Old")).toEqual(old);
9373 expect(yield* getState("New")).toBeUndefined();
9374 yield* stack.destroy();
9375 }),
9376 );
9377
9378 test.provider(
9379 "rejects replacement across an unselected dependent boundary, then permits a closed replacement",
9380 (stack) =>
9381 Effect.gen(function* () {
9382 yield* stack.destroy();
9383 const program = (revision: string) =>
9384 Effect.gen(function* () {
9385 const a = yield* TestResource("A", { replaceString: revision });
9386 yield* TestResource("B", { string: a.string });
9387 });
9388 yield* stack.deploy(program("1"));
9389 const a = yield* getState("A");
9390 const b = yield* getState("B");
9391 const exit = yield* stack
9392 .deploy(program("2"), { include: ["A"] })
9393 .pipe(Effect.exit);
9394 expect(Exit.isFailure(exit)).toBe(true);
9395 if (Exit.isFailure(exit))
9396 expect(Cause.pretty(exit.cause)).toContain("UnsafeSelectionBoundary");
9397 expect(yield* getState("A")).toEqual(a);
9398 expect(yield* getState("B")).toEqual(b);
9399 yield* stack.deploy(program("2"), { include: ["B"] });
9400 expect((yield* getState("A")).instanceId).not.toBe(a.instanceId);
9401 yield* stack.destroy();
9402 }),
9403 );
9404
9405 test.provider(
9406 "rejects replacement when removed historical binding-cycle edges cross the selection",
9407 (stack) =>
9408 Effect.gen(function* () {
9409 yield* stack.destroy();
9410 yield* stack.deploy(
9411 Effect.gen(function* () {
9412 const a = yield* BindingTarget("A", { replaceString: "1" });
9413 const b = yield* BindingTarget("B", {});
9414 yield* a.bind("B", { env: { B: b.name } });
9415 yield* b.bind("A", { env: { A: a.name } });
9416 }),
9417 );
9418 const a = yield* getState("A");
9419 const b = yield* getState("B");
9420 expect(a.downstream).toEqual([]);
9421 expect(b.downstream).toEqual([]);
9422 const exit = yield* stack
9423 .deploy(
9424 Effect.gen(function* () {
9425 yield* BindingTarget("A", { replaceString: "2" });
9426 yield* BindingTarget("B", {});
9427 }),
9428 { include: ["A"] },
9429 )
9430 .pipe(Effect.exit);
9431 expect(Exit.isFailure(exit)).toBe(true);
9432 if (Exit.isFailure(exit))
9433 expect(Cause.pretty(exit.cause)).toContain(
9434 "Historical binding-cycle",
9435 );
9436 expect(yield* getState("A")).toEqual(a);
9437 expect(yield* getState("B")).toEqual(b);
9438 yield* stack.destroy();
9439 }),
9440 );
9441
9442 test.provider(
9443 "rejects resuming selected GC when an old generation still has unselected dependents",
9444 (stack) =>
9445 Effect.gen(function* () {
9446 yield* stack.destroy();
9447 const program = (revision: string) =>
9448 Effect.gen(function* () {
9449 const a = yield* TestResource("A", { replaceString: revision });
9450 yield* TestResource("B", { string: a.string });
9451 });
9452 yield* stack.deploy(program("1"));
9453 yield* stack.deploy(program("2")).pipe(
9454 Effect.provideService(TestResourceHooks, {
9455 delete: () => Effect.fail(new ResourceFailure()),
9456 }),
9457 Effect.exit,
9458 );
9459 const before = yield* getState("A");
9460 expect(before.status).toBe("replaced");
9461 const exit = yield* stack
9462 .deploy(TestResource("A", { replaceString: "2" }), { include: ["A"] })
9463 .pipe(Effect.exit);
9464 expect(Exit.isFailure(exit)).toBe(true);
9465 if (Exit.isFailure(exit))
9466 expect(Cause.pretty(exit.cause)).toContain("UnsafeSelectionBoundary");
9467 expect(yield* getState("A")).toEqual(before);
9468 yield* stack.deploy(program("2"));
9469 expectConvergedStatus((yield* getState("A")).status);
9470 yield* stack.destroy();
9471 }),
9472 );
9473
9474 test.provider(
9475 "does not let an unselected rename claim a selected identity",
9476 (stack) =>
9477 Effect.gen(function* () {
9478 yield* stack.destroy();
9479 yield* stack.deploy(TestResource("Old", { string: "original" }));
9480 const before = yield* getState("Old");
9481 const exit = yield* stack
9482 .deploy(
9483 Effect.gen(function* () {
9484 yield* TestResource("Old", { string: "reused" });
9485 yield* TestResource("New", {}).pipe(renamedFrom("Old"));
9486 }),
9487 { include: ["Old"] },
9488 )
9489 .pipe(Effect.exit);
9490 expect(Exit.isFailure(exit)).toBe(true);
9491 expect(yield* getState("Old")).toEqual(before);
9492 expect(yield* getState("New")).toBeUndefined();
9493 yield* stack.destroy();
9494 }),
9495 );
9496
9497 test.provider(
9498 "does not collect unselected interrupted replacement generations",
9499 (stack) =>
9500 Effect.gen(function* () {
9501 yield* stack.destroy();
9502 const program = (revision: string) =>
9503 Effect.gen(function* () {
9504 yield* TestResource("Old", { replaceString: revision });
9505 yield* TestResource("Branch", {});
9506 });
9507 yield* stack.deploy(program("1"));
9508 const interrupted = yield* stack.deploy(program("2")).pipe(
9509 Effect.provideService(TestResourceHooks, {
9510 delete: () => Effect.fail(new ResourceFailure()),
9511 }),
9512 Effect.exit,
9513 );
9514 expect(Exit.isFailure(interrupted)).toBe(true);
9515 const before = yield* getState("Old");
9516 expect(before.status).toBe("replaced");
9517 yield* stack
9518 .deploy(TestResource("Branch", {}), { include: ["Branch"] })
9519 .pipe(
9520 Effect.provideService(TestResourceHooks, {
9521 delete: () => Effect.die("unselected GC"),
9522 }),
9523 );
9524 expect(yield* getState("Old")).toEqual(before);
9525 yield* stack.deploy(program("2"));
9526 expectConvergedStatus((yield* getState("Old")).status);
9527 yield* stack.destroy();
9528 }),
9529 );
9530});
9531
9532describe("filtered audit safeguards", { tags: ["unit", "local"] }, () => {
9533 test.provider(
9534 "rejects an unpersisted keeper before its first checkpoint can fail",
9535 (stack) =>
9536 Effect.gen(function* () {
9537 yield* stack.destroy();
9538 const program = (clear: boolean, revision = "1") =>
9539 Effect.gen(function* () {
9540 const a = yield* BindingTarget("A", {
9541 string: `generation-${revision}`,
9542 replaceString: revision,
9543 });
9544 const middle = yield* BindingTarget("Middle", {});
9545 if (!clear) {
9546 yield* a.bind("Middle", { env: { MIDDLE: middle.name } });
9547 yield* middle.bind("A", { env: { SOURCE: a.string } });
9548 } else {
9549 const keeper = yield* BindingTarget("Keeper", {});
9550 yield* keeper.bind("constant", {
9551 env: { VALUE: "new evidence" },
9552 });
9553 }
9554 yield* TestResource("B", {
9555 string: clear
9556 ? "detached"
9557 : middle.env.pipe(Output.map((env) => env.SOURCE)),
9558 });
9559 });
9560 yield* stack.deploy(program(false));
9561 const rows = Effect.all([
9562 getState("A"),
9563 getState("Middle"),
9564 getState("B"),
9565 ]);
9566 const before = yield* rows;
9567 const bytes = yield* Effect.sync(() => JSON.stringify(before));
9568 const state = yield* yield* State;
9569 const writes: string[] = [];
9570 const calls: string[] = [];
9571 const track = (id: string) =>
9572 Effect.sync(() => {
9573 calls.push(id);
9574 });
9575 const result = yield* Effect.gen(function* () {
9576 const plan = yield* stack.plan(program(true), {
9577 include: ["A", "Middle", "Keeper"],
9578 });
9579 yield* apply(plan);
9580 }).pipe(
9581 Effect.provideService(
9582 State,
9583 Effect.succeed({
9584 ...state,
9585 set: (request) =>
9586 Effect.sync(() => {
9587 writes.push(request.fqn);
9588 }).pipe(
9589 Effect.andThen(() =>
9590 request.fqn === "Keeper"
9591 ? Effect.fail(
9592 new StateStoreError({
9593 message: "First Keeper checkpoint failed",
9594 }),
9595 )
9596 : state.set(request),
9597 ),
9598 ),
9599 }),
9600 ),
9601 Effect.provideService(TestResourceHooks, {
9602 create: track,
9603 update: track,
9604 delete: track,
9605 }),
9606 Effect.exit,
9607 );
9608 assert(Exit.isFailure(result));
9609 expect(Cause.pretty(result.cause)).toContain(
9610 "last historical binding evidence",
9611 );
9612 expect(writes).toEqual([]);
9613 expect(calls).toEqual([]);
9614 const after = yield* rows;
9615 expect(after).toEqual(before);
9616 expect(yield* Effect.sync(() => JSON.stringify(after))).toBe(bytes);
9617 expect(yield* getState("Keeper")).toBeUndefined();
9618 const replacement = yield* stack
9619 .deploy(program(true, "2"), { include: ["A"] })
9620 .pipe(
9621 Effect.provideService(TestResourceHooks, {
9622 create: track,
9623 update: track,
9624 delete: track,
9625 }),
9626 Effect.exit,
9627 );
9628 assert(Exit.isFailure(replacement));
9629 expect(calls).toEqual([]);
9630 expect(yield* rows).toEqual(before);
9631 yield* stack.deploy(program(true), {
9632 include: ["A", "Middle", "Keeper", "B"],
9633 });
9634 expect((yield* getState("B")).attr?.string).toBe("detached");
9635 yield* stack.destroy();
9636 }),
9637 );
9638
9639 for (const operation of ["force", "update", "action noop"] as const) {
9640 test.provider(
9641 `preserves incomplete metadata before partial ${operation} and replacement`,
9642 (stack) =>
9643 Effect.gen(function* () {
9644 yield* stack.destroy();
9645 let runs = 0;
9646 const Compute = Action("Middle", (input: { value: string }) =>
9647 Effect.sync(() => {
9648 runs++;
9649 return { value: input.value };
9650 }),
9651 );
9652 const program = (
9653 detached: boolean,
9654 revision = "1",
9655 value = "generation-1",
9656 ) =>
9657 Effect.gen(function* () {
9658 const a = yield* TestResource("A", {
9659 string: value,
9660 replaceString: revision,
9661 });
9662 if (operation === "action noop") {
9663 const middle = yield* Compute({
9664 value: detached ? "generation-1" : a.string,
9665 });
9666 if (detached)
9667 yield* TestResource("C", { string: middle.value });
9668 yield* TestResource("B", {
9669 string: detached ? "detached" : middle.value,
9670 });
9671 } else
9672 yield* TestResource("B", {
9673 string: detached ? "detached" : a.string,
9674 });
9675 });
9676 yield* stack.deploy(program(false));
9677 const state = yield* yield* State;
9678 const incomplete = operation === "action noop" ? "Middle" : "A";
9679 const key = {
9680 stack: stack.name,
9681 stage: stack.stage,
9682 fqn: incomplete,
9683 };
9684 const row = yield* state.get(key);
9685 assert(row !== undefined);
9686 const legacy = { ...row };
9687 yield* Effect.sync(() =>
9688 Reflect.deleteProperty(legacy, "downstream"),
9689 );
9690 yield* state.set({ ...key, value: legacy });
9691 const ids =
9692 operation === "action noop" ? ["A", "Middle", "B"] : ["A", "B"];
9693 const rows = Effect.all(
9694 ids.map((id) => state.get({ ...key, fqn: id })),
9695 );
9696 const before = yield* rows;
9697 const bytes = yield* Effect.sync(() => JSON.stringify(before));
9698 const runsBefore = runs;
9699 const calls: string[] = [];
9700 const track = (id: string) =>
9701 Effect.sync(() => {
9702 calls.push(id);
9703 });
9704 const desired = program(
9705 true,
9706 "1",
9707 operation === "update" ? "updated" : "generation-1",
9708 );
9709 const include =
9710 operation === "action noop" ? ["A", "Middle", "C"] : ["A"];
9711 const result = yield* stack
9712 .deploy(desired, { include, force: operation === "force" })
9713 .pipe(
9714 Effect.provideService(TestResourceHooks, {
9715 create: track,
9716 update: track,
9717 delete: track,
9718 }),
9719 Effect.exit,
9720 );
9721 assert(Exit.isFailure(result));
9722 expect(Cause.pretty(result.cause)).toContain(
9723 "incomplete historical downstream metadata",
9724 );
9725 expect(calls).toEqual([]);
9726 expect(runs).toBe(runsBefore);
9727 const after = yield* rows;
9728 expect(after).toEqual(before);
9729 expect(yield* Effect.sync(() => JSON.stringify(after))).toBe(bytes);
9730 expect(yield* getState("C")).toBeUndefined();
9731 const replacement = yield* stack
9732 .deploy(program(true, "2", "generation-2"), { include: ["A"] })
9733 .pipe(
9734 Effect.provideService(TestResourceHooks, {
9735 create: track,
9736 update: track,
9737 delete: track,
9738 }),
9739 Effect.exit,
9740 );
9741 assert(Exit.isFailure(replacement));
9742 expect(calls).toEqual([]);
9743 expect(yield* rows).toEqual(before);
9744 yield* stack.deploy(desired, { force: true });
9745 expect((yield* getState("B")).attr?.string).toBe("detached");
9746 yield* stack.deploy(program(true, "2", "generation-2"), {
9747 include: ["A"],
9748 });
9749 expect((yield* getState("A")).attr?.string).toBe("generation-2");
9750 yield* stack.destroy();
9751 }),
9752 );
9753 }
9754
9755 test.provider(
9756 "retains last binding evidence in interrupted cleanup snapshots",
9757 (stack) =>
9758 Effect.gen(function* () {
9759 yield* stack.destroy();
9760 const program = (bound: boolean) =>
9761 Effect.gen(function* () {
9762 const a = yield* BindingTarget("A", { string: "generation-1" });
9763 const middle = yield* BindingTarget("Middle", {});
9764 if (bound) {
9765 yield* a.bind("Middle", { env: { Middle: middle.name } });
9766 yield* middle.bind("A", { env: { A: a.string } });
9767 }
9768 yield* TestResource("B", {
9769 string: bound
9770 ? middle.env.pipe(Output.map((env) => env.A))
9771 : "detached",
9772 });
9773 });
9774 yield* stack.deploy(program(true));
9775 const interrupted = yield* stack.deploy(program(false)).pipe(
9776 Effect.provideService(TestResourceHooks, {
9777 update: () => Effect.fail(new ResourceFailure()),
9778 }),
9779 Effect.exit,
9780 );
9781 assert(Exit.isFailure(interrupted));
9782 const rows = Effect.all([
9783 getState("A"),
9784 getState("Middle"),
9785 getState("B"),
9786 ]);
9787 const before = yield* rows;
9788 for (const id of ["A", "Middle"]) {
9789 const row = yield* getState(id);
9790 assert(row.status === "updating");
9791 expect(row.bindings).toEqual([]);
9792 expect(row.old.bindings.length).toBeGreaterThan(0);
9793 }
9794 const calls: string[] = [];
9795 const track = (id: string) =>
9796 Effect.sync(() => {
9797 calls.push(id);
9798 });
9799 const recovery = yield* stack
9800 .deploy(program(false), { include: ["A", "Middle"] })
9801 .pipe(
9802 Effect.provideService(TestResourceHooks, {
9803 create: track,
9804 update: track,
9805 delete: track,
9806 }),
9807 Effect.exit,
9808 );
9809 assert(Exit.isFailure(recovery));
9810 expect(Cause.pretty(recovery.cause)).toContain(
9811 "last historical binding evidence",
9812 );
9813 expect(calls).toEqual([]);
9814 expect(yield* rows).toEqual(before);
9815 yield* stack.deploy(program(false));
9816 for (const row of yield* rows) {
9817 expect(row.bindings).toEqual([]);
9818 expect(row).not.toHaveProperty("old");
9819 }
9820 expect((yield* getState("B")).attr?.string).toBe("detached");
9821 yield* stack.destroy();
9822 }),
9823 );
9824
9825 test.provider(
9826 "allows partial binding cleanup when a selected noop retains evidence",
9827 (stack) =>
9828 Effect.gen(function* () {
9829 yield* stack.destroy();
9830 const program = (bound: boolean) =>
9831 Effect.gen(function* () {
9832 const a = yield* BindingTarget("A", {});
9833 const keeper = yield* BindingTarget("Keeper", {});
9834 if (bound) yield* a.bind("value", { env: { VALUE: "a" } });
9835 yield* keeper.bind("value", { env: { VALUE: "keeper" } });
9836 yield* TestResource("B", {});
9837 });
9838 yield* stack.deploy(program(true));
9839 const b = yield* getState("B");
9840 const keeper = yield* getState("Keeper");
9841 const plan = yield* stack.plan(program(false), {
9842 include: ["A", "Keeper"],
9843 });
9844 expect(plan.resources.Keeper.action).toBe("noop");
9845 yield* apply(plan);
9846 expect((yield* getState("A")).bindings).toEqual([]);
9847 expect(yield* getState("Keeper")).toEqual(keeper);
9848 expect(yield* getState("B")).toEqual(b);
9849 yield* stack.destroy();
9850 }),
9851 );
9852
9853 for (const initialCycle of [false, true]) {
9854 for (const consumer of ["Resource", "Action"] as const) {
9855 for (const completion of [
9856 "selected closure",
9857 "full deployment",
9858 ] as const) {
9859 test.provider(
9860 `preserves last binding evidence for ${consumer} through ${completion} (initialCycle=${initialCycle})`,
9861 (stack) =>
9862 Effect.gen(function* () {
9863 yield* stack.destroy();
9864 let runs = 0;
9865 const Consume = Action("B", (input: { value: string }) =>
9866 Effect.sync(() => {
9867 runs++;
9868 return input.value;
9869 }),
9870 );
9871 const program = (
9872 mode: "acyclic" | "cycle" | "clear",
9873 revision = "1",
9874 ) =>
9875 Effect.gen(function* () {
9876 const a = yield* BindingTarget("A", {
9877 string: `generation-${revision}`,
9878 replaceString: revision,
9879 });
9880 const middle = yield* BindingTarget("Middle", {});
9881 if (mode !== "clear")
9882 yield* middle.bind("A", { env: { A: a.string } });
9883 if (mode === "cycle")
9884 yield* a.bind("Middle", { env: { Middle: middle.name } });
9885 const value =
9886 mode === "clear"
9887 ? "detached"
9888 : middle.env.pipe(Output.map((env) => env.A));
9889 if (consumer === "Action") yield* Consume({ value });
9890 else yield* TestResource("B", { string: value });
9891 });
9892 const consumerValue = Effect.gen(function* () {
9893 const row = yield* getState<ResourceState | ActionState>("B");
9894 if (row.kind === "action") {
9895 assert(row.status === "ran");
9896 return row.output;
9897 }
9898 return row.attr?.string;
9899 });
9900 yield* stack.deploy(program(initialCycle ? "cycle" : "acyclic"));
9901 expect(yield* consumerValue).toBe("generation-1");
9902 const b = yield* getState<ResourceState | ActionState>("B");
9903 if (!initialCycle) {
9904 expect((yield* getState("A")).downstream).toEqual(["Middle"]);
9905 yield* stack.deploy(program("cycle"), {
9906 include: ["A", "Middle"],
9907 });
9908 }
9909 expect((yield* getState("A")).downstream).toEqual([]);
9910 expect(yield* getState<ResourceState | ActionState>("B")).toEqual(
9911 b,
9912 );
9913 const rows = Effect.all([
9914 getState("A"),
9915 getState("Middle"),
9916 getState<ResourceState | ActionState>("B"),
9917 ]);
9918 const before = yield* rows;
9919 const bytes = yield* Effect.sync(() => JSON.stringify(before));
9920 expect((yield* getState("A")).bindings.length).toBeGreaterThan(0);
9921 expect(
9922 (yield* getState("Middle")).bindings.length,
9923 ).toBeGreaterThan(0);
9924 const runsBefore = runs;
9925 const calls: string[] = [];
9926 const track = (id: string) =>
9927 Effect.sync(() => {
9928 calls.push(id);
9929 });
9930 const exit = yield* stack
9931 .deploy(program("clear"), { include: ["A", "Middle"] })
9932 .pipe(
9933 Effect.provideService(TestResourceHooks, {
9934 create: track,
9935 update: track,
9936 delete: track,
9937 }),
9938 Effect.exit,
9939 );
9940 assert(Exit.isFailure(exit));
9941 expect(Cause.pretty(exit.cause)).toContain(
9942 "last historical binding evidence",
9943 );
9944 expect(Cause.pretty(exit.cause)).toContain("unselected: B");
9945 expect(Cause.pretty(exit.cause)).toContain(
9946 "Select them or run a full deployment",
9947 );
9948 expect(calls).toEqual([]);
9949 expect(runs).toBe(runsBefore);
9950 const after = yield* rows;
9951 expect(after).toEqual(before);
9952 expect(yield* Effect.sync(() => JSON.stringify(after))).toBe(
9953 bytes,
9954 );
9955 const replacement = yield* stack
9956 .deploy(program("clear", "2"), { include: ["A"] })
9957 .pipe(
9958 Effect.provideService(TestResourceHooks, {
9959 create: track,
9960 update: track,
9961 delete: track,
9962 }),
9963 Effect.exit,
9964 );
9965 assert(Exit.isFailure(replacement));
9966 expect(Cause.pretty(replacement.cause)).toContain(
9967 "Historical binding-cycle",
9968 );
9969 expect(calls).toEqual([]);
9970 expect(runs).toBe(runsBefore);
9971 expect(yield* rows).toEqual(before);
9972 if (consumer === "Action") {
9973 const actionBoundary = yield* stack
9974 .deploy(program("clear", "2"), {
9975 include: ["A", "Middle"],
9976 })
9977 .pipe(
9978 Effect.provideService(TestResourceHooks, {
9979 create: track,
9980 update: track,
9981 delete: track,
9982 }),
9983 Effect.exit,
9984 );
9985 assert(Exit.isFailure(actionBoundary));
9986 expect(Cause.pretty(actionBoundary.cause)).toContain(
9987 "'B' is unselected",
9988 );
9989 expect(Cause.pretty(actionBoundary.cause)).toContain(
9990 "Historical binding-cycle",
9991 );
9992 expect(calls).toEqual([]);
9993 expect(runs).toBe(runsBefore);
9994 expect(yield* rows).toEqual(before);
9995 }
9996 if (completion === "selected closure")
9997 yield* stack.deploy(program("clear"), {
9998 include: ["A", "Middle", "B"],
9999 });
10000 else yield* stack.deploy(program("clear"));
10001 expect((yield* getState("A")).bindings).toEqual([]);
10002 expect((yield* getState("Middle")).bindings).toEqual([]);
10003 expect(yield* consumerValue).toBe("detached");
10004 const clearedB = yield* getState<ResourceState | ActionState>(
10005 "B",
10006 );
10007 const a = yield* getState("A");
10008 yield* stack.deploy(program("clear", "2"), { include: ["A"] });
10009 expect((yield* getState("A")).instanceId).not.toBe(a.instanceId);
10010 expect(yield* getState<ResourceState | ActionState>("B")).toEqual(
10011 clearedB,
10012 );
10013 yield* stack.destroy();
10014 }),
10015 );
10016 }
10017 }
10018 }
10019
10020 for (const intermediate of ["Action", "Resource"] as const) {
10021 for (const completion of ["selected closure", "full deployment"] as const) {
10022 for (const historical of [false, true]) {
10023 test.provider(
10024 `refuses selected ${intermediate} detachment before ${completion} (historical=${historical})`,
10025 (stack) =>
10026 Effect.gen(function* () {
10027 yield* stack.destroy();
10028 let runs = 0;
10029 const Compute = Action("Middle", (input: { value: string }) =>
10030 Effect.sync(() => {
10031 runs++;
10032 return input;
10033 }),
10034 );
10035 const program = (
10036 revision: string,
10037 detached: boolean,
10038 updating = false,
10039 ) =>
10040 Effect.gen(function* () {
10041 const a = yield* TestResource("A", {
10042 string: updating ? "updated" : `generation-${revision}`,
10043 replaceString: revision,
10044 });
10045 const input = detached ? "detached" : a.string;
10046 const middle =
10047 intermediate === "Action"
10048 ? (yield* Compute({ value: input })).value
10049 : (yield* TestResource("Middle", { string: input }))
10050 .string;
10051 const value = historical
10052 ? (yield* TestResource("Bridge", {
10053 string:
10054 updating && intermediate === "Resource"
10055 ? "detached"
10056 : middle,
10057 })).string
10058 : middle;
10059 yield* TestResource("B", {
10060 string: historical && detached ? "detached" : value,
10061 });
10062 });
10063 yield* stack.deploy(program("1", false));
10064 if (historical) {
10065 const failed = yield* stack
10066 .deploy(program("1", true, true))
10067 .pipe(
10068 Effect.provideService(TestResourceHooks, {
10069 update: () => Effect.fail(new ResourceFailure()),
10070 }),
10071 Effect.exit,
10072 );
10073 assert(Exit.isFailure(failed));
10074 const a = yield* getState("A");
10075 const bridge = yield* getState("Bridge");
10076 assert(a.status === "updating");
10077 assert(bridge.status === "updating");
10078 expect(a.downstream).toEqual([]);
10079 expect(a.old).toMatchObject({ downstream: ["Middle"] });
10080 expect(bridge.downstream).toEqual([]);
10081 expect(bridge.old).toMatchObject({ downstream: ["B"] });
10082 }
10083 const ids = historical
10084 ? ["A", "Middle", "Bridge", "B"]
10085 : ["A", "Middle", "B"];
10086 const rows = Effect.all(ids.map((id) => getState(id)));
10087 const before = yield* rows;
10088 const beforeBytes = yield* Effect.sync(() =>
10089 JSON.stringify(before),
10090 );
10091 const runsBefore = runs;
10092 const calls: string[] = [];
10093 const track = (id: string) =>
10094 Effect.sync(() => {
10095 calls.push(id);
10096 });
10097 const include = ids.filter((id) => id !== "B");
10098 const exit = yield* stack
10099 .deploy(program("1", true, historical), { include })
10100 .pipe(
10101 Effect.provideService(TestResourceHooks, {
10102 create: track,
10103 update: track,
10104 delete: track,
10105 }),
10106 Effect.exit,
10107 );
10108 assert(Exit.isFailure(exit));
10109 expect(Cause.pretty(exit.cause)).toContain(
10110 "Cannot detach 'A' from 'Middle'",
10111 );
10112 expect(Cause.pretty(exit.cause)).toContain("unselected: B");
10113 expect(Cause.pretty(exit.cause)).toContain(
10114 "Select them or run a full deployment",
10115 );
10116 expect(calls).toEqual([]);
10117 expect(runs).toBe(runsBefore);
10118 const after = yield* rows;
10119 expect(after).toEqual(before);
10120 expect(yield* Effect.sync(() => JSON.stringify(after))).toBe(
10121 beforeBytes,
10122 );
10123 const complete = program("1", true, historical);
10124 if (completion === "selected closure")
10125 yield* stack.deploy(complete, { include: ids });
10126 else yield* stack.deploy(complete);
10127 expect((yield* getState("A")).downstream).toEqual([]);
10128 expect((yield* getState("B")).attr?.string).toBe("detached");
10129 const b = yield* getState("B");
10130 const a = yield* getState("A");
10131 yield* stack.deploy(program("2", true, historical), {
10132 include: ["A"],
10133 });
10134 expect((yield* getState("A")).instanceId).not.toBe(a.instanceId);
10135 expect(yield* getState("B")).toEqual(b);
10136 yield* stack.destroy();
10137 }),
10138 );
10139 }
10140 }
10141 }
10142
10143 test.provider(
10144 "does not confuse binding SCC filtering with selected detachment",
10145 (stack) =>
10146 Effect.gen(function* () {
10147 yield* stack.destroy();
10148 const program = (cycle: boolean) =>
10149 Effect.gen(function* () {
10150 const a = yield* BindingTarget("A", {});
10151 const middle = yield* BindingTarget("Middle", {});
10152 yield* middle.bind("A", { env: { A: a.name } });
10153 if (cycle)
10154 yield* a.bind("Middle", { env: { Middle: middle.name } });
10155 yield* TestResource("B", { string: middle.name });
10156 });
10157 yield* stack.deploy(program(false));
10158 expect((yield* getState("A")).downstream).toEqual(["Middle"]);
10159 const b = yield* getState("B");
10160 yield* stack.deploy(program(true), { include: ["A", "Middle"] });
10161 expect((yield* getState("A")).downstream).toEqual([]);
10162 expect(yield* getState("B")).toEqual(b);
10163 yield* stack.destroy();
10164 }),
10165 );
10166
10167 test.provider(
10168 "preserves historical excluded edges through partial cycle convergence",
10169 (stack) =>
10170 Effect.gen(function* () {
10171 yield* stack.destroy();
10172 const program = (revision: string, attached: boolean) =>
10173 Effect.gen(function* () {
10174 const a = yield* BindingTarget("A", { string: `${revision}-a` });
10175 const c = yield* BindingTarget("C", { string: `${revision}-c` });
10176 yield* a.bind("C", { env: { C: c.string } });
10177 yield* c.bind("A", { env: { A: a.string } });
10178 yield* TestResource("B", {
10179 string: attached ? a.string : "detached",
10180 });
10181 });
10182 yield* stack.deploy(program("1", true));
10183 const failed = yield* stack.deploy(program("2", false)).pipe(
10184 Effect.provideService(TestResourceHooks, {
10185 update: () => Effect.fail(new ResourceFailure()),
10186 }),
10187 Effect.exit,
10188 );
10189 assert(Exit.isFailure(failed));
10190 const interruptedA = yield* getState("A");
10191 const b = yield* getState("B");
10192 assert(interruptedA.status === "updating");
10193 expect(interruptedA.downstream).toEqual([]);
10194 expect(interruptedA.old).toMatchObject({ downstream: ["B"] });
10195 const started = new Set<string>();
10196 const updates: string[] = [];
10197 const ready = yield* Deferred.make<void>();
10198 yield* stack.deploy(program("2", false), { include: ["A"] }).pipe(
10199 Effect.provideService(TestResourceHooks, {
10200 update: (id) =>
10201 Effect.gen(function* () {
10202 const count = yield* Effect.sync(() => {
10203 updates.push(id);
10204 started.add(id);
10205 return started.size;
10206 });
10207 // Both cycle members reconcile stale bindings before convergence.
10208 if (count === 2) yield* Deferred.succeed(ready, undefined);
10209 else yield* Deferred.await(ready);
10210 }),
10211 }),
10212 );
10213 expect(updates.filter((id) => id === "A").length).toBeGreaterThan(1);
10214 const a = yield* getState("A");
10215 expectConvergedStatus(a.status);
10216 expect(a).not.toHaveProperty("old");
10217 expect(a.downstream).toEqual(["B"]);
10218 expect(a.attr?.env).toEqual({ C: "2-c" });
10219 expect(yield* getState("B")).toEqual(b);
10220 yield* stack.destroy();
10221 }),
10222 { timeout: 10_000 },
10223 );
10224
10225 for (const declaredConsumer of [true, false]) {
10226 test.provider(
10227 `preserves historical excluded edges through partial update recovery (declared=${declaredConsumer})`,
10228 (stack) =>
10229 Effect.gen(function* () {
10230 yield* stack.destroy();
10231 const program = (
10232 value: string,
10233 replacement: string,
10234 attached: boolean,
10235 consumer = true,
10236 ) =>
10237 Effect.gen(function* () {
10238 const a = yield* TestResource("A", {
10239 string: value,
10240 replaceString: replacement,
10241 });
10242 if (consumer)
10243 yield* TestResource("B", {
10244 string: attached ? a.string : "detached",
10245 });
10246 });
10247 yield* stack.deploy(program("original", "1", true));
10248 const failed = yield* stack
10249 .deploy(program("recovered", "1", false))
10250 .pipe(
10251 Effect.provideService(TestResourceHooks, {
10252 update: () => Effect.fail(new ResourceFailure()),
10253 }),
10254 Effect.exit,
10255 );
10256 assert(Exit.isFailure(failed));
10257 const interruptedA = yield* getState("A");
10258 const b = yield* getState("B");
10259 assert(interruptedA.status === "updating");
10260 assert(b.status === "updating");
10261 expect(interruptedA.downstream).toEqual([]);
10262 expect(interruptedA.old).toMatchObject({ downstream: ["B"] });
10263 expect(b.attr?.string).toBe("original");
10264 const recovery = program("recovered", "1", false, declaredConsumer);
10265 const plan = yield* stack.plan(recovery, { include: ["A"] });
10266 expect(plan.resources.A.action).toBe("update");
10267 yield* apply(plan);
10268 const recovered = yield* getState("A");
10269 expectConvergedStatus(recovered.status);
10270 expect(recovered).not.toHaveProperty("old");
10271 expect(recovered.downstream).toEqual(["B"]);
10272 expect(yield* getState("B")).toEqual(b);
10273 const noop = yield* stack.plan(recovery, { include: ["A"] });
10274 expect(noop.resources.A.action).toBe("noop");
10275 yield* apply(noop);
10276 expect((yield* getState("A")).downstream).toEqual(["B"]);
10277 expect(yield* getState("B")).toEqual(b);
10278 yield* stack.deploy(
10279 program("updated again", "1", false, declaredConsumer),
10280 { include: ["A"] },
10281 );
10282 const before = yield* getState("A");
10283 expect(before.downstream).toEqual(["B"]);
10284 expect(yield* getState("B")).toEqual(b);
10285 const calls: string[] = [];
10286 const track = (id: string) =>
10287 Effect.sync(() => {
10288 calls.push(id);
10289 });
10290 const exit = yield* stack
10291 .deploy(program("updated again", "2", false, declaredConsumer), {
10292 include: ["A"],
10293 })
10294 .pipe(
10295 Effect.provideService(TestResourceHooks, {
10296 create: track,
10297 update: track,
10298 delete: track,
10299 }),
10300 Effect.exit,
10301 );
10302 assert(Exit.isFailure(exit));
10303 expect(Cause.pretty(exit.cause)).toContain(
10304 "dependents are unselected: B",
10305 );
10306 expect(calls).toEqual([]);
10307 expect(yield* getState("A")).toEqual(before);
10308 expect(yield* getState("B")).toEqual(b);
10309 yield* stack.deploy(program("updated again", "1", false));
10310 expect((yield* getState("A")).downstream).toEqual([]);
10311 expectConvergedStatus((yield* getState("B")).status);
10312 yield* stack.destroy();
10313 }),
10314 );
10315 }
10316
10317 for (const status of ["updating", "replacing", "replaced"] as const) {
10318 test.provider(
10319 `traverses intermediate ${status}.old downstream edges`,
10320 (stack) =>
10321 Effect.gen(function* () {
10322 yield* stack.destroy();
10323 const program = (aRevision: string, changed: boolean) =>
10324 Effect.gen(function* () {
10325 const a = yield* TestResource("A", { replaceString: aRevision });
10326 const middle = yield* TestResource("Middle", {
10327 string: a.string,
10328 stringArray: [changed ? "2" : "1"],
10329 replaceString: changed && status !== "updating" ? "2" : "1",
10330 });
10331 yield* TestResource("B", {
10332 string: changed ? "detached" : middle.string,
10333 });
10334 });
10335 yield* stack.deploy(program("1", false));
10336 const failed = yield* stack
10337 .deploy(program("1", true))
10338 .pipe(
10339 Effect.provideService(
10340 TestResourceHooks,
10341 failOn(
10342 "Middle",
10343 status === "updating"
10344 ? "update"
10345 : status === "replacing"
10346 ? "create"
10347 : "delete",
10348 ),
10349 ),
10350 Effect.exit,
10351 );
10352 assert(Exit.isFailure(failed));
10353 const before = yield* Effect.all([
10354 getState("A"),
10355 getState("Middle"),
10356 getState("B"),
10357 ]);
10358 const middle = before[1];
10359 assert(
10360 middle.status === "updating" ||
10361 middle.status === "replacing" ||
10362 middle.status === "replaced",
10363 );
10364 expect(middle.status).toBe(status);
10365 expect(middle.downstream).toEqual([]);
10366 expect(middle.old).toMatchObject({ downstream: ["B"] });
10367 const calls: string[] = [];
10368 const track = (id: string) =>
10369 Effect.sync(() => {
10370 calls.push(id);
10371 });
10372 const exit = yield* stack
10373 .deploy(program("2", true), { include: ["Middle"] })
10374 .pipe(
10375 Effect.provideService(TestResourceHooks, {
10376 create: track,
10377 update: track,
10378 delete: track,
10379 }),
10380 Effect.exit,
10381 );
10382 assert(Exit.isFailure(exit));
10383 expect(Cause.pretty(exit.cause)).toContain(
10384 "dependents are unselected: B",
10385 );
10386 expect(calls).toEqual([]);
10387 expect(
10388 yield* Effect.all([
10389 getState("A"),
10390 getState("Middle"),
10391 getState("B"),
10392 ]),
10393 ).toEqual(before);
10394 yield* stack.destroy();
10395 }),
10396 );
10397 }
10398
10399 test.provider(
10400 "refuses ambiguous mixed binding-cycle GC resumption",
10401 (stack) =>
10402 Effect.gen(function* () {
10403 yield* stack.destroy();
10404 const program = (revision: string, bound: boolean) =>
10405 Effect.gen(function* () {
10406 const b = yield* BindingTarget("B", {});
10407 const a = yield* BindingTarget("A", {
10408 replaceString: revision,
10409 string: bound ? b.name : undefined,
10410 });
10411 if (bound) yield* b.bind("A", { env: { A: a.name } });
10412 });
10413 yield* stack.deploy(program("1", true));
10414 const failed = yield* stack.deploy(program("2", true)).pipe(
10415 Effect.provideService(TestResourceHooks, {
10416 delete: () => Effect.fail(new ResourceFailure()),
10417 }),
10418 Effect.exit,
10419 );
10420 assert(Exit.isFailure(failed));
10421 const before = yield* Effect.all([getState("A"), getState("B")]);
10422 const a = before[0];
10423 assert(a.status === "replaced");
10424 expect(a.bindings).toEqual([]);
10425 expect(a.old.bindings).toEqual([]);
10426 expect(a.downstream).toEqual([]);
10427 expect(a.old.downstream).toEqual([]);
10428 const calls: string[] = [];
10429 const track = (id: string) =>
10430 Effect.sync(() => {
10431 calls.push(id);
10432 });
10433 const exit = yield* stack
10434 .deploy(program("2", false), { include: ["A"] })
10435 .pipe(
10436 Effect.provideService(TestResourceHooks, {
10437 create: track,
10438 update: track,
10439 delete: track,
10440 }),
10441 Effect.exit,
10442 );
10443 assert(Exit.isFailure(exit));
10444 expect(Cause.pretty(exit.cause)).toContain("Historical binding-cycle");
10445 expect(calls).toEqual([]);
10446 expect(yield* Effect.all([getState("A"), getState("B")])).toEqual(
10447 before,
10448 );
10449 yield* stack.destroy();
10450 }),
10451 );
10452
10453 for (const intermediate of ["Action", "Resource"] as const) {
10454 for (const declaredConsumer of [true, false]) {
10455 for (const resume of [false, true]) {
10456 test.provider(
10457 `refuses transitive ${intermediate} boundary (declared=${declaredConsumer}, resume=${resume})`,
10458 (stack) =>
10459 Effect.gen(function* () {
10460 yield* stack.destroy();
10461 let runs = 0;
10462 const Compute = Action("Middle", (input: { value: string }) =>
10463 Effect.sync(() => {
10464 runs++;
10465 return input;
10466 }),
10467 );
10468 const program = (revision: string, consumer = true) =>
10469 Effect.gen(function* () {
10470 const a = yield* TestResource("A", {
10471 string: revision,
10472 replaceString: revision,
10473 });
10474 const value =
10475 intermediate === "Action"
10476 ? (yield* Compute({ value: a.string })).value
10477 : (yield* TestResource("Middle", { string: a.string }))
10478 .string;
10479 if (consumer) yield* TestResource("B", { string: value });
10480 });
10481 yield* stack.deploy(program("1"));
10482 if (resume) {
10483 const interrupted = yield* stack.deploy(program("2")).pipe(
10484 Effect.provideService(TestResourceHooks, {
10485 delete: () => Effect.fail(new ResourceFailure()),
10486 }),
10487 Effect.exit,
10488 );
10489 assert(Exit.isFailure(interrupted));
10490 expect((yield* getState("A")).status).toBe("replaced");
10491 }
10492 const before = yield* Effect.all([
10493 getState("A"),
10494 getState("Middle"),
10495 getState("B"),
10496 ]);
10497 const runsBefore = runs;
10498 const calls: string[] = [];
10499 const track = (id: string) =>
10500 Effect.sync(() => {
10501 calls.push(id);
10502 });
10503 const exit = yield* stack
10504 .deploy(program("2", declaredConsumer), {
10505 include: ["Middle"],
10506 })
10507 .pipe(
10508 Effect.provideService(TestResourceHooks, {
10509 create: track,
10510 update: track,
10511 delete: track,
10512 }),
10513 Effect.exit,
10514 );
10515 assert(Exit.isFailure(exit));
10516 expect(Cause.pretty(exit.cause)).toContain(
10517 "UnsafeSelectionBoundary",
10518 );
10519 expect(Cause.pretty(exit.cause)).toContain("B");
10520 expect(calls).toEqual([]);
10521 expect(runs).toBe(runsBefore);
10522 expect(
10523 yield* Effect.all([
10524 getState("A"),
10525 getState("Middle"),
10526 getState("B"),
10527 ]),
10528 ).toEqual(before);
10529 yield* stack.destroy();
10530 }),
10531 );
10532 }
10533 }
10534 }
10535
10536 for (const history of [
10537 "partial removal",
10538 "selected updating",
10539 "unselected updating",
10540 "unselected replacing",
10541 "unselected replaced",
10542 "mixed cycle",
10543 ] as const) {
10544 test.provider(
10545 `refuses ambiguous historical bindings after ${history}`,
10546 (stack) =>
10547 Effect.gen(function* () {
10548 yield* stack.destroy();
10549 const program = (bound: boolean, aRevision = "1", bRevision = "1") =>
10550 Effect.gen(function* () {
10551 const b = yield* BindingTarget("B", { replaceString: bRevision });
10552 const a = yield* BindingTarget("A", {
10553 replaceString: aRevision,
10554 string: bound && history === "mixed cycle" ? b.name : undefined,
10555 });
10556 if (bound) {
10557 if (history !== "mixed cycle")
10558 yield* a.bind("B", { env: { B: b.name } });
10559 yield* b.bind("A", { env: { A: a.name } });
10560 }
10561 });
10562 yield* stack.deploy(program(true));
10563 if (history === "selected updating") {
10564 yield* stack.deploy(program(false), { include: ["B"] });
10565 const failed = yield* stack.deploy(program(false)).pipe(
10566 Effect.provideService(TestResourceHooks, {
10567 update: () => Effect.fail(new ResourceFailure()),
10568 }),
10569 Effect.exit,
10570 );
10571 assert(Exit.isFailure(failed));
10572 const a = yield* getState("A");
10573 assert(a.status === "updating");
10574 expect(a.bindings).toEqual([]);
10575 expect(a.old.bindings.length).toBeGreaterThan(0);
10576 expect((yield* getState("B")).bindings).toEqual([]);
10577 } else if (history !== "mixed cycle") {
10578 yield* stack.deploy(program(false), { include: ["A"] });
10579 expect((yield* getState("A")).bindings).toEqual([]);
10580 if (history === "unselected updating") {
10581 const failed = yield* stack.deploy(program(false)).pipe(
10582 Effect.provideService(TestResourceHooks, {
10583 update: () => Effect.fail(new ResourceFailure()),
10584 }),
10585 Effect.exit,
10586 );
10587 assert(Exit.isFailure(failed));
10588 const b = yield* getState("B");
10589 assert(b.status === "updating");
10590 expect(b.bindings).toEqual([]);
10591 expect(b.old.bindings.length).toBeGreaterThan(0);
10592 } else if (
10593 history === "unselected replacing" ||
10594 history === "unselected replaced"
10595 ) {
10596 const failed = yield* stack
10597 .deploy(program(false, "1", "2"))
10598 .pipe(
10599 Effect.provideService(
10600 TestResourceHooks,
10601 history === "unselected replacing"
10602 ? { create: () => Effect.fail(new ResourceFailure()) }
10603 : { delete: () => Effect.fail(new ResourceFailure()) },
10604 ),
10605 Effect.exit,
10606 );
10607 assert(Exit.isFailure(failed));
10608 const b = yield* getState("B");
10609 assert(b.status === "replacing" || b.status === "replaced");
10610 expect(b.status).toBe(
10611 history === "unselected replacing" ? "replacing" : "replaced",
10612 );
10613 expect(b.bindings).toEqual([]);
10614 expect(b.old.bindings.length).toBeGreaterThan(0);
10615 }
10616 }
10617 const before = yield* Effect.all([getState("A"), getState("B")]);
10618 expect(before[0].bindings).toEqual([]);
10619 const calls: string[] = [];
10620 const track = (id: string) =>
10621 Effect.sync(() => {
10622 calls.push(id);
10623 });
10624 const exit = yield* stack
10625 .deploy(program(false, "2"), { include: ["A"] })
10626 .pipe(
10627 Effect.provideService(TestResourceHooks, {
10628 create: track,
10629 update: track,
10630 delete: track,
10631 }),
10632 Effect.exit,
10633 );
10634 assert(Exit.isFailure(exit));
10635 expect(Cause.pretty(exit.cause)).toContain(
10636 "Historical binding-cycle",
10637 );
10638 expect(calls).toEqual([]);
10639 expect(yield* Effect.all([getState("A"), getState("B")])).toEqual(
10640 before,
10641 );
10642 yield* stack.destroy();
10643 }),
10644 );
10645 }
10646
10647 for (const replacement of [false, true]) {
10648 test.provider(
10649 `refuses Action collision with persisted resource (replacement=${replacement})`,
10650 (stack) =>
10651 Effect.gen(function* () {
10652 yield* stack.destroy();
10653 yield* stack.deploy(TestResource("X", { replaceString: "1" }));
10654 if (replacement) {
10655 const failed = yield* stack
10656 .deploy(TestResource("X", { replaceString: "2" }))
10657 .pipe(
10658 Effect.provideService(TestResourceHooks, {
10659 delete: () => Effect.fail(new ResourceFailure()),
10660 }),
10661 Effect.exit,
10662 );
10663 assert(Exit.isFailure(failed));
10664 expect((yield* getState("X")).status).toBe("replaced");
10665 }
10666 const before = yield* getState("X");
10667 let runs = 0;
10668 const Compute = Action("X", (_: {}) =>
10669 Effect.sync(() => {
10670 runs++;
10671 }),
10672 );
10673 const calls: string[] = [];
10674 const track = (id: string) =>
10675 Effect.sync(() => {
10676 calls.push(id);
10677 });
10678 const exit = yield* stack
10679 .deploy(Compute({}), { include: ["X"] })
10680 .pipe(
10681 Effect.provideService(TestResourceHooks, {
10682 create: track,
10683 update: track,
10684 delete: track,
10685 }),
10686 Effect.exit,
10687 );
10688 assert(Exit.isFailure(exit));
10689 expect(Cause.pretty(exit.cause)).toContain("UnsafeSelectionBoundary");
10690 expect(Cause.pretty(exit.cause)).toContain("persisted resource");
10691 expect(runs).toBe(0);
10692 expect(calls).toEqual([]);
10693 expect(yield* getState("X")).toEqual(before);
10694 yield* stack.destroy();
10695 }),
10696 );
10697 }
10698
10699 test.provider(
10700 "refuses filtered mode switch after interrupted live destruction",
10701 (stack) =>
10702 Effect.gen(function* () {
10703 yield* stack.destroy();
10704 const program = ModalResource("Worker", { value: "live" });
10705 yield* stack.deploy(program);
10706 const failed = yield* stack.destroy().pipe(
10707 Effect.provideService(TestResourceHooks, {
10708 delete: () => Effect.fail(new ResourceFailure()),
10709 }),
10710 Effect.exit,
10711 );
10712 assert(Exit.isFailure(failed));
10713 const before = yield* getState("Worker");
10714 expect(before.status).toBe("deleting");
10715 expect(before.providerMode).toBe("live");
10716 const callsBefore = modalCalls.filter(
10717 (call) => call.stack === stack.name,
10718 );
10719 const exit = yield* inDev(
10720 stack.deploy(program, { include: ["Worker"] }),
10721 ).pipe(Effect.exit);
10722 assert(Exit.isFailure(exit));
10723 expect(Cause.pretty(exit.cause)).toContain("UnsafeSelectionBoundary");
10724 expect(Cause.pretty(exit.cause)).toContain("recorded 'live' mode");
10725 expect(yield* getState("Worker")).toEqual(before);
10726 expect(modalCalls.filter((call) => call.stack === stack.name)).toEqual(
10727 callsBefore,
10728 );
10729 yield* inDev(stack.destroy());
10730 expect(
10731 modalCalls
10732 .filter((call) => call.stack === stack.name)
10733 .slice(callsBefore.length),
10734 ).toEqual([
10735 { stack: stack.name, mode: "live", op: "delete", id: "Worker" },
10736 ]);
10737 expect(yield* getState("Worker")).toBeUndefined();
10738 yield* inDev(stack.deploy(program, { include: ["Worker"] }));
10739 expect((yield* getState("Worker")).providerMode).toBe("local");
10740 yield* stack.destroy();
10741 }),
10742 );
10743});
10744
10745describe("noop stable readiness", { tags: ["unit", "local"] }, () => {
10746 test.provider(
10747 "publishes noop resource output before waking captured Action consumers",
10748 (stack) =>
10749 Effect.gen(function* () {
10750 yield* stack.destroy();
10751 let runs = 0;
10752 const program = (revision: number) =>
10753 Effect.gen(function* () {
10754 const source = yield* TestResource("Source", {
10755 string: "ready",
10756 }).pipe(RemovalPolicy.retain(revision > 1));
10757 const Compute = Action(
10758 "Compute",
10759 Effect.gen(function* () {
10760 const value = yield* source.string;
10761 return (_: { revision: number }) =>
10762 Effect.gen(function* () {
10763 const resolved = yield* value;
10764 runs++;
10765 return { value: resolved };
10766 });
10767 }),
10768 );
10769 const result = yield* Compute({ revision });
10770 return result.value;
10771 });
10772 expect(yield* stack.deploy(program(1))).toBe("ready");
10773 const plan = yield* stack.plan(program(2));
10774 expect(plan.resources.Source.action).toBe("noop");
10775 expect(plan.actions.Compute.action).toBe("run");
10776 // The policy note holds the noop until the Action reports pending.
10777 // One cooperative yield lets it await readyStable before publication;
10778 // Deferred completion then resumes the waiting consumer synchronously.
10779 const pending = yield* Deferred.make<void>();
10780 expect(
10781 yield* apply(plan, {
10782 session: {
10783 done: () => Effect.void,
10784 emit: (event) => {
10785 if (
10786 event._tag === "apply.resource.status" &&
10787 event.id === "Compute" &&
10788 event.status === "pending"
10789 ) {
10790 return Deferred.succeed(pending, undefined).pipe(
10791 Effect.asVoid,
10792 );
10793 }
10794 if (
10795 event._tag === "apply.resource.note" &&
10796 event.id === "Source"
10797 ) {
10798 return Deferred.await(pending).pipe(
10799 Effect.andThen(Effect.yieldNow),
10800 );
10801 }
10802 return Effect.void;
10803 },
10804 },
10805 }),
10806 ).toBe("ready");
10807 expect(runs).toBe(2);
10808 expect(yield* stack.deploy(program(2))).toBe("ready");
10809 expect(runs).toBe(2);
10810 yield* stack.destroy();
10811 }),
10812 );
10813});
10814
10815describe(
10816 "resource selection apply barriers",
10817 { tags: ["unit", "local"] },
10818 () => {
10819 for (const revision of ["old", "new"]) {
10820 test.provider(
10821 `excluded ${revision === "old" ? "noop" : "changed"} upstream rejects without reads diffs mutations or row changes`,
10822 (stack) =>
10823 Effect.gen(function* () {
10824 yield* stack.destroy();
10825 const program = (value: string, fresh = false) =>
10826 Effect.gen(function* () {
10827 const source = yield* BindingTarget("Source", {
10828 string: value,
10829 });
10830 const middle = yield* BindingTarget("Middle", {
10831 string: source.string,
10832 });
10833 const consumer = yield* TestResource("Consumer", {
10834 string: middle.string,
10835 });
10836 if (fresh) yield* TestResource("Fresh", {});
10837 return { consumer: consumer.string };
10838 }).pipe(Namespace.push("App"));
10839 yield* stack.deploy(program("old"));
10840 const state = yield* yield* State;
10841 const key = { stack: stack.name, stage: stack.stage };
10842 const snapshot = Effect.gen(function* () {
10843 const ids = [...(yield* state.list(key))].sort();
10844 const rows = yield* Effect.forEach(ids, (fqn) =>
10845 state.get({ ...key, fqn }),
10846 );
10847 const output = yield* state.getOutput(key);
10848 return yield* Effect.sync(() =>
10849 JSON.stringify({ ids, rows, output }),
10850 );
10851 });
10852 const before = yield* snapshot;
10853 const calls: string[] = [];
10854 const record = (op: string) => (id: string) =>
10855 Effect.sync(() => {
10856 calls.push(`${op}:${id}`);
10857 });
10858 const rejected = yield* stack
10859 .deploy(program(revision, true), {
10860 include: ["App/Fresh", "App/Consumer"],
10861 exclude: ["App/S*"],
10862 })
10863 .pipe(
10864 Effect.provideService(TestResourceHooks, {
10865 read: (id) => record("read")(id).pipe(Effect.as(undefined)),
10866 diff: record("diff"),
10867 create: record("create"),
10868 update: record("update"),
10869 delete: record("delete"),
10870 }),
10871 Effect.exit,
10872 );
10873 expect(Exit.isFailure(rejected)).toBe(true);
10874 if (Exit.isFailure(rejected)) {
10875 const message = Cause.pretty(rejected.cause);
10876 expect(message).toContain(
10877 "App/Consumer -> App/Middle -> App/Source",
10878 );
10879 expect(message).toContain("exclude pattern 'App/S*'");
10880 }
10881 expect(calls).toEqual([]);
10882 expect(yield* snapshot).toBe(before);
10883 yield* stack.destroy();
10884 }),
10885 );
10886 }
10887
10888 test.provider(
10889 "includes and updates implicit upstreams outside the include pattern",
10890 (stack) =>
10891 Effect.gen(function* () {
10892 yield* stack.destroy();
10893 const program = (value: string) =>
10894 Effect.gen(function* () {
10895 const source = yield* TestResource("Source", { string: value });
10896 const middle = yield* TestResource("Middle", {
10897 string: source.string,
10898 });
10899 const consumer = yield* TestResource("Consumer", {
10900 string: middle.string,
10901 });
10902 yield* TestResource("Excluded", {});
10903 return { value: consumer.string };
10904 });
10905 yield* stack.deploy(program("old"));
10906 const excluded = yield* getState("Excluded");
10907 const changed: string[] = [];
10908 const output = yield* stack
10909 .deploy(program("new"), {
10910 include: ["Cons*"],
10911 exclude: ["Excluded"],
10912 })
10913 .pipe(
10914 Effect.provideService(TestResourceHooks, {
10915 update: (id) =>
10916 Effect.sync(() => {
10917 changed.push(id);
10918 }),
10919 }),
10920 );
10921 expect(output).toBeUndefined();
10922 expect(changed).toEqual(["Source", "Middle", "Consumer"]);
10923 expect((yield* getState("Source")).attr?.string).toBe("new");
10924 expect((yield* getState("Middle")).attr?.string).toBe("new");
10925 expect((yield* getState("Consumer")).attr?.string).toBe("new");
10926 expect(yield* getState("Excluded")).toEqual(excluded);
10927 yield* stack.destroy();
10928 }),
10929 );
10930
10931 for (const selection of [
10932 { include: ["**"] },
10933 { exclude: ["Missing/**"] },
10934 ]) {
10935 test.provider(
10936 `selecting everything still preserves stack outputs ${JSON.stringify(selection)}`,
10937 (stack) =>
10938 Effect.gen(function* () {
10939 yield* stack.destroy();
10940 yield* stack.deploy(
10941 TestResource("Only", { string: "old" }).pipe(
10942 Effect.as({ full: "old" }),
10943 ),
10944 );
10945 const output = yield* stack.deploy(
10946 TestResource("Only", { string: "new" }).pipe(
10947 Effect.map((resource) =>
10948 Output.map(resource.string, () => {
10949 throw new Error("partial output evaluated");
10950 }),
10951 ),
10952 ),
10953 selection,
10954 );
10955 expect(output).toBeUndefined();
10956 expect((yield* getState("Only")).attr?.string).toBe("new");
10957 const state = yield* yield* State;
10958 expect(
10959 yield* state.getOutput({ stack: stack.name, stage: stack.stage }),
10960 ).toEqual({ full: "old" });
10961 yield* stack.destroy();
10962 }),
10963 );
10964 }
10965 },
10966);