1import * as Cause from "effect/Cause";
2import type { ConfigError } from "effect/Config";
3import * as Data from "effect/Data";
4import * as Deferred from "effect/Deferred";
5import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";
7import * as Option from "effect/Option";
8import * as Predicate from "effect/Predicate";
9import type { PlatformError } from "effect/PlatformError";
10import type { Simplify } from "effect/Types";
11import type { ActionLike } from "./Action.ts";
12import { makeResolveContext } from "./ActionRuntimeContext.ts";
13import { OwnedBySomeoneElse, stripUnowned, Unowned } from "./AdoptPolicy.ts";
14import { AlchemyContext } from "./AlchemyContext.ts";
15import type { AuthError, NeedsReauth } from "./Auth/AuthProvider.ts";
16import {
17 type CredentialsRequired,
18 failCredentialsRequired,
19 demandPlanCredentials,
20} from "./Auth/Demand.ts";
21import { RuntimeContext } from "./RuntimeContext.ts";
22import {
23 Artifacts,
24 ArtifactStore,
25 createArtifactStore,
26 ensureArtifactStore,
27 makeScopedArtifacts,
28} from "./Artifacts.ts";
29import {
30 type PlanDisplayOptions,
31 type PlanStatusSession,
32 type ScopedPlanStatusSession,
33 Cli,
34 noopSession,
35} from "./Report.ts";
36import type { ApplyStatus } from "./Report.ts";
37import { havePropsChanged, stripUnresolved } from "./Diff.ts";
38import type { Input } from "./Input.ts";
39import { generateInstanceId, InstanceId } from "./InstanceId.ts";
40import * as Output from "./Output.ts";
41import {
42 type ActionApply,
43 type Apply,
44 type Delete,
45 type Plan,
46} from "./Plan.ts";
47import {
48 findProviderByType,
49 missingProviderError,
50 tryFindProviderByType,
51} from "./Provider.ts";
52import { stampedMode, type ProviderMode } from "./ProviderMode.ts";
53import type { ResourceBinding } from "./Resource.ts";
54import { Stack } from "./Stack.ts";
55import { Stage } from "./Stage.ts";
56import {
57 type ActionState,
58 type CreatedResourceState,
59 type CreatingResourceState,
60 type DeletingResourceState,
61 type PersistedState,
62 type RanActionState,
63 type ReplacedResourceState,
64 type ReplacementOldResourceState,
65 type ReplacementResourceState,
66 type ReplacingResourceState,
67 type ResourceState,
68 type RunningActionState,
69 type UpdatedResourceState,
70 type UpdatingReourceState,
71 State,
72 StateStoreError,
73} from "./State/index.ts";
74import { type ResourceOp, recordResourceOp } from "./Telemetry/Metrics.ts";
75import { hashInput } from "./Util/sha256.ts";
76
77export type AppliedPlan<P extends Plan> = {
78 [id in keyof P["resources"]]: P["resources"][id] extends
79 | Delete
80 | undefined
81 | never
82 ? never
83 : Simplify<P["resources"][id]["resource"]["attr"]>;
84};
85
86export interface ApplyOptions {
87 planDisplay?: PlanDisplayOptions;
88 /** Structured event sink for non-CLI callers. */
89 session?: PlanStatusSession;
90}
91
92interface ResourceTracker {
93 output: any;
94 props: any;
95 bindings: ResourceBinding[];
96 instanceId: string;
97}
98
99const provideLifecycleScope =
100 (fqn: string, instanceId: string) =>
101 <A, E, R>(effect: Effect.Effect<A, E, R>) =>
102 Effect.serviceOption(ArtifactStore).pipe(
103 Effect.map(Option.getOrElse(createArtifactStore)),
104 Effect.flatMap((store) =>
105 effect.pipe(
106 failCredentialsRequired(fqn),
107 Effect.provideService(Artifacts, makeScopedArtifacts(store, fqn)),
108 Effect.provideService(InstanceId, instanceId),
109 ),
110 ),
111 );
112
113/**
114 * Instruments a single provider lifecycle call with an OTel span
115 * (`provider.<op>`), the resource counter / duration histogram, and the
116 * scoped artifacts/instance services normally supplied by
117 * {@link provideLifecycleScope}.
118 *
119 * This is the only call site through which provider lifecycle methods
120 * are dispatched, so wrapping it here gives us a fully-instrumented
121 * toolchain without touching any individual provider implementation.
122 */
123const instrumentLifecycle =
124 (
125 op: ResourceOp,
126 fqn: string,
127 resourceType: string,
128 logicalId: string,
129 instanceId: string,
130 ) =>
131 <A, E, R>(effect: Effect.Effect<A, E, R>) =>
132 effect.pipe(
133 provideLifecycleScope(fqn, instanceId),
134 recordResourceOp(resourceType, op),
135 Effect.withSpan(`provider.${op}`, {
136 attributes: {
137 "alchemy.resource.fqn": fqn,
138 "alchemy.resource.type": resourceType,
139 "alchemy.resource.logical_id": logicalId,
140 "alchemy.resource.instance_id": instanceId,
141 "alchemy.resource.op": op,
142 },
143 }),
144 );
145
146export const apply = <P extends Plan>(
147 plan: P,
148 options: ApplyOptions = {},
149): Effect.Effect<
150 Input.Resolve<P["output"]>,
151 | Output.InvalidReferenceError
152 | Output.MissingSourceError
153 | StateStoreError
154 | DestroyError
155 | CredentialsRequired
156 | AuthError
157 | NeedsReauth
158 | PlatformError
159 | ConfigError,
160 State | Stack | Stage
161> =>
162 Effect.gen(function* () {
163 // Credential-free dev: a dev-mode plan that needs the real cloud
164 // (`Alchemy.remote()` rows, remote-proxied bindings, deletions of rows
165 // stamped `providerMode: "live"`) demands cloud credentials exactly
166 // once, up front, BEFORE any lifecycle operation runs — a fully-local
167 // dev plan demands nothing. Non-dev runs never enter the seam: live
168 // providers keep the pre-existing lazy credential flow. Wired here (not
169 // in Deploy/Destroy) because `apply` is the single choke point every
170 // path shares — CLI deploy/destroy, `Test.make` deploys, and
171 // `test.provider` scratch stacks. See `Auth/Demand.ts`.
172 const alchemy = yield* Effect.serviceOption(AlchemyContext);
173 if (Option.isSome(alchemy) && alchemy.value.dev) {
174 yield* demandPlanCredentials(plan);
175 }
176
177 // Renderer resolution: an explicit session wins, then the ambient Cli
178 // renderer service (the bundled CLI, LoggingCli in tests, or a custom
179 // implementation), else events are dropped.
180 const session =
181 options.session ??
182 (yield* Effect.serviceOption(Cli).pipe(
183 Effect.flatMap(
184 Option.match({
185 onNone: () => Effect.succeed(noopSession),
186 onSome: (cli) => cli.startApplySession(plan, options.planDisplay),
187 }),
188 ),
189 ));
190 // The nested gen exists so `onExit` can settle the session on every
191 // exit path without introducing a scope: local dev providers start
192 // long-running processes in the AMBIENT scope during reconcile, so
193 // wrapping this body in Effect.scoped would kill them when apply
194 // returns.
195 return yield* Effect.gen(function* () {
196 const state = yield* yield* State;
197 const stack = yield* Stack;
198 const stage = yield* Stage;
199 const stackName = stack.name;
200
201 const tracker: Record<string, ResourceTracker> = {};
202 const terminalStatuses = new Map<
203 string,
204 {
205 fqn: string;
206 id: string;
207 type: string;
208 status: Extract<
209 ApplyStatus,
210 "created" | "updated" | "adopted" | "ran" | "skipped"
211 >;
212 providerMode?: ProviderMode;
213 }
214 >();
215
216 // ── FQN migrations (renamedFrom) ──
217 // Persist renames before any lifecycle operation runs: a node whose row
218 // was found under a former FQN carries that row pre-remapped in
219 // `node.state` (see Plan's rename resolution). Commit it at the current
220 // FQN FIRST, then drop the former row — in that order, so an
221 // interruption leaves rows at both FQNs with the same instanceId, which
222 // the next plan recognizes as an in-flight migration (and never as an
223 // orphan to delete).
224 //
225 // In a same-deploy shift (A→B while B→C), C's former FQN `B` is
226 // simultaneously B's migration TARGET. C must NOT delete it: B's own
227 // `state.set` supersedes the stale copy, and the migrations run
228 // concurrently — the delete could land after B's write and destroy the
229 // freshly migrated row.
230 const migrationTargets = new Set(
231 Object.values(plan.resources)
232 .filter(
233 (node) => node.renamedFrom?.length && node.state !== undefined,
234 )
235 .map((node) => node.resource.FQN),
236 );
237 yield* Effect.forEach(
238 Object.values(plan.resources),
239 (node) => {
240 const { renamedFrom, state: row } = node;
241 return renamedFrom === undefined ||
242 renamedFrom.length === 0 ||
243 row === undefined
244 ? Effect.void
245 : Effect.gen(function* () {
246 yield* state.set({
247 stack: stackName,
248 stage,
249 fqn: node.resource.FQN,
250 value: row,
251 });
252 yield* Effect.forEach(
253 renamedFrom.filter(
254 (formerFqn) => !migrationTargets.has(formerFqn),
255 ),
256 (formerFqn) =>
257 state.delete({ stack: stackName, stage, fqn: formerFqn }),
258 { concurrency: "unbounded" },
259 );
260 });
261 },
262 { concurrency: "unbounded" },
263 );
264
265 yield* executePlan(
266 plan,
267 tracker,
268 terminalStatuses,
269 session,
270 state,
271 stackName,
272 stage,
273 );
274
275 // TODO(sam): support roll back to previous state if errors occur during expansion
276 // -> RISK: some UPDATEs may not be reversible (i.e. trigger replacements)
277 // TODO(sam): should pivot be done separately? E.g shift traffic?
278
279 yield* collectGarbage(plan, session);
280
281 yield* converge(
282 plan,
283 tracker,
284 terminalStatuses,
285 session,
286 state,
287 stackName,
288 stage,
289 );
290
291 yield* Effect.forEach(
292 Array.from(terminalStatuses.values()),
293 ({ fqn, id, type, status, providerMode }) =>
294 session.emit({
295 _tag: "apply.resource.status",
296 fqn,
297 id,
298 type,
299 status,
300 providerMode,
301 }),
302 { concurrency: "unbounded" },
303 );
304
305 if (plan.destroy) {
306 // The destroy converged: every resource row was deleted above. Drop
307 // the rest of the stage's persisted state — notably the stack output
308 // record written by the last deploy — so `getOutput` returns
309 // undefined and `listStages` no longer reports the stage.
310 // https://github.com/alchemy-run/alchemy/issues/961
311 yield* state.deleteStack({ stack: stackName, stage });
312 // Invariant: a successful destroy leaves the stage EMPTY. If rows
313 // survive, this destroy session could not actually see (or delete)
314 // the stack's state — e.g. its plan listed an empty store while
315 // committed rows existed — and reporting success here would silently
316 // leak every cloud resource those rows track. Fail loudly instead so
317 // the leak surfaces in the run that caused it.
318 const remaining = yield* state.list({ stack: stackName, stage });
319 if (remaining.length > 0) {
320 return yield* Effect.fail(
321 new StateStoreError({
322 message:
323 `destroy of ${stackName}/${stage} reported success but ${remaining.length} ` +
324 `state row(s) remain (${remaining.join(", ")}) — the destroy session could ` +
325 `not see the stack's persisted state, so its cloud resources were NOT deleted`,
326 }),
327 );
328 }
329 return undefined;
330 }
331
332 if (plan.selectedFqns !== undefined || !plan.output) {
333 return undefined;
334 }
335
336 const outputs = Object.fromEntries(
337 Object.entries(tracker).map(([fqn, t]) => [fqn, t.output]),
338 );
339 const resolved = yield* Output.evaluate(plan.output, outputs);
340
341 // Persist the stack's evaluated outputs so cross-stack references
342 // (`yield* OtherStack` / `OtherStack.stage.<name>` / `Output.stackRef`)
343 // can read them back out of the state store.
344 yield* state.setOutput({ stack: stackName, stage, value: resolved });
345
346 return resolved;
347 }).pipe(
348 Effect.onExit((exit) =>
349 session.done(Exit.isSuccess(exit) ? "success" : "failure"),
350 ),
351 );
352 }).pipe(
353 ensureArtifactStore,
354 Effect.withSpan("apply", {
355 attributes: {
356 "alchemy.resources.count": Object.keys(plan.resources).length,
357 "alchemy.deletions.count": Object.keys(plan.deletions).length,
358 },
359 }),
360 );
361
362// ── Phase 1: concurrent initial execution ──────────────────────────────────
363//
364// Each resource gets a Deferred<void> that signals "I have some output
365// available in `tracker`." Resources with `precreate` signal early so that
366// downstream resources can resolve stable identifiers without deadlocking.
367// The actual output lives in the mutable `tracker` map, not in the Deferred.
368
369const executePlan = Effect.fn(function* (
370 plan: Plan,
371 tracker: Record<string, ResourceTracker>,
372 terminalStatuses: Map<
373 string,
374 {
375 fqn: string;
376 id: string;
377 type: string;
378 status: Extract<
379 ApplyStatus,
380 "created" | "updated" | "adopted" | "ran" | "skipped"
381 >;
382 providerMode?: ProviderMode;
383 }
384 >,
385 session: PlanStatusSession,
386 state: {
387 set: <V extends PersistedState>(req: {
388 stack: string;
389 stage: string;
390 fqn: string;
391 value: V;
392 }) => Effect.Effect<V, StateStoreError, never>;
393 },
394 stackName: string,
395 stage: string,
396) {
397 // Resources and tasks share the same FQN namespace and DAG, so the
398 // scheduler tracks them together. Each entry gets a single Deferred that
399 // signals "my output is available in `tracker`."
400 const allNodes: Record<string, Apply | ActionApply> = {
401 ...plan.resources,
402 ...plan.actions,
403 };
404
405 const ready = Object.fromEntries(
406 yield* Effect.all(
407 Object.keys(allNodes).map((fqn) =>
408 Effect.map(Deferred.make<void>(), (d) => [fqn, d] as const),
409 ),
410 ),
411 ) as Record<string, Deferred.Deferred<void>>;
412
413 // `readyStable` fires only when a node has reached its TERMINAL output —
414 // resources after `reconcile`, tasks after the body completes. Resource
415 // precreate stubs do NOT signal `readyStable`, so any consumer that
416 // requires stable inputs (e.g. Tasks) waits past the precreate phase.
417 const readyStable = Object.fromEntries(
418 yield* Effect.all(
419 Object.keys(allNodes).map((fqn) =>
420 Effect.map(Deferred.make<void>(), (d) => [fqn, d] as const),
421 ),
422 ),
423 ) as Record<string, Deferred.Deferred<void>>;
424
425 const getOutputs = (): Record<string, any> =>
426 Object.fromEntries(
427 Object.entries(tracker).map(([fqn, t]) => [fqn, t.output]),
428 );
429
430 const waitForDeps = (downstreamFqn: string, fqns: string[]) =>
431 Effect.all(
432 fqns
433 .filter((fqn) => fqn in ready)
434 .map((fqn) =>
435 // Non-cycle upstreams must be observed at their TERMINAL output
436 // (`readyStable`), not their early precreate signal (`ready`). This
437 // is what makes a failed upstream actually interrupt its downstream:
438 // a resource with `precreate` resolves `ready` before its real
439 // `reconcile` runs, so a downstream waiting on `ready` would proceed
440 // (and even finish) even though the upstream's reconcile later
441 // failed. Waiting on `readyStable` means the downstream's
442 // `waitForDeps` short-circuits with the upstream's failure cause.
443 //
444 // Cycle members are the exception: peers in an SCC depend on each
445 // other, so they must rendezvous on the early `ready`/precreate
446 // signal to break the deadlock. Phase 3 (`converge`) re-runs them
447 // against final outputs once the cycle settles. A downstream
448 // outside the cycle must still wait for reconcile: e.g. a queue
449 // consumer cannot attach to a Worker's fetch-only precreate stub.
450 plan.cycleMembers.has(downstreamFqn) && plan.cycleMembers.has(fqn)
451 ? Deferred.await(ready[fqn])
452 : Deferred.await(readyStable[fqn]),
453 ),
454 { concurrency: "unbounded" },
455 );
456
457 const waitForStableDeps = (fqns: string[]) =>
458 Effect.all(
459 fqns
460 .filter((fqn) => fqn in readyStable)
461 .map((fqn) => Deferred.await(readyStable[fqn])),
462 { concurrency: "unbounded" },
463 );
464
465 const failures: LifecycleFailure[] = [];
466
467 yield* Effect.all(
468 Object.entries(allNodes).map(([fqn, node]) =>
469 (node as ActionApply).kind === "action"
470 ? executeActionNode(
471 fqn,
472 node as ActionApply,
473 tracker,
474 ready,
475 readyStable,
476 terminalStatuses,
477 session,
478 state,
479 stackName,
480 stage,
481 getOutputs,
482 waitForStableDeps,
483 failures,
484 )
485 : executeNode(
486 fqn,
487 node as Apply,
488 tracker,
489 ready,
490 readyStable,
491 terminalStatuses as any,
492 session,
493 state,
494 stackName,
495 stage,
496 getOutputs,
497 (fqns) => waitForDeps(fqn, fqns),
498 failures,
499 plan.cycleMembers.has(fqn),
500 ),
501 ),
502 { concurrency: "unbounded" },
503 );
504
505 if (failures.length > 0) {
506 // Aggregate every collected lifecycle failure into a single parallel Cause
507 // so the apply ends with one combined error containing every distinct
508 // failure / defect that occurred across the concurrent fibers.
509 return yield* Effect.failCause(
510 failures.map((f) => f.cause).reduce(Cause.combine),
511 );
512 }
513});
514
515interface LifecycleFailure {
516 fqn: string;
517 logicalId: string;
518 type: string;
519 cause: Cause.Cause<unknown>;
520}
521
522/**
523 * First human-readable line of a lifecycle failure, for the per-resource
524 * `fail` status event. The full cause is still aggregated and raised when
525 * the whole apply settles — this exists so a failed row explains itself the
526 * moment it fails instead of only after every sibling has finished.
527 * `undefined` for interrupt-only causes (nothing failed, the run was
528 * cancelled).
529 */
530const failureMessage = (cause: Cause.Cause<unknown>): string | undefined => {
531 for (const reason of cause.reasons) {
532 const error = Cause.isFailReason(reason)
533 ? reason.error
534 : Cause.isDieReason(reason)
535 ? reason.defect
536 : undefined;
537 if (error === undefined) continue;
538 const tag =
539 Predicate.hasProperty(error, "_tag") && typeof error._tag === "string"
540 ? error._tag
541 : undefined;
542 const message =
543 Predicate.hasProperty(error, "message") &&
544 typeof error.message === "string"
545 ? error.message
546 : String(error);
547 const line = message.split("\n", 1)[0]!;
548 return tag !== undefined && !line.includes(tag) ? `${tag}: ${line}` : line;
549 }
550 return undefined;
551};
552
553const executeNode = (
554 fqn: string,
555 node: Apply,
556 tracker: Record<string, ResourceTracker>,
557 ready: Record<string, Deferred.Deferred<void>>,
558 readyStable: Record<string, Deferred.Deferred<void>>,
559 terminalStatuses: Map<
560 string,
561 {
562 fqn: string;
563 id: string;
564 type: string;
565 status: Extract<ApplyStatus, "created" | "updated" | "adopted">;
566 providerMode?: ProviderMode;
567 }
568 >,
569 session: PlanStatusSession,
570 state: {
571 set: <V extends ResourceState>(req: {
572 stack: string;
573 stage: string;
574 fqn: string;
575 value: V;
576 }) => Effect.Effect<V, StateStoreError, never>;
577 },
578 stackName: string,
579 stage: string,
580 getOutputs: () => Record<string, any>,
581 waitForDeps: (fqns: string[]) => Effect.Effect<void[], never, never>,
582 failures: LifecycleFailure[],
583 inCycle: boolean,
584): Effect.Effect<void, never, never> =>
585 Effect.gen(function* () {
586 const logicalId = node.resource.LogicalId;
587 const namespace = node.resource.Namespace;
588
589 const commit = <S extends ResourceState>(value: Omit<S, "namespace">) =>
590 state.set({
591 stack: stackName,
592 stage,
593 fqn,
594 // Early commits (`creating`/`replacing`) persist plan props that may
595 // still hold unresolved Output exprs; strip them so state stores only
596 // plain data (see stripUnresolved in Diff.ts).
597 //
598 // Binding rows follow the same rule. Even the RESOLVED binding
599 // payload can carry Effect leaves — a tagged Worker/Function class in
600 // `env` (the circular-bindings pattern) is a function-typed Effect
601 // that `Output.evaluate` passes through untouched. A JSON state store
602 // would persist it via its `toJSON` as an `{"_id":"Effect",...}`
603 // relic, which the next plan's `diffBindings` compares against the
604 // live class stripped to `undefined` — a phantom binding "update" on
605 // every deploy, forever. Stripping at the commit boundary keeps both
606 // store kinds consistent with the comparison in `havePropsChanged`.
607 value: {
608 ...value,
609 adoptionBlocked: value.adoptionBlocked ?? node.adoptionBlocked,
610 props: stripUnresolved(value.props),
611 bindings: stripUnresolved(value.bindings),
612 namespace,
613 } as S,
614 });
615
616 const scopedSession = {
617 ...session,
618 note: (note, options?) =>
619 session.emit({
620 fqn,
621 id: logicalId,
622 _tag: "apply.resource.note",
623 message: note,
624 kind: options?.kind,
625 }),
626 } satisfies ScopedPlanStatusSession;
627
628 // On a mode-switch replacement (local ⇄ live) surface the transition:
629 // the old generation's stamped mode → the mode resolved for this run.
630 const fromProviderMode =
631 node.action === "replace" &&
632 node.mode !== undefined &&
633 node.state.providerMode !== undefined &&
634 node.state.providerMode !== node.mode
635 ? node.state.providerMode
636 : undefined;
637
638 const report = (status: ApplyStatus) =>
639 session.emit({
640 _tag: "apply.resource.status",
641 fqn,
642 id: logicalId,
643 type: node.resource.Type,
644 status,
645 providerMode: node.mode,
646 fromProviderMode,
647 });
648
649 const markTerminal = (status: "created" | "updated") =>
650 Effect.gen(function* () {
651 terminalStatuses.set(fqn, {
652 fqn,
653 id: logicalId,
654 type: node.resource.Type,
655 status,
656 providerMode: node.mode,
657 });
658 // A local dev instance announces where it's serving: any
659 // local-mode row whose fresh Attributes carry a string `url`
660 // (Workers expose their dev-proxy URL this way) gets a
661 // `[id] ready at http://localhost:1337` line.
662 if (node.mode === "local") {
663 const url = (tracker[fqn]?.output as { url?: unknown })?.url;
664 if (typeof url === "string" && url.length > 0) {
665 yield* scopedSession.note(`ready at ${url}`);
666 }
667 }
668 // Emit immediately so the CLI surfaces the terminal status as soon
669 // as the resource is actually done — instead of batching every
670 // resource's "created"/"updated" event to the end of apply(), which
671 // makes long-running siblings appear stuck in "creating" until the
672 // entire deploy finishes.
673 //
674 // Cycle members are exempt: their initial pass produces an
675 // intermediate result that Phase 3 (`converge`) will overwrite once
676 // the SCC reaches a fixed point. Emitting "created"/"updated" here
677 // would surface that intermediate state to the CLI before the real
678 // terminal status is known. Their final status is flushed from
679 // `terminalStatuses` after `converge` completes.
680 if (inCycle) return;
681 yield* session.emit({
682 _tag: "apply.resource.status",
683 fqn,
684 id: logicalId,
685 type: node.resource.Type,
686 status,
687 providerMode: node.mode,
688 });
689 });
690
691 const signalReady = Deferred.succeed(ready[fqn], void 0);
692 // Signal only after reconcile completes — never during precreate. Tasks
693 // (and any other consumer that calls `waitForStableDeps`) block on this
694 // so they observe the resource's final attrs rather than a stub.
695 const signalReadyStable = Deferred.succeed(readyStable[fqn], void 0);
696
697 const storeAndSignal = (t: ResourceTracker) =>
698 Effect.gen(function* () {
699 tracker[fqn] = t;
700 yield* signalReady;
701 });
702
703 // ── noop ──
704
705 if (node.action === "noop") {
706 // No work to do on the cloud resource — the persisted attr is already
707 // stable. Two pieces of row METADATA can still have drifted from the
708 // declaration, and this is the only pass that will ever see them:
709 //
710 // 1. `resourceType` — the row was persisted under a legacy type name
711 // (the type was since renamed and carries the old name as an
712 // alias); migrate it so the state stops depending on the alias.
713 // 2. `removalPolicy` — `RemovalPolicy.retain()` / `.destroy()` is a
714 // decoration on the declaration, not a prop, so changing it never
715 // produces a diff. Without this commit the new policy would never
716 // reach state, and the orphan delete (which reads the policy from
717 // the persisted row, see `Plan.ts`'s delete node) would act on the
718 // stale one — destroying a resource the user had marked `retain`.
719 // See https://github.com/alchemy-run/alchemy/issues/1248.
720 // 3. `downstream` — a dependent that repins from resource A to B
721 // (e.g. a HostnameAssociation switching certificates) updates
722 // *itself*, while A and B are both noops. Delete ordering reads the
723 // persisted `downstream` of the resource being deleted, so without
724 // this commit a later destroy deletes B concurrently with the
725 // dependent that still references it (and needlessly waits on A).
726 const policyChanged =
727 node.state.removalPolicy !== node.resource.RemovalPolicy;
728 const downstreamChanged = !sameSet(
729 node.state.downstream ?? [],
730 node.downstream,
731 );
732 if (
733 node.state.resourceType !== node.resource.Type ||
734 policyChanged ||
735 downstreamChanged
736 ) {
737 yield* commit({
738 ...node.state,
739 resourceType: node.resource.Type,
740 removalPolicy: node.resource.RemovalPolicy,
741 downstream: node.downstream,
742 });
743 }
744 // A policy flip is otherwise invisible (the row is a noop), and it is
745 // exactly the change a user wants confirmation of. Legacy rows with no
746 // persisted policy normalize silently — there is nothing to report.
747 if (policyChanged && node.state.removalPolicy !== undefined) {
748 yield* scopedSession.note(
749 `removal policy ${node.state.removalPolicy} → ${node.resource.RemovalPolicy}`,
750 );
751 }
752 yield* storeAndSignal({
753 output: node.state.attr,
754 props: node.state.props,
755 bindings: node.state.bindings ?? [],
756 instanceId: node.state.instanceId,
757 });
758 yield* signalReadyStable;
759 return;
760 }
761
762 const allUpstreamFqns = () => {
763 const propDeps = Object.keys(Output.resolveUpstream(node.props));
764 const bindingDeps = Object.keys(Output.resolveUpstream(node.bindings));
765 return [...new Set([...propDeps, ...bindingDeps])];
766 };
767
768 // ── instance ID ──
769
770 const instanceId = yield* Effect.gen(function* () {
771 if (node.action === "create" && !node.state?.instanceId) {
772 const id = yield* generateInstanceId();
773 yield* commit<CreatingResourceState>({
774 status: "creating",
775 fqn,
776 logicalId,
777 instanceId: id,
778 downstream: node.downstream,
779 props: node.props,
780 providerVersion: node.provider.version ?? 0,
781 resourceType: node.resource.Type,
782 bindings: excludeDeletedBindings(node.bindings),
783 removalPolicy: node.resource.RemovalPolicy,
784 providerMode: node.mode,
785 adoptionBlocked: node.adoptionBlocked,
786 });
787 return id;
788 } else if (node.action === "replace") {
789 if (
790 (node.state.status === "replaced" ||
791 node.state.status === "replacing") &&
792 !node.restart
793 ) {
794 // Ordinary replacement recovery keeps using the same replacement
795 // generation. Only `restart` is allowed to mint a new instance id.
796 return node.state.instanceId;
797 }
798 const id = yield* generateInstanceId();
799 yield* commit<ReplacingResourceState>({
800 status: "replacing",
801 fqn,
802 logicalId,
803 instanceId: id,
804 downstream: node.downstream,
805 props: node.props,
806 providerVersion: node.provider.version ?? 0,
807 resourceType: node.resource.Type,
808 bindings: excludeDeletedBindings(node.bindings),
809 old: node.state,
810 deleteFirst: node.deleteFirst,
811 removalPolicy: node.resource.RemovalPolicy,
812 providerMode: node.mode,
813 });
814 return id;
815 } else if (node.state?.instanceId) {
816 return node.state.instanceId;
817 }
818 return yield* Effect.die(
819 `Instance ID not found for resource '${logicalId}' and action is '${node.action}'`,
820 );
821 });
822
823 // ── lifecycle ──
824
825 yield* Effect.gen(function* () {
826 // ── create ──
827 if (node.action === "create") {
828 if (!node.state) {
829 // First persistence point for a brand new logical resource. Once this is
830 // written, retries know they should resume creation instead of planning
831 // another fresh create from scratch.
832 yield* commit<CreatingResourceState>({
833 status: "creating",
834 fqn,
835 logicalId,
836 instanceId,
837 resourceType: node.resource.Type,
838 props: node.props,
839 attr: undefined,
840 providerVersion: node.provider.version ?? 0,
841 bindings: excludeDeletedBindings(node.bindings),
842 downstream: node.downstream,
843 removalPolicy: node.resource.RemovalPolicy,
844 providerMode: node.mode,
845 adoptionBlocked: node.adoptionBlocked,
846 });
847 }
848
849 let attr: any = node.state?.attr;
850
851 if (attr !== undefined) {
852 // Precreate/read may already have produced a usable output snapshot. Publish
853 // it early so downstream resources can start resolving against it.
854 yield* storeAndSignal({
855 output: attr,
856 props: {},
857 bindings: [],
858 instanceId,
859 });
860 }
861
862 if (node.provider.precreate && attr === undefined) {
863 // Some resources need a placeholder physical resource before their real
864 // create can finish. Persist that stub so downstream evaluation can proceed.
865 yield* report("pre-creating");
866 attr = yield* node.provider
867 .precreate({
868 id: logicalId,
869 fqn,
870 news: node.props,
871 session: scopedSession,
872 instanceId,
873 bindings: excludeDeletedBindings(node.bindings),
874 })
875 .pipe(
876 instrumentLifecycle(
877 "precreate",
878 fqn,
879 node.resource.Type,
880 logicalId,
881 instanceId,
882 ),
883 );
884 yield* commit<CreatingResourceState>({
885 status: "creating",
886 fqn,
887 logicalId,
888 instanceId,
889 resourceType: node.resource.Type,
890 props: node.props,
891 attr,
892 providerVersion: node.provider.version ?? 0,
893 bindings: excludeDeletedBindings(node.bindings),
894 downstream: node.downstream,
895 removalPolicy: node.resource.RemovalPolicy,
896 providerMode: node.mode,
897 adoptionBlocked: node.adoptionBlocked,
898 });
899 yield* storeAndSignal({
900 output: attr,
901 props: {},
902 bindings: [],
903 instanceId,
904 });
905 }
906
907 // While we're waiting on upstream outputs the resource isn't actually
908 // creating anything yet — surface that as "pending" so the CLI doesn't
909 // look stuck in "creating" for slow-upstream deploys.
910 yield* report("pending");
911
912 // Create runs against fully resolved upstream outputs and bindings, not the
913 // raw Output expressions stored in the plan.
914 yield* waitForDeps(allUpstreamFqns());
915
916 yield* report("creating");
917 const outputs = getOutputs();
918
919 const news = (yield* Output.evaluate(node.props, outputs)) as Record<
920 string,
921 any
922 >;
923
924 const bindingOutputs = excludeDeletedBindings(
925 yield* Output.evaluate(node.bindings, outputs),
926 );
927
928 if (attr === undefined && node.deferredAdoption && node.provider.read) {
929 const checkpoint = () =>
930 commit<CreatingResourceState>({
931 status: "creating",
932 fqn,
933 logicalId,
934 instanceId,
935 resourceType: node.resource.Type,
936 props: news,
937 attr,
938 providerVersion: node.provider.version ?? 0,
939 bindings: bindingOutputs,
940 downstream: node.downstream,
941 removalPolicy: node.resource.RemovalPolicy,
942 providerMode: node.mode,
943 adoptionBlocked: node.adoptionBlocked,
944 });
945 // Refusal or interruption must retain identity, never foreign attributes.
946 yield* checkpoint();
947 const observed = yield* node.provider
948 .read({
949 id: logicalId,
950 fqn,
951 instanceId,
952 olds: news,
953 output: undefined,
954 })
955 .pipe(
956 instrumentLifecycle(
957 "read",
958 fqn,
959 node.resource.Type,
960 logicalId,
961 instanceId,
962 ),
963 );
964 if (observed !== undefined) {
965 if (Unowned.is(observed) && !node.deferredAdoption.adopt) {
966 return yield* new OwnedBySomeoneElse({
967 message:
968 `Cannot adopt resource '${fqn}' (${node.resource.Type}): ` +
969 "it exists in the cloud but is not owned by this " +
970 "stack/stage/logical-id. Re-run with `--adopt` (or " +
971 "wrap the effect in `adopt(true)`) to take it over.",
972 resourceType: node.resource.Type,
973 logicalId,
974 });
975 }
976 attr = stripUnowned(observed);
977 // A creating checkpoint retains olds: undefined on retry.
978 yield* checkpoint();
979 }
980 }
981
982 attr = yield* node.provider
983 .reconcile({
984 id: logicalId,
985 fqn,
986 news,
987 instanceId,
988 bindings: bindingOutputs,
989 session: scopedSession,
990 olds: undefined,
991 output: attr,
992 })
993 .pipe(
994 instrumentLifecycle(
995 "create",
996 fqn,
997 node.resource.Type,
998 logicalId,
999 instanceId,
1000 ),
1001 );
1002
1003 yield* commit<CreatedResourceState>({
1004 status: "created",
1005 fqn,
1006 logicalId,
1007 instanceId,
1008 resourceType: node.resource.Type,
1009 props: news,
1010 attr,
1011 // Terminal commits persist the RESOLVED binding payload the
1012 // provider actually reconciled with, not the raw plan-time
1013 // expressions. Raw `node.bindings` may hold unresolved Outputs
1014 // (silently dropped by JSON state stores), so persisting them
1015 // makes the next plan's `diffBindings` compare a lossy stored
1016 // shape against fully-resolved data — a phantom binding update
1017 // on every plan (#874).
1018 bindings: bindingOutputs,
1019 providerVersion: node.provider.version ?? 0,
1020 downstream: node.downstream,
1021 removalPolicy: node.resource.RemovalPolicy,
1022 providerMode: node.mode,
1023 });
1024
1025 tracker[fqn] = {
1026 output: attr,
1027 props: news,
1028 bindings: bindingOutputs,
1029 instanceId,
1030 };
1031 yield* signalReady;
1032 yield* signalReadyStable;
1033
1034 yield* markTerminal("created");
1035 return;
1036 }
1037
1038 // ── update ──
1039 if (node.action === "update" || node.action === "adopted") {
1040 // Cycle members publish their previous live attr *before* waiting on
1041 // upstreams so the SCC can converge — peers in the cycle would
1042 // otherwise deadlock waiting on each other. Phase 3 (`converge`)
1043 // re-runs each peer's update against fresh outputs once everyone
1044 // has settled.
1045 //
1046 // Linear (DAG) update nodes skip this entirely and simply wait for
1047 // fresh upstream outputs, mirroring the create flow. This is the
1048 // important property: a downstream of a non-cycle update never
1049 // observes the upstream's stale attr, which prevents wasted/
1050 // destructive intermediate updates (e.g. a Worker deploying with
1051 // stale Build assets).
1052 if (inCycle) {
1053 yield* storeAndSignal({
1054 output: node.state.attr,
1055 props: node.state.props,
1056 bindings: node.state.bindings ?? [],
1057 instanceId,
1058 });
1059 }
1060
1061 // See create-flow note: while we're waiting on upstream outputs
1062 // this resource isn't actually updating yet.
1063 yield* report("pending");
1064 yield* waitForDeps(allUpstreamFqns());
1065 const outputs = getOutputs();
1066
1067 const news = (yield* Output.evaluate(node.props, outputs)) as Record<
1068 string,
1069 any
1070 >;
1071 const adopting =
1072 node.adopting === true ||
1073 (node.state.status === "updating" && node.state.adopting === true);
1074
1075 yield* node.state.status === "replaced"
1076 ? commit<ReplacedResourceState>({
1077 // Keep the replacement wrapper intact while changing the live
1078 // replacement props; GC still has older generations to delete.
1079 ...node.state,
1080 attr: node.state.attr,
1081 props: news,
1082 providerMode: node.mode,
1083 })
1084 : commit<UpdatingReourceState>({
1085 // For ordinary updates we snapshot the previously stable props/attrs
1086 // once, so retries can continue from the same baseline.
1087 status: "updating",
1088 fqn,
1089 logicalId,
1090 instanceId,
1091 resourceType: node.resource.Type,
1092 props: news,
1093 attr: node.state.attr,
1094 providerVersion: node.provider.version ?? 0,
1095 bindings: excludeDeletedBindings(node.bindings),
1096 downstream: node.downstream,
1097 old:
1098 node.state.status === "updating" ? node.state.old : node.state,
1099 adopting: adopting ? true : undefined,
1100 removalPolicy: node.resource.RemovalPolicy,
1101 providerMode: node.mode,
1102 });
1103
1104 yield* report(node.action === "adopted" ? "adopting" : "updating");
1105
1106 const previousProps = adopting
1107 ? undefined
1108 : node.state.status === "created" ||
1109 node.state.status === "updated" ||
1110 node.state.status === "replaced"
1111 ? node.state.props
1112 : node.state.old.props;
1113
1114 // Providers receive the resolved binding payload for this exact pass, while
1115 // `previousProps` tells them what state the live resource is being updated from.
1116 const bindingOutputs = excludeDeletedBindings(
1117 yield* Output.evaluate(node.bindings, outputs),
1118 );
1119
1120 const attr = yield* node.provider
1121 .reconcile({
1122 id: logicalId,
1123 fqn,
1124 news,
1125 instanceId,
1126 bindings: bindingOutputs,
1127 session: scopedSession,
1128 olds: previousProps,
1129 output: node.state.attr,
1130 })
1131 .pipe(
1132 instrumentLifecycle(
1133 "update",
1134 fqn,
1135 node.resource.Type,
1136 logicalId,
1137 instanceId,
1138 ),
1139 );
1140
1141 if (node.state.status === "replaced") {
1142 yield* commit<ReplacedResourceState>({
1143 // The live replacement changed, but cleanup of older generations still
1144 // has to continue afterwards.
1145 ...node.state,
1146 attr,
1147 props: news,
1148 // Resolved payload, not raw `node.bindings` — see create commit.
1149 bindings: bindingOutputs,
1150 providerMode: node.mode,
1151 });
1152 } else {
1153 yield* commit<UpdatedResourceState>({
1154 status: "updated",
1155 fqn,
1156 logicalId,
1157 instanceId,
1158 resourceType: node.resource.Type,
1159 props: news,
1160 attr,
1161 // Resolved payload, not raw `node.bindings` — see create commit.
1162 bindings: bindingOutputs,
1163 providerVersion: node.provider.version ?? 0,
1164 downstream: node.downstream,
1165 removalPolicy: node.resource.RemovalPolicy,
1166 providerMode: node.mode,
1167 });
1168 }
1169
1170 tracker[fqn] = {
1171 output: attr,
1172 props: news,
1173 bindings: bindingOutputs,
1174 instanceId,
1175 };
1176 // Signal here for the linear (non-cycle) path. For in-cycle updates
1177 // the deferred has already been resolved by the early `storeAndSignal`
1178 // above and `signalReady` is a no-op the second time.
1179 yield* signalReady;
1180 yield* signalReadyStable;
1181
1182 if (node.action === "adopted") {
1183 terminalStatuses.set(fqn, {
1184 fqn,
1185 id: logicalId,
1186 type: node.resource.Type,
1187 status: "adopted",
1188 providerMode: node.mode,
1189 });
1190 if (!inCycle) yield* report("adopted");
1191 } else {
1192 yield* markTerminal("updated");
1193 }
1194 return;
1195 }
1196
1197 // ── replace ──
1198 if (node.action === "replace") {
1199 if (node.state.status === "replaced" && !node.restart) {
1200 // The replacement already exists; this pass only needs GC to clean up
1201 // older generations, so expose the current replacement and stop here.
1202 tracker[fqn] = {
1203 output: node.state.attr,
1204 props: node.state.props,
1205 bindings: node.state.bindings ?? [],
1206 instanceId,
1207 };
1208 yield* signalReady;
1209 yield* signalReadyStable;
1210 yield* markTerminal("created");
1211 return;
1212 }
1213
1214 let replState: ReplacingResourceState;
1215 if (node.state.status !== "replacing" || node.restart) {
1216 // `restart` deliberately nests the previous top-level replacement state
1217 // into `old`, creating a new outer generation to replace it.
1218 replState = yield* commit<ReplacingResourceState>({
1219 status: "replacing",
1220 fqn,
1221 logicalId,
1222 instanceId,
1223 resourceType: node.resource.Type,
1224 props: node.props,
1225 bindings: excludeDeletedBindings(node.bindings),
1226 attr: undefined,
1227 providerVersion: node.provider.version ?? 0,
1228 deleteFirst: node.deleteFirst,
1229 old: node.state,
1230 downstream: node.downstream,
1231 removalPolicy: node.resource.RemovalPolicy,
1232 providerMode: node.mode,
1233 });
1234 } else {
1235 // Resume the same replacement generation after an interrupted apply.
1236 replState = node.state;
1237 }
1238
1239 // ── delete-first replacements ──
1240 //
1241 // By default a replacement is create-first: the new generation is
1242 // created here and the old generation(s) are reclaimed afterwards by
1243 // `collectGarbage` (Phase 2). That ordering keeps the old resource
1244 // alive if the create fails, but it is wrong for resources whose
1245 // replacement cannot coexist with the original — a fixed physical
1246 // name, a singleton, etc. Those providers return
1247 // `{ action: "replace", deleteFirst: true }`.
1248 //
1249 // When `deleteFirst` is set we tear the previous generation(s) down
1250 // BEFORE creating the new one, and commit the result as a terminal
1251 // `created` state (rather than `replaced`) so Phase 2 has no old chain
1252 // left to drain. `delete` is required to be idempotent, so a re-run
1253 // after an interrupted apply simply re-converges.
1254 const deleteOldGenerations = (
1255 old: ReplacementOldResourceState,
1256 ): Effect.Effect<void, any, any> =>
1257 Effect.gen(function* () {
1258 const retain = node.resource.RemovalPolicy === "retain";
1259 if (old.attr !== undefined && !retain) {
1260 // Delete each old generation with the provider variant of the
1261 // mode that created it — after a local ⇄ live switch,
1262 // `node.provider` (the new mode) cannot tear down the other
1263 // runtime's instance. Unstamped rows (legacy or written by a
1264 // mode-agnostic provider) are physically live, unless their
1265 // attrs carry the `dev:` identity marker — see stampedMode.
1266 const oldProvider = yield* findProviderByType(
1267 node.resource.Type,
1268 stampedMode(old),
1269 );
1270 yield* oldProvider
1271 .delete({
1272 id: logicalId,
1273 fqn,
1274 instanceId: old.instanceId,
1275 olds: old.props as never,
1276 output: old.attr,
1277 session: scopedSession,
1278 bindings: [],
1279 })
1280 .pipe(
1281 instrumentLifecycle(
1282 "delete",
1283 fqn,
1284 node.resource.Type,
1285 logicalId,
1286 old.instanceId,
1287 ),
1288 );
1289 }
1290 if (old.status === "replacing" || old.status === "replaced") {
1291 yield* deleteOldGenerations(old.old);
1292 }
1293 });
1294
1295 if (node.deleteFirst) {
1296 yield* scopedSession.note(
1297 "Deleting previous resource before creating its replacement (deleteFirst)...",
1298 );
1299 yield* deleteOldGenerations(replState.old);
1300 }
1301
1302 let attr: any = replState.attr;
1303
1304 if (attr !== undefined) {
1305 // If precreate already ran, expose that intermediate output immediately so
1306 // downstream resources can resolve against the same in-flight replacement.
1307 yield* storeAndSignal({
1308 output: attr,
1309 props: {},
1310 bindings: [],
1311 instanceId,
1312 });
1313 }
1314
1315 if (node.provider.precreate && attr === undefined) {
1316 yield* report("pre-creating");
1317 attr = yield* node.provider
1318 .precreate({
1319 id: logicalId,
1320 fqn,
1321 news: node.props,
1322 session: scopedSession,
1323 instanceId,
1324 bindings: excludeDeletedBindings(node.bindings),
1325 })
1326 .pipe(
1327 instrumentLifecycle(
1328 "precreate",
1329 fqn,
1330 node.resource.Type,
1331 logicalId,
1332 instanceId,
1333 ),
1334 );
1335 yield* commit<ReplacingResourceState>({
1336 status: "replacing",
1337 fqn,
1338 logicalId,
1339 instanceId,
1340 resourceType: node.resource.Type,
1341 props: node.props,
1342 attr,
1343 providerVersion: node.provider.version ?? 0,
1344 bindings: excludeDeletedBindings(node.bindings),
1345 downstream: node.downstream,
1346 old: replState.old,
1347 deleteFirst: node.deleteFirst,
1348 removalPolicy: node.resource.RemovalPolicy,
1349 providerMode: node.mode,
1350 });
1351 yield* storeAndSignal({
1352 output: attr,
1353 props: {},
1354 bindings: [],
1355 instanceId,
1356 });
1357 }
1358
1359 // See create-flow note: while we're waiting on upstream outputs
1360 // the replacement isn't actually being created yet.
1361 yield* report("pending");
1362
1363 // Replacement create is evaluated exactly like create, but against the new
1364 // generation's instance id and with the previous generations preserved in `old`.
1365 yield* waitForDeps(allUpstreamFqns());
1366
1367 yield* report("creating replacement");
1368 const outputs = getOutputs();
1369
1370 const news = (yield* Output.evaluate(node.props, outputs)) as Record<
1371 string,
1372 any
1373 >;
1374
1375 const bindingOutputs = excludeDeletedBindings(
1376 yield* Output.evaluate(node.bindings, outputs),
1377 );
1378
1379 attr = yield* node.provider
1380 .reconcile({
1381 id: logicalId,
1382 fqn,
1383 news,
1384 instanceId,
1385 bindings: bindingOutputs,
1386 session: scopedSession,
1387 olds: undefined,
1388 output: attr,
1389 })
1390 .pipe(
1391 instrumentLifecycle(
1392 "create",
1393 fqn,
1394 node.resource.Type,
1395 logicalId,
1396 instanceId,
1397 ),
1398 );
1399
1400 if (node.deleteFirst) {
1401 // The old generation(s) were already torn down above, so there is
1402 // nothing left for `collectGarbage` to drain — collapse straight to
1403 // the terminal `created` state.
1404 yield* commit<CreatedResourceState>({
1405 status: "created",
1406 fqn,
1407 logicalId,
1408 instanceId,
1409 resourceType: node.resource.Type,
1410 props: news,
1411 attr,
1412 providerVersion: node.provider.version ?? 0,
1413 // Resolved payload, not raw `node.bindings` — see create commit.
1414 bindings: bindingOutputs,
1415 downstream: node.downstream,
1416 removalPolicy: node.resource.RemovalPolicy,
1417 providerMode: node.mode,
1418 });
1419 } else {
1420 yield* commit<ReplacedResourceState>({
1421 // Creation of the new generation succeeded; from here on the only remaining
1422 // work is draining the old chain via garbage collection.
1423 status: "replaced",
1424 fqn,
1425 logicalId,
1426 instanceId,
1427 resourceType: node.resource.Type,
1428 props: news,
1429 attr,
1430 providerVersion: node.provider.version ?? 0,
1431 // Resolved payload, not raw `node.bindings` — see create commit.
1432 bindings: bindingOutputs,
1433 downstream: node.downstream,
1434 // Preserve the remaining backlog exactly as-is. GC is responsible for
1435 // popping one generation at a time until the chain is exhausted.
1436 old: replState.old,
1437 deleteFirst: node.deleteFirst,
1438 removalPolicy: node.resource.RemovalPolicy,
1439 providerMode: node.mode,
1440 });
1441 }
1442
1443 tracker[fqn] = {
1444 output: attr,
1445 props: news,
1446 bindings: bindingOutputs,
1447 instanceId,
1448 };
1449 yield* signalReady;
1450 yield* signalReadyStable;
1451
1452 // Keep progress anchored to the live replacement while GC drains the
1453 // previous generation(s) in the background.
1454 yield* markTerminal("created");
1455 return;
1456 }
1457
1458 return yield* Effect.die(`Unknown action: ${node.action}`);
1459 });
1460 }).pipe(
1461 Effect.catchCause((cause) =>
1462 // Record the failure, propagate it to any downstream resources waiting on
1463 // our Deferred (so their waitForDeps short-circuits instead of deadlocking),
1464 // emit a "fail" status to the session, and resolve to void so Effect.all
1465 // does not interrupt sibling fibers. The aggregated cause is raised at
1466 // the end of executePlan.
1467 Effect.gen(function* () {
1468 failures.push({
1469 fqn,
1470 logicalId: node.resource.LogicalId,
1471 type: node.resource.Type,
1472 cause,
1473 });
1474 yield* Deferred.failCause(ready[fqn], cause as Cause.Cause<never>);
1475 yield* Deferred.failCause(
1476 readyStable[fqn],
1477 cause as Cause.Cause<never>,
1478 );
1479 yield* session.emit({
1480 _tag: "apply.resource.status",
1481 fqn,
1482 id: node.resource.LogicalId,
1483 type: node.resource.Type,
1484 status: "fail",
1485 message: failureMessage(cause),
1486 providerMode: node.mode,
1487 });
1488 }),
1489 ),
1490 Effect.withSpan("apply.resource", {
1491 attributes: {
1492 "alchemy.resource.fqn": fqn,
1493 "alchemy.resource.type": node.resource.Type,
1494 "alchemy.resource.logical_id": node.resource.LogicalId,
1495 "alchemy.resource.action": node.action,
1496 },
1497 }),
1498 ) as Effect.Effect<void, never, never>;
1499
1500// ── Task execution ─────────────────────────────────────────────────────────
1501//
1502// Tasks slot into the same scheduler as resources. They have no provider
1503// lifecycle — just a single Effect that runs when inputs change (or when
1504// `--force` is set). The output value is written to `tracker[fqn].output`
1505// so downstream Output evaluation works identically to resource attrs.
1506
1507/**
1508 * Run an Action body, resolving any Outputs it captured via `yield* output`
1509 * during init against the current tracker and exposing them to the body through
1510 * the resolve {@link RuntimeContext}. See {@link makeCaptureContext}.
1511 */
1512const runAction = Effect.fn("apply.runAction")(function* (
1513 task: ActionLike,
1514 input: any,
1515 outputs: Record<string, any>,
1516) {
1517 const resolved: Record<string, unknown> = {};
1518 for (const [key, output] of Object.entries(task.Captures)) {
1519 resolved[key] = yield* Output.evaluate(output, outputs);
1520 }
1521 return yield* task
1522 .Run(input)
1523 .pipe(Effect.provideService(RuntimeContext, makeResolveContext(resolved)));
1524});
1525
1526const executeActionNode = (
1527 fqn: string,
1528 node: ActionApply,
1529 tracker: Record<string, ResourceTracker>,
1530 ready: Record<string, Deferred.Deferred<void>>,
1531 readyStable: Record<string, Deferred.Deferred<void>>,
1532 terminalStatuses: Map<
1533 string,
1534 {
1535 fqn: string;
1536 id: string;
1537 type: string;
1538 status: Extract<
1539 ApplyStatus,
1540 "created" | "updated" | "adopted" | "ran" | "skipped"
1541 >;
1542 providerMode?: ProviderMode;
1543 }
1544 >,
1545 session: PlanStatusSession,
1546 state: {
1547 set: <V extends PersistedState>(req: {
1548 stack: string;
1549 stage: string;
1550 fqn: string;
1551 value: V;
1552 }) => Effect.Effect<V, StateStoreError, never>;
1553 },
1554 stackName: string,
1555 stage: string,
1556 getOutputs: () => Record<string, any>,
1557 waitForDeps: (fqns: string[]) => Effect.Effect<void[], never, never>,
1558 failures: LifecycleFailure[],
1559): Effect.Effect<void, never, any> =>
1560 Effect.gen(function* () {
1561 const task = node.def;
1562 const logicalId = task.LogicalId;
1563 const namespace = task.Namespace;
1564
1565 const commit = <S extends ActionState>(value: Omit<S, "namespace">) =>
1566 state.set({
1567 stack: stackName,
1568 stage,
1569 fqn,
1570 value: { ...value, namespace } as S,
1571 });
1572
1573 const report = (status: ApplyStatus) =>
1574 session.emit({
1575 _tag: "apply.resource.status",
1576 fqn,
1577 id: logicalId,
1578 type: task.Type,
1579 status,
1580 });
1581
1582 const signalReady = Deferred.succeed(ready[fqn], void 0);
1583 const signalReadyStable = Deferred.succeed(readyStable[fqn], void 0);
1584
1585 const skip = (state: RanActionState) =>
1586 Effect.gen(function* () {
1587 if (!sameSet(state.downstream ?? [], node.downstream)) {
1588 yield* commit<RanActionState>({
1589 ...state,
1590 downstream: node.downstream,
1591 });
1592 }
1593 tracker[fqn] = {
1594 output: state.output,
1595 props: { __input: state.input },
1596 bindings: [],
1597 instanceId: fqn,
1598 };
1599 yield* signalReady;
1600 yield* signalReadyStable;
1601 terminalStatuses.set(fqn, {
1602 fqn,
1603 id: logicalId,
1604 type: task.Type,
1605 status: "skipped",
1606 });
1607 yield* report("skipped");
1608 });
1609
1610 if (node.action === "noop") {
1611 return yield* skip(node.state);
1612 }
1613
1614 // ── run ──
1615 // Tasks wait on `waitForStableDeps` (post-reconcile attrs) for their
1616 // upstreams, which is often the slowest dep chain in the deploy.
1617 // Surface that as "pending" instead of having the task show no status
1618 // until its run actually starts.
1619 yield* report("pending");
1620 yield* waitForDeps(
1621 [
1622 ...new Set([
1623 ...Object.keys(Output.resolveUpstream(node.input)),
1624 ...Object.keys(Output.upstreamAny(task.Captures)),
1625 ]),
1626 ].filter((f) => f in readyStable),
1627 );
1628
1629 const outputs = getOutputs();
1630 const resolvedInput = (yield* Output.evaluate(node.input, outputs)) as any;
1631 const inputHashValue = yield* hashInput(resolvedInput);
1632
1633 // Inputs unknown during planning may resolve to the last successful input.
1634 if (
1635 !node.forced &&
1636 node.state?.status === "ran" &&
1637 node.state.inputHash === inputHashValue
1638 ) {
1639 return yield* skip(node.state);
1640 }
1641
1642 yield* commit<RunningActionState>({
1643 kind: "action",
1644 status: "running",
1645 fqn,
1646 logicalId,
1647 actionType: task.Type,
1648 inputHash: inputHashValue,
1649 input: resolvedInput,
1650 downstream: node.downstream,
1651 });
1652 yield* report("running");
1653
1654 const result = yield* runAction(task, resolvedInput, outputs);
1655
1656 yield* commit<RanActionState>({
1657 kind: "action",
1658 status: "ran",
1659 fqn,
1660 logicalId,
1661 actionType: task.Type,
1662 inputHash: inputHashValue,
1663 input: resolvedInput,
1664 output: result,
1665 downstream: node.downstream,
1666 });
1667
1668 tracker[fqn] = {
1669 output: result,
1670 props: { __input: resolvedInput },
1671 bindings: [],
1672 instanceId: fqn,
1673 };
1674 yield* signalReady;
1675 yield* signalReadyStable;
1676 terminalStatuses.set(fqn, {
1677 fqn,
1678 id: logicalId,
1679 type: task.Type,
1680 status: "ran",
1681 });
1682 yield* report("ran");
1683 }).pipe(
1684 Effect.catchCause((cause) =>
1685 Effect.gen(function* () {
1686 failures.push({
1687 fqn,
1688 logicalId: node.def.LogicalId,
1689 type: node.def.Type,
1690 cause,
1691 });
1692 yield* Deferred.failCause(ready[fqn], cause as Cause.Cause<never>);
1693 yield* Deferred.failCause(
1694 readyStable[fqn],
1695 cause as Cause.Cause<never>,
1696 );
1697 yield* session.emit({
1698 _tag: "apply.resource.status",
1699 fqn,
1700 id: node.def.LogicalId,
1701 type: node.def.Type,
1702 status: "fail",
1703 message: failureMessage(cause),
1704 });
1705 }),
1706 ),
1707 Effect.withSpan("apply.action", {
1708 attributes: {
1709 "alchemy.action.fqn": fqn,
1710 "alchemy.action.type": node.def.Type,
1711 "alchemy.action.logical_id": node.def.LogicalId,
1712 "alchemy.action.verb": node.action,
1713 },
1714 }),
1715 ) as Effect.Effect<void, never, any>;
1716
1717// ── Phase 3: imperative convergence loop ───────────────────────────────────
1718//
1719// After the initial concurrent pass, some resources may have been created
1720// with stale upstream values (e.g. a precreate stub instead of the final
1721// output). Walk the plan and re-evaluate each resource's props/bindings
1722// against the current tracker outputs. Call provider.reconcile for any
1723// resource whose resolved inputs differ from what it was last applied with.
1724// Repeat until no resource needs updating.
1725
1726const converge = Effect.fn(function* (
1727 plan: Plan,
1728 tracker: Record<string, ResourceTracker>,
1729 terminalStatuses: Map<
1730 string,
1731 {
1732 fqn: string;
1733 id: string;
1734 type: string;
1735 status: Extract<
1736 ApplyStatus,
1737 "created" | "updated" | "adopted" | "ran" | "skipped"
1738 >;
1739 providerMode?: ProviderMode;
1740 }
1741 >,
1742 session: PlanStatusSession,
1743 state: {
1744 set: <V extends PersistedState>(req: {
1745 stack: string;
1746 stage: string;
1747 fqn: string;
1748 value: V;
1749 }) => Effect.Effect<V, StateStoreError, never>;
1750 },
1751 stackName: string,
1752 stage: string,
1753) {
1754 for (;;) {
1755 let anyUpdated = false;
1756
1757 for (const [fqn, node] of Object.entries(plan.resources)) {
1758 if (node.action === "noop") continue;
1759 if (!tracker[fqn]) continue;
1760
1761 const outputs = Object.fromEntries(
1762 Object.entries(tracker).map(([k, t]) => [k, t.output]),
1763 );
1764
1765 const newProps = (yield* Output.evaluate(node.props, outputs)) as Record<
1766 string,
1767 any
1768 >;
1769
1770 const newBindings = excludeDeletedBindings(
1771 yield* Output.evaluate(node.bindings, outputs),
1772 );
1773
1774 const oldProps = tracker[fqn].props;
1775 const oldBindings = tracker[fqn].bindings;
1776
1777 const propsChanged = havePropsChanged(oldProps, newProps);
1778 const bindingsChanged =
1779 JSON.stringify(oldBindings) !== JSON.stringify(newBindings);
1780
1781 if (!propsChanged && !bindingsChanged) continue;
1782
1783 anyUpdated = true;
1784
1785 const logicalId = node.resource.LogicalId;
1786 const namespace = node.resource.Namespace;
1787 const instanceId = tracker[fqn].instanceId;
1788
1789 const scopedSession = {
1790 ...session,
1791 note: (note, options?) =>
1792 session.emit({
1793 fqn,
1794 id: logicalId,
1795 _tag: "apply.resource.note",
1796 message: note,
1797 kind: options?.kind,
1798 }),
1799 } satisfies ScopedPlanStatusSession;
1800
1801 const attr = yield* node.provider
1802 .reconcile({
1803 id: logicalId,
1804 fqn,
1805 news: newProps,
1806 instanceId,
1807 bindings: newBindings,
1808 session: scopedSession,
1809 olds: oldProps,
1810 output: tracker[fqn].output,
1811 })
1812 .pipe(
1813 instrumentLifecycle(
1814 "update",
1815 fqn,
1816 node.resource.Type,
1817 logicalId,
1818 instanceId,
1819 ),
1820 );
1821
1822 tracker[fqn] = {
1823 output: attr,
1824 props: newProps,
1825 bindings: newBindings,
1826 instanceId,
1827 };
1828
1829 yield* state.set({
1830 stack: stackName,
1831 stage,
1832 fqn,
1833 value: {
1834 status: "updated",
1835 fqn,
1836 logicalId,
1837 instanceId,
1838 resourceType: node.resource.Type,
1839 // This site bypasses the `commit` helper, so strip unresolved
1840 // leaves (Effect-valued env entries survive `Output.evaluate`)
1841 // the same way `commit` does — state only ever holds plain data.
1842 props: stripUnresolved(newProps),
1843 attr,
1844 providerVersion: node.provider.version ?? 0,
1845 // Resolved payload, not raw `node.bindings` — see the create
1846 // commit in applyResource. Stripped like the commit helper so
1847 // Effect leaves (e.g. tagged Worker classes in `env`) never reach
1848 // the state store.
1849 bindings: stripUnresolved(newBindings),
1850 downstream: node.downstream,
1851 namespace,
1852 removalPolicy: node.resource.RemovalPolicy,
1853 providerMode: node.mode,
1854 adoptionBlocked: node.adoptionBlocked,
1855 } as UpdatedResourceState,
1856 });
1857
1858 terminalStatuses.set(fqn, {
1859 fqn,
1860 id: logicalId,
1861 type: node.resource.Type,
1862 status: node.action === "adopted" ? "adopted" : "updated",
1863 providerMode: node.mode,
1864 });
1865 }
1866
1867 // Tasks: re-run when their resolved input drifts vs. the value they
1868 // last applied with (e.g. an upstream resource produced new attrs in
1869 // this pass). Skipped (noop) tasks are not re-checked here — their
1870 // recorded inputHash is authoritative until the next plan.
1871 for (const [fqn, node] of Object.entries(plan.actions)) {
1872 if (node.action !== "run") continue;
1873 if (!tracker[fqn]) continue;
1874
1875 const outputs = Object.fromEntries(
1876 Object.entries(tracker).map(([k, t]) => [k, t.output]),
1877 );
1878 const newInput = (yield* Output.evaluate(node.input, outputs)) as any;
1879 const newHash = yield* hashInput(newInput);
1880 const oldInput = tracker[fqn].props?.__input;
1881 const oldHash = yield* hashInput(oldInput);
1882 if (newHash === oldHash) continue;
1883
1884 anyUpdated = true;
1885
1886 yield* state.set({
1887 stack: stackName,
1888 stage,
1889 fqn,
1890 value: {
1891 kind: "action",
1892 status: "running",
1893 fqn,
1894 logicalId: node.def.LogicalId,
1895 namespace: node.def.Namespace,
1896 actionType: node.def.Type,
1897 inputHash: newHash,
1898 input: newInput,
1899 downstream: node.downstream,
1900 } satisfies RunningActionState,
1901 });
1902
1903 const result = yield* runAction(node.def, newInput, outputs);
1904
1905 yield* state.set({
1906 stack: stackName,
1907 stage,
1908 fqn,
1909 value: {
1910 kind: "action",
1911 status: "ran",
1912 fqn,
1913 logicalId: node.def.LogicalId,
1914 namespace: node.def.Namespace,
1915 actionType: node.def.Type,
1916 inputHash: newHash,
1917 input: newInput,
1918 output: result,
1919 downstream: node.downstream,
1920 } satisfies RanActionState,
1921 });
1922
1923 tracker[fqn] = {
1924 output: result,
1925 props: { __input: newInput },
1926 bindings: [],
1927 instanceId: fqn,
1928 };
1929 terminalStatuses.set(fqn, {
1930 fqn,
1931 id: node.def.LogicalId,
1932 type: node.def.Type,
1933 status: "ran",
1934 });
1935 }
1936
1937 if (!anyUpdated) break;
1938 }
1939});
1940
1941// ── Phase 2: delete orphans and old replaced resources ─────────────────────
1942
1943/** A provider delete (or its attr-recovery read / state commit) that failed. */
1944export interface DeleteFailure {
1945 fqn: string;
1946 logicalId: string;
1947 resourceType: string;
1948 cause: Cause.Cause<unknown>;
1949}
1950
1951/**
1952 * A delete that was never attempted because a dependent's delete failed (or
1953 * was itself blocked). The resource may legitimately be undeletable while its
1954 * dependents still exist, so skipping is not an error in its own right.
1955 */
1956export interface BlockedDelete {
1957 fqn: string;
1958 logicalId: string;
1959 resourceType: string;
1960 /** FQNs of the dependents whose failed/blocked deletes block this one. */
1961 blockedBy: string[];
1962}
1963
1964/**
1965 * Aggregate raised at the end of the deletion phase when one or more
1966 * resource deletes failed. Every resource whose delete did not depend on a
1967 * failed one was still attempted — a single failure no longer strands
1968 * unrelated siblings.
1969 */
1970export class DestroyError extends Data.TaggedError("DestroyError")<{
1971 failures: ReadonlyArray<DeleteFailure>;
1972 blocked: ReadonlyArray<BlockedDelete>;
1973}> {
1974 override get message(): string {
1975 return [
1976 `Failed to delete ${this.failures.length} resource(s)` +
1977 (this.blocked.length > 0
1978 ? ` (${this.blocked.length} more skipped because a dependent's delete failed)`
1979 : "") +
1980 ":",
1981 ...this.failures.map(
1982 (f) => ` ✗ ${f.fqn} (${f.resourceType}): ${Cause.pretty(f.cause)}`,
1983 ),
1984 ...this.blocked.map(
1985 (b) =>
1986 ` ⊘ ${b.fqn} (${b.resourceType}): skipped — blocked by failed delete of ${b.blockedBy.join(", ")}`,
1987 ),
1988 ].join("\n");
1989 }
1990}
1991
1992const collectGarbage = Effect.fn(function* (
1993 plan: Plan,
1994 session: PlanStatusSession,
1995) {
1996 const state = yield* yield* State;
1997 const stack = yield* Stack;
1998 const stackName = stack.name;
1999 const stage = yield* Stage;
2000
2001 // Task deletions are pure state drops — no body is invoked. Run them in
2002 // parallel before resource GC; tasks never have provider-side dependencies
2003 // to wait on.
2004 yield* Effect.all(
2005 Object.entries(plan.actionDeletions ?? {}).map(([fqn, node]) =>
2006 node === undefined
2007 ? Effect.void
2008 : Effect.gen(function* () {
2009 yield* session.emit({
2010 _tag: "apply.resource.status",
2011 fqn,
2012 id: node.def.LogicalId,
2013 type: node.def.Type,
2014 status: "deleting",
2015 });
2016 yield* state.delete({ stack: stackName, stage, fqn });
2017 yield* session.emit({
2018 _tag: "apply.resource.status",
2019 fqn,
2020 id: node.def.LogicalId,
2021 type: node.def.Type,
2022 status: "deleted",
2023 });
2024 }),
2025 ),
2026 { concurrency: "unbounded" },
2027 );
2028
2029 // Failures are collected — not propagated — so one bad delete never
2030 // strands unrelated siblings. `unresolved` tracks every FQN whose delete
2031 // failed or was blocked this run: later passes must not retry them (a
2032 // still-`replaced` row would otherwise spin the drain loop forever) and
2033 // dependencies scheduled in later passes must observe them as blocking.
2034 const failures: DeleteFailure[] = [];
2035 const blockedDeletes: BlockedDelete[] = [];
2036 const unresolved = new Set<string>();
2037
2038 const pendingDeletes = { ...plan.deletions };
2039 // Pending means this generation drained; deferred means it is still live.
2040 type DeleteOutcome =
2041 | "deleted"
2042 | "pending"
2043 | "deferred"
2044 | "failed"
2045 | "blocked";
2046
2047 const deleteGraph = Effect.fn(function* (
2048 deletionGraph: Record<
2049 string,
2050 Delete | ReplacementResourceState | undefined
2051 >,
2052 ) {
2053 const deletions: {
2054 [fqn: string]: Effect.Effect<DeleteOutcome, never, ArtifactStore>;
2055 } = {};
2056
2057 const deleteResource = (
2058 node: Delete | ReplacementResourceState,
2059 ancestors: ReadonlySet<string> = new Set(),
2060 ): Effect.Effect<DeleteOutcome, never, ArtifactStore> =>
2061 Effect.gen(function* () {
2062 const isDeleteNode = (
2063 node: Delete | ReplacementResourceState,
2064 ): node is Delete => "action" in node;
2065
2066 const {
2067 fqn,
2068 logicalId,
2069 namespace,
2070 resourceType,
2071 instanceId,
2072 downstream,
2073 props,
2074 attr: persistedAttr,
2075 provider,
2076 providerMode,
2077 } = isDeleteNode(node)
2078 ? {
2079 // Use the persisted FQN verbatim — never recompute it from
2080 // `toFqn(namespace, logicalId)`. A logical ID may legitimately
2081 // contain the FQN separator (`/`), in which case `parseFqn`
2082 // truncated it and a recomputed key would miss the real state
2083 // row (the row would then resurface as an orphan on every
2084 // subsequent destroy, never getting deleted).
2085 fqn: node.resource.FQN,
2086 logicalId: node.resource.LogicalId,
2087 namespace: node.resource.Namespace,
2088 resourceType: node.resource.Type,
2089 instanceId: node.state.instanceId,
2090 downstream: node.downstream,
2091 props: node.state.props,
2092 attr: node.state.attr,
2093 // Plan resolved this provider for the row's persisted (or
2094 // marker-inferred) `providerMode` (see the deletions builder
2095 // in Plan.ts).
2096 provider: node.provider,
2097 providerMode: node.state.providerMode,
2098 }
2099 : {
2100 fqn: node.fqn,
2101 logicalId: node.logicalId,
2102 namespace: node.namespace,
2103 resourceType: node.old.resourceType,
2104 instanceId: node.old.instanceId,
2105 downstream: node.old.downstream,
2106 props: node.old.props,
2107 attr: node.old.attr,
2108 // A missing provider is fatal — plan already dies on zombie
2109 // rows (see the deletions builder in Plan.ts); this guards
2110 // the replaced-chain generations that bypass plan. The old
2111 // generation is torn down with the provider variant of the
2112 // mode that created it (local ⇄ live replacements);
2113 // unstamped rows are physically live unless their attrs
2114 // carry the `dev:` identity marker (see stampedMode).
2115 provider: yield* tryFindProviderByType(
2116 node.old.resourceType,
2117 stampedMode(node.old),
2118 ).pipe(
2119 Effect.flatMap(
2120 Option.match({
2121 onNone: () =>
2122 Effect.die(
2123 missingProviderError(node.old.resourceType, node.fqn),
2124 ),
2125 onSome: Effect.succeed,
2126 }),
2127 ),
2128 ),
2129 providerMode: node.old.providerMode,
2130 };
2131
2132 const adoptionBlocked = isDeleteNode(node)
2133 ? node.state.adoptionBlocked
2134 : node.old.adoptionBlocked;
2135 // Mutable: an attr-less row (interrupted create) may recover its
2136 // attributes from `provider.read` below, right before deletion.
2137 let attr = persistedAttr;
2138
2139 const nextAncestors = new Set(ancestors).add(fqn);
2140
2141 const commit = <S extends ResourceState>(value: Omit<S, "namespace">) =>
2142 state.set({
2143 stack: stackName,
2144 stage,
2145 fqn,
2146 // Same rule as the lifecycle commit above: state only stores
2147 // plain data, never unresolved Output exprs or Effect leaves.
2148 value: {
2149 ...value,
2150 adoptionBlocked:
2151 value.adoptionBlocked ??
2152 (isDeleteNode(node)
2153 ? node.state.adoptionBlocked
2154 : node.adoptionBlocked),
2155 props: stripUnresolved(value.props),
2156 bindings: stripUnresolved(value.bindings),
2157 namespace,
2158 } as S,
2159 });
2160
2161 const report = (status: ApplyStatus) =>
2162 session.emit({
2163 _tag: "apply.resource.status",
2164 fqn,
2165 id: logicalId,
2166 type: resourceType,
2167 status,
2168 providerMode,
2169 });
2170
2171 const scopedSession = {
2172 ...session,
2173 note: (note, options?) =>
2174 session.emit({
2175 fqn,
2176 id: logicalId,
2177 _tag: "apply.resource.note",
2178 message: note,
2179 kind: options?.kind,
2180 }),
2181 } satisfies ScopedPlanStatusSession;
2182
2183 return yield* (deletions[fqn] ??= yield* Effect.cached(
2184 Effect.gen(function* () {
2185 // Dependents (`downstream`) are deleted before this resource. A
2186 // dependent whose delete failed (or was itself blocked) may make
2187 // this resource legitimately undeletable (dependency violation),
2188 // so it is skipped with a "blocked by" note instead of surfacing
2189 // a spurious second error.
2190 const dependents = yield* Effect.all(
2191 downstream.map((dep) =>
2192 dep !== fqn && !ancestors.has(dep)
2193 ? dep in deletionGraph
2194 ? deleteResource(
2195 deletionGraph[dep] as Delete | ReplacementResourceState,
2196 nextAncestors,
2197 ).pipe(Effect.map((outcome) => ({ dep, outcome })))
2198 : // Not in this pass's graph — but it may have failed in
2199 // an earlier drain pass of the same destroy.
2200 Effect.sync(() => ({
2201 dep,
2202 outcome: unresolved.has(dep)
2203 ? ("failed" as const)
2204 : ("deleted" as const),
2205 }))
2206 : Effect.succeed({ dep, outcome: "deleted" as const }),
2207 ),
2208 { concurrency: "unbounded" },
2209 );
2210
2211 const blockedBy = dependents
2212 .filter(
2213 ({ outcome }) => outcome === "failed" || outcome === "blocked",
2214 )
2215 .map(({ dep }) => dep);
2216
2217 if (blockedBy.length > 0) {
2218 unresolved.add(fqn);
2219 blockedDeletes.push({
2220 fqn,
2221 logicalId,
2222 resourceType,
2223 blockedBy,
2224 });
2225 yield* scopedSession.note(
2226 `Skipping delete — blocked by failed delete of ${blockedBy.join(", ")}.`,
2227 );
2228 yield* report("skipped");
2229 return "blocked" as const;
2230 }
2231
2232 if (
2233 dependents.some(
2234 ({ outcome }) =>
2235 outcome === "deferred" ||
2236 (isDeleteNode(node) && outcome === "pending"),
2237 )
2238 ) {
2239 // Even GC must wait when the dependent's physical delete has not run.
2240 return "deferred" as const;
2241 }
2242
2243 return yield* deleteResourceBody().pipe(
2244 Effect.catchCause((cause) =>
2245 Effect.gen(function* () {
2246 unresolved.add(fqn);
2247 failures.push({ fqn, logicalId, resourceType, cause });
2248 yield* report("fail");
2249 return "failed" as const;
2250 }),
2251 ),
2252 );
2253 }),
2254 ));
2255
2256 function deleteResourceBody() {
2257 return Effect.gen(function* () {
2258 if (isDeleteNode(node)) {
2259 yield* report(
2260 node.action === "orphaned" ? "orphaning" : "deleting",
2261 );
2262 if (node.action === "orphaned") {
2263 yield* state.delete({
2264 stack: stackName,
2265 stage,
2266 fqn,
2267 });
2268 yield* report("orphaned");
2269 delete pendingDeletes[fqn];
2270 // Retention is intentional — it never blocks dependencies.
2271 return "deleted" as const;
2272 }
2273 }
2274
2275 // Honor `retain` for the old generation of a replacement, mirroring
2276 // the orphan-delete path above. Delete-node retain is already
2277 // handled with an early return; this guards the replaced
2278 // old-generation physical delete.
2279 const retainOldGeneration =
2280 !isDeleteNode(node) && node.removalPolicy === "retain";
2281
2282 if (retainOldGeneration) {
2283 yield* scopedSession.note(
2284 "Retaining replaced resource (removal policy: retain)...",
2285 );
2286 }
2287
2288 // A row can reach deletion with `attr === undefined` when a create
2289 // was interrupted after the cloud-side call succeeded but before
2290 // `reconcile` returned Attributes (a `creating` row — or the old
2291 // generation of a replacement chain in the same predicament).
2292 // Skipping the provider's delete outright would silently orphan
2293 // the physical resource, so ask `read` to look it up from the
2294 // persisted props (providers derive the deterministic physical
2295 // name from id/props):
2296 // - plain attrs → exists and is ours; proceed to delete
2297 // - Unowned(attrs) → exists but is NOT ours (e.g. our create
2298 // actually lost a name race, or died before
2299 // stamping ownership) — never delete a
2300 // foreign resource; drop our state and say so
2301 // - undefined → nothing exists; dropping state is safe
2302 if (attr === undefined && !retainOldGeneration) {
2303 if (adoptionBlocked === "migrated-fqn") {
2304 yield* scopedSession.note(
2305 "Skipping recovery of a reused FQN: a lookup could find its " +
2306 "migrated predecessor. Any interrupted new physical " +
2307 "resource must be identified and cleaned up manually.",
2308 );
2309 } else if (provider.read) {
2310 const recovered = yield* provider
2311 .read({
2312 id: logicalId,
2313 fqn,
2314 instanceId,
2315 olds: props as never,
2316 output: undefined,
2317 })
2318 .pipe(
2319 instrumentLifecycle(
2320 "read",
2321 fqn,
2322 resourceType,
2323 logicalId,
2324 instanceId,
2325 ),
2326 // The persisted props of an interrupted create can carry
2327 // holes where unresolved Outputs were stripped at commit
2328 // time (see stripUnresolved) — e.g. a parent reference
2329 // persisted as `{}`. A provider that dereferences one
2330 // crashes deep inside its SDK client (a SchemaError
2331 // defect), which would make the stage impossible to
2332 // destroy. Recovery is best-effort: degrade the defect
2333 // to "nothing recovered", surface a note, and let the
2334 // row be dropped (#995).
2335 Effect.catchDefect((defect) =>
2336 scopedSession
2337 .note(
2338 "Recovery read crashed while looking up this " +
2339 "resource's interrupted create " +
2340 `(${String(defect)}) — if a physical resource ` +
2341 "was created, it must be cleaned up manually.",
2342 )
2343 .pipe(Effect.as(undefined)),
2344 ),
2345 );
2346 if (recovered !== undefined) {
2347 if (Unowned.is(recovered)) {
2348 yield* scopedSession.note(
2349 "Resource exists in the cloud but is not owned by this " +
2350 "stack — leaving it in place (re-deploy with --adopt " +
2351 "to take ownership, then destroy).",
2352 );
2353 } else {
2354 attr = stripUnowned(recovered as Record<string, any>);
2355 }
2356 }
2357 } else {
2358 yield* scopedSession.note(
2359 "No attributes were recorded for this resource (its create " +
2360 "was interrupted) and the provider does not implement " +
2361 "`read` — if a physical resource was created, it must be " +
2362 "cleaned up manually.",
2363 );
2364 }
2365 }
2366
2367 if (isDeleteNode(node)) {
2368 yield* commit<DeletingResourceState>({
2369 status: "deleting",
2370 fqn,
2371 logicalId,
2372 instanceId,
2373 resourceType,
2374 props,
2375 attr,
2376 downstream,
2377 providerVersion: provider.version ?? 0,
2378 bindings: excludeDeletedBindings(node.bindings),
2379 removalPolicy: node.resource.RemovalPolicy,
2380 providerMode,
2381 adoptionBlocked,
2382 });
2383 }
2384
2385 if (attr !== undefined && !retainOldGeneration) {
2386 yield* provider
2387 .delete({
2388 id: logicalId,
2389 fqn,
2390 instanceId,
2391 olds: props as never,
2392 output: attr,
2393 session: scopedSession,
2394 bindings: [],
2395 })
2396 .pipe(
2397 instrumentLifecycle(
2398 "delete",
2399 fqn,
2400 resourceType,
2401 logicalId,
2402 instanceId,
2403 ),
2404 );
2405 }
2406
2407 if (isDeleteNode(node)) {
2408 yield* state.delete({
2409 stack: stackName,
2410 stage,
2411 fqn,
2412 });
2413 yield* report("deleted");
2414 } else {
2415 if (!retainOldGeneration) {
2416 yield* scopedSession.note("Cleaning up replaced resource...");
2417 }
2418 if (
2419 node.old.status === "replacing" ||
2420 node.old.status === "replaced"
2421 ) {
2422 const remaining: ReplacementResourceState = {
2423 ...node,
2424 bindings: excludeDeletedBindings(node.bindings),
2425 old: node.old.old,
2426 };
2427 yield* commit(remaining);
2428 } else {
2429 // Precreate attributes do not mean the replacement reconcile completed.
2430 yield* commit<CreatedResourceState | CreatingResourceState>({
2431 ...(node.status === "replacing"
2432 ? { status: "creating" as const, attr: node.attr }
2433 : { status: "created" as const, attr: node.attr }),
2434 fqn,
2435 logicalId: node.logicalId,
2436 instanceId: node.instanceId,
2437 resourceType: node.resourceType,
2438 props: node.props,
2439 providerVersion: node.providerVersion,
2440 downstream: node.downstream,
2441 bindings: excludeDeletedBindings(node.bindings),
2442 removalPolicy: node.removalPolicy,
2443 providerMode: node.providerMode,
2444 });
2445 }
2446 yield* scopedSession.note(
2447 retainOldGeneration
2448 ? "Replaced resource retained."
2449 : "Replaced resource cleanup complete.",
2450 );
2451 }
2452 if (isDeleteNode(node)) {
2453 delete pendingDeletes[fqn];
2454 return "deleted" as const;
2455 }
2456 return node.old.status === "replacing" ||
2457 node.old.status === "replaced" ||
2458 pendingDeletes[fqn] !== undefined
2459 ? ("pending" as const)
2460 : ("deleted" as const);
2461 });
2462 }
2463 });
2464
2465 // Attempt every root. Per-node failures were recorded (and their state
2466 // retained) inside each node's delete effect — the effects resolve to
2467 // outcomes and never fail, so one bad resource never interrupts sibling
2468 // deletions mid-flight.
2469 yield* Effect.all(
2470 Object.values(deletionGraph)
2471 .filter((node) => node !== undefined)
2472 .map((node) => deleteResource(node)),
2473 { concurrency: "unbounded" },
2474 );
2475 });
2476
2477 // Drain one old generation per FQN per pass, preserving that generation's
2478 // dependency edges. Planned deletions remain queued until their chains drain.
2479 while (true) {
2480 const remainingReplacedResources = (yield* state.getReplacedResources({
2481 stack: stackName,
2482 stage,
2483 })).filter(
2484 (replaced) =>
2485 !unresolved.has(replaced.fqn) &&
2486 (plan.selectedFqns === undefined ||
2487 plan.selectedFqns.has(replaced.fqn)),
2488 );
2489 const deletionGraph: Record<
2490 string,
2491 Delete | ReplacementResourceState | undefined
2492 > = Object.fromEntries(
2493 remainingReplacedResources.map((replaced) => [replaced.fqn, replaced]),
2494 );
2495 for (const [fqn, node] of Object.entries(pendingDeletes)) {
2496 if (node === undefined || unresolved.has(fqn)) continue;
2497 const current = yield* state.get({ stack: stackName, stage, fqn });
2498 if (current === undefined) {
2499 delete pendingDeletes[fqn];
2500 } else if (current.kind !== "action") {
2501 deletionGraph[fqn] =
2502 node.action !== "orphaned" &&
2503 (current.status === "replacing" || current.status === "replaced")
2504 ? current
2505 : { ...node, state: current, downstream: current.downstream };
2506 }
2507 }
2508 if (Object.keys(deletionGraph).length === 0) break;
2509 yield* deleteGraph(deletionGraph);
2510 }
2511
2512 if (failures.length > 0) {
2513 // Every independent delete was still attempted; now surface everything
2514 // that went wrong (and everything skipped as a consequence) as one
2515 // typed aggregate. The destroy as a whole still fails.
2516 return yield* Effect.fail(
2517 new DestroyError({ failures, blocked: blockedDeletes }),
2518 );
2519 }
2520});
2521
2522const excludeDeletedBindings = (
2523 bindings: ReadonlyArray<ResourceBinding & { action?: string }>,
2524): ResourceBinding[] =>
2525 bindings.flatMap(({ action, sid, data }) =>
2526 action === "delete" ? [] : [{ sid, data }],
2527 );
2528
2529/** Order-insensitive equality of two FQN lists. */
2530const sameSet = (a: ReadonlyArray<string>, b: ReadonlyArray<string>) => {
2531 const sa = new Set(a);
2532 return sa.size === new Set(b).size && b.every((fqn) => sa.has(fqn));
2533};