1/** @effect-diagnostics anyUnknownInErrorContext:off */
2/** @effect-diagnostics missingEffectError:off */
3import * as Cause from "effect/Cause";
4import * as Config from "effect/Config";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Exit from "effect/Exit";
8import * as Option from "effect/Option";
9import * as Ref from "effect/Ref";
10import { cachedInScope } from "./Util/Memoize.ts";
11import { asEffect } from ".//Util/types.ts";
12import { isAction, type ActionLike } from "./Action.ts";
13import {
14 AdoptPolicy,
15 OwnedBySomeoneElse,
16 stripUnowned,
17 Unowned,
18} from "./AdoptPolicy.ts";
19import { AlchemyContext } from "./AlchemyContext.ts";
20import {
21 demandRemoteCredentials,
22 failCredentialsRequired,
23} from "./Auth/Demand.ts";
24import {
25 Artifacts,
26 ArtifactStore,
27 createArtifactStore,
28 ensureArtifactStore,
29 makeScopedArtifacts,
30} from "./Artifacts.ts";
31import {
32 dedupeBindings,
33 diffBindings,
34 havePropsChanged,
35 isResolved,
36 type NoopDiff,
37 type ReplaceDiff,
38 type UpdateDiff,
39} from "./Diff.ts";
40import { capturedEnvKeys } from "./RuntimeContext.ts";
41import { parseFqn } from "./FQN.ts";
42import { generateInstanceId, InstanceId } from "./InstanceId.ts";
43import * as Output from "./Output.ts";
44import {
45 tryFindProviderRegistrationByType,
46 missingProviderError,
47 Provider,
48 providerForMode,
49 tryFindProviderByType,
50 type ProviderService,
51} from "./Provider.ts";
52import {
53 defaultProviderMode,
54 stampedMode,
55 type ProviderMode,
56} from "./ProviderMode.ts";
57import {
58 Progress,
59 type PlannedAction,
60 type PlannedBinding,
61 type PlannedResource,
62} from "./Report.ts";
63import {
64 isResource,
65 missingImplementation,
66 type ResourceBinding,
67 type ResourceLike,
68} from "./Resource.ts";
69import {
70 InvalidResourceSelection,
71 UnsafeSelectionBoundary,
72 selectResources,
73 type ResourceSelection,
74 type SelectionOutput,
75} from "./ResourceSelection.ts";
76export {
77 InvalidResourceSelection,
78 UnsafeSelectionBoundary,
79} from "./ResourceSelection.ts";
80import { type StackSpec } from "./Stack.ts";
81import {
82 isActionState,
83 State,
84 type ActionState,
85 type CreatedResourceState,
86 type CreatingResourceState,
87 type RanActionState,
88 type ReplacedResourceState,
89 type ReplacingResourceState,
90 type ResourceState,
91 type StateStoreError,
92 type UpdatedResourceState,
93 type UpdatingReourceState,
94} from "./State/index.ts";
95import { isPlainData, mapPlainData } from "./Util/data.ts";
96import { findCycleMembers } from "./Util/scc.ts";
97import { hashInput } from "./Util/sha256.ts";
98
99export type PlanError = never;
100
101/**
102 * A node in the plan that represents a resource CRUD operation.
103 */
104export type CRUD<R extends ResourceLike = ResourceLike> =
105 | Create<R>
106 | Update<R>
107 | Delete<R>
108 | Replace<R>
109 | NoopUpdate<R>;
110
111export type Apply<R extends ResourceLike = ResourceLike> =
112 | Create<R>
113 | Update<R>
114 | Replace<R>
115 | NoopUpdate<R>;
116
117export type BindingAction = "create" | "update" | "delete" | "noop";
118
119export interface BindingNode<Data = any> extends ResourceBinding {
120 action: BindingAction;
121 data: Data;
122}
123
124export interface BaseNode<
125 R extends ResourceLike<string> = ResourceLike<string>,
126> {
127 resource: R;
128 provider: ProviderService<R>;
129 /**
130 * The {@link ProviderMode} this node's `provider` was resolved for.
131 * `undefined` for mode-agnostic providers (a single implementation
132 * serves both dev and deploy) — such resources never persist a mode and
133 * never replace on a mode switch. Apply stamps this onto every state
134 * commit as `providerMode`.
135 */
136 mode: ProviderMode | undefined;
137 downstream: string[];
138 bindings: BindingNode<R["Binding"]>[];
139}
140
141/**
142 * Base for the apply-side nodes (create/update/replace/noop) — the nodes a
143 * DECLARED resource plans to. Only these can carry a rename: a `Delete`
144 * node is an orphaned row with no declaration, so nothing can claim to
145 * have renamed it (migrating rows are excluded from the orphan pass
146 * entirely).
147 */
148export interface ApplyNodeBase<
149 R extends ResourceLike<string> = ResourceLike<string>,
150> extends BaseNode<R> {
151 /** Cloud attribute drift attached to synthetic drift-repair plans. */
152 drift?: {
153 readonly expected: unknown;
154 readonly actual: unknown;
155 readonly missing?: boolean;
156 };
157 /**
158 * Set when this resource's persisted row was found under former FQNs
159 * (`renamedFrom(...)`) — the migration source (no row at the current FQN
160 * yet) and/or stale leftovers from interrupted migrations (same
161 * `instanceId` as the resource's row; a rename chain with repeated
162 * partial failures can leave several). Apply persists the move up-front,
163 * before any lifecycle operation runs: commit `state` at the current
164 * FQN, then delete every former row.
165 */
166 renamedFrom?: string[] | undefined;
167 /** Preserve the FQN-reuse exclusion across generations and state transitions. */
168 adoptionBlocked?: "migrated-fqn";
169}
170
171export interface Create<
172 R extends ResourceLike = ResourceLike,
173> extends ApplyNodeBase<R> {
174 action: "create";
175 props: R["Props"];
176 state: CreatingResourceState | undefined;
177 /** Ownership probe deferred until upstream identities resolve during apply. */
178 deferredAdoption?: { adopt: boolean };
179}
180
181export interface Update<
182 R extends ResourceLike = ResourceLike,
183> extends ApplyNodeBase<R> {
184 action: "update" | "adopted";
185 /** True while this is the first reconcile after a cold adoption. */
186 adopting?: boolean;
187 props: R["Props"];
188 state:
189 | CreatedResourceState
190 | UpdatedResourceState
191 | UpdatingReourceState
192 // the props can change after creating the replacement resource,
193 // so Apply needs to handle updates and then continue with cleaning up the replaced graph
194 | ReplacedResourceState;
195}
196
197export interface Delete<
198 R extends ResourceLike = ResourceLike,
199> extends BaseNode<R> {
200 action: "delete" | "orphaned";
201 // a resource can be deleted no matter what state it's in
202 state: ResourceState;
203}
204
205export interface NoopUpdate<
206 R extends ResourceLike = ResourceLike,
207> extends ApplyNodeBase<R> {
208 action: "noop";
209 state: CreatedResourceState | UpdatedResourceState;
210}
211
212export interface Replace<
213 R extends ResourceLike = ResourceLike,
214> extends ApplyNodeBase<R> {
215 action: "replace";
216 props: any;
217 deleteFirst: boolean;
218 restart?: boolean;
219 state:
220 | CreatingResourceState
221 | CreatedResourceState
222 | UpdatingReourceState
223 | UpdatedResourceState
224 | ReplacingResourceState
225 | ReplacedResourceState;
226}
227
228// ── Tasks ──────────────────────────────────────────────────────────────────
229//
230// Tasks live in the same FQN namespace as resources and participate in the
231// same DAG (downstream/upstream edges, cycle detection). Their plan nodes
232// have a different shape because they have no provider lifecycle.
233
234export type ActionApply<T extends ActionLike = ActionLike> =
235 | ActionRun<T>
236 | ActionNoop<T>;
237
238export interface ActionNodeBase<T extends ActionLike = ActionLike> {
239 readonly kind: "action";
240 def: T;
241 downstream: string[];
242}
243
244export interface ActionRun<
245 T extends ActionLike = ActionLike,
246> extends ActionNodeBase<T> {
247 action: "run";
248 /** Input expression — resolved against tracker outputs during apply. */
249 input: T["Input"];
250 /** Previous state, if any. `undefined` on the first run. */
251 state: ActionState | undefined;
252 /** True when `--force` triggered the re-run regardless of input drift. */
253 forced: boolean;
254}
255
256export interface ActionNoop<
257 T extends ActionLike = ActionLike,
258> extends ActionNodeBase<T> {
259 action: "noop";
260 state: RanActionState;
261}
262
263export interface ActionDelete<
264 T extends ActionLike = ActionLike,
265> extends ActionNodeBase<T> {
266 action: "delete";
267 state: ActionState;
268}
269
270export type Plan<Output = any> = {
271 /** Closed selection for partial apply and GC; absent for a full plan. */
272 selectedFqns?: ReadonlySet<string>;
273 resources: {
274 [id in string]: Apply<any>;
275 };
276 /**
277 * Tasks scheduled for this apply. Keyed by FQN, same namespace as
278 * `resources` — Apply's scheduler merges both into a single DAG.
279 */
280 actions: {
281 [id in string]: ActionApply;
282 };
283 deletions: {
284 [id in string]?: Delete<ResourceLike>;
285 };
286 /** Tasks whose state should be dropped (no body invoked on removal). */
287 actionDeletions: {
288 [id in string]?: ActionDelete;
289 };
290 output: Output;
291 /**
292 * FQNs of resources that participate in a strongly-connected component
293 * of the upstream dependency graph (or have a self-edge). The scheduler
294 * uses this to decide whether an `update` node must publish its prior
295 * attr early to break the cycle, or can simply wait for upstreams and
296 * publish a fresh attr (the common, linear case).
297 */
298 cycleMembers: ReadonlySet<string>;
299 /**
300 * The run-level default {@link ProviderMode} this plan was built with
301 * (`alchemy dev` → `"local"`, `alchemy deploy` → `"live"`). Renderers use
302 * it to tag only the EXCEPTIONS — rows whose resolved mode differs from
303 * the run default. `undefined` (plans built by older/auxiliary builders)
304 * is treated as `"live"`.
305 */
306 defaultMode?: ProviderMode;
307 /**
308 * Marks a plan built by {@link destroy}. `apply` finishes a destroy plan
309 * by deleting the stage's remaining persisted state — notably the stack
310 * output record written by the last deploy — instead of persisting a new
311 * (empty) output.
312 */
313 destroy?: boolean;
314};
315
316const describeBinding = (binding: BindingNode): PlannedBinding => ({
317 sid: binding.sid,
318 action: binding.action,
319});
320
321/** The serializable row view of one resource node (see {@link describePlan}). */
322export const describeResource = (node: CRUD): PlannedResource => ({
323 fqn: node.resource.FQN,
324 logicalId: node.resource.LogicalId,
325 resourceType: node.resource.Type,
326 action: node.action,
327 bindings: node.bindings.map(describeBinding),
328 providerMode: node.mode,
329 fromProviderMode:
330 node.action === "replace" &&
331 node.mode !== undefined &&
332 node.state.providerMode !== undefined &&
333 node.state.providerMode !== node.mode
334 ? node.state.providerMode
335 : undefined,
336});
337
338/** The serializable row view of one stack-action node. */
339export const describeAction = (
340 node: ActionApply | ActionDelete,
341): PlannedAction => ({
342 fqn: node.def.FQN,
343 logicalId: node.def.LogicalId,
344 actionType: node.def.Type,
345 action: node.action,
346});
347
348/**
349 * Serializable per-row views of a plan — everything a renderer or remote
350 * consumer needs without holding the native plan: resource rows with their
351 * bindings and provider modes, orphan deletions, and stack actions.
352 */
353export const describePlan = (
354 plan: Plan,
355): {
356 readonly resources: ReadonlyArray<PlannedResource>;
357 readonly actions: ReadonlyArray<PlannedAction>;
358} => ({
359 resources: [
360 ...Object.values(plan.resources),
361 ...Object.values(plan.deletions).filter(
362 (node): node is Delete => node !== undefined,
363 ),
364 ].map(describeResource),
365 actions: [
366 ...Object.values(plan.actions ?? {}),
367 ...Object.values(plan.actionDeletions ?? {}),
368 ]
369 .filter((node): node is ActionApply | ActionDelete => node !== undefined)
370 .map(describeAction),
371});
372
373export interface MakePlanOptions {
374 force?: boolean;
375 include?: never;
376 exclude?: never;
377}
378
379export interface FilteredPlanOptions
380 extends Omit<MakePlanOptions, keyof ResourceSelection>, ResourceSelection {
381 /**
382 * Exact FQNs, unique logical IDs, or FQN globs, plus transitive input, capture,
383 * and binding dependencies. An empty selection is an error. Other rows
384 * and persisted stack outputs are untouched; filtered apply returns void.
385 * The entire stack declaration still evaluates before selection. Renames
386 * with persisted former rows require a full deployment. Replacement/GC
387 * fails when prior or declared dependents are outside the selection.
388 * Detaching a selected intermediate also requires selecting all consumers
389 * reachable through its declared or historical downstream paths.
390 * Historical bindings require selecting every persisted resource and Action
391 * for replacement/GC. Clearing the last binding evidence also requires that
392 * complete selection, since stored payloads cannot recover binding-cycle edges.
393 * Only already-persisted, continuously retained bindings count as evidence.
394 * Reconciling a row with incomplete downstream history requires that selection.
395 * Exclusions are hard barriers, even for unchanged dependencies. Quote globs
396 * in the CLI: `--include 'App/**' --exclude 'App/Legacy'`. Each occurrence
397 * supplies one pattern; commas are literal. Dependencies hidden inside
398 * callbacks must be declared explicitly.
399 */
400 include?: ReadonlyArray<string>;
401}
402
403type FullPlanCall = [options?: MakePlanOptions];
404
405export function make<
406 A,
407 Call extends [options?: FilteredPlanOptions] = FullPlanCall,
408>(
409 stack: StackSpec<A>,
410 ...call: Call
411): Effect.Effect<Plan<SelectionOutput<A, Call[0]>>, never, State>;
412export function make<A>(
413 stack: StackSpec<A>,
414 options: FilteredPlanOptions = {},
415) {
416 return makePlan(stack, options);
417}
418
419const makePlan = <A>(
420 stack: StackSpec<A>,
421 options: FilteredPlanOptions = {},
422): Effect.Effect<Plan<A | undefined>, never, State> =>
423 // @ts-expect-error
424 Effect.gen(function* () {
425 // Per-resource plan progress and phase markers are reported through
426 // the ambient Progress reporter as the work happens, so a renderer can show
427 // plan computation while it runs. The default reporter is a no-op.
428 const reportPlanned = yield* Progress;
429
430 const declaredResources = Object.values(stack.resources);
431 const declaredActions = Object.values(stack.actions ?? {});
432 const declared = [...declaredResources, ...declaredActions];
433 const upstream = (node: ResourceLike | ActionLike) =>
434 Object.keys(
435 Output.upstreamAny(
436 isAction(node)
437 ? [node.Input, node.Captures]
438 : [node.Props, stack.bindings[node.FQN] ?? []],
439 ),
440 );
441 const selected = yield* selectResources(declared, upstream, options);
442 const resources = declaredResources.filter(
443 (node) => !selected || selected.has(node.FQN),
444 );
445 const actions = declaredActions.filter(
446 (node) => !selected || selected.has(node.FQN),
447 );
448
449 // Resolving the state service may bootstrap a remote store.
450 yield* reportPlanned({ _tag: "plan.phase", phase: "loading-state" });
451 const state = yield* yield* State;
452
453 // A bare platform tag yields a forward reference registered with
454 // `undefined` props (and `RequiresImplementation`); its `.make(props,
455 // impl)` Layer repairs the props when it builds (in either order — the
456 // #874 circular env-tag pattern). Props still `undefined` after the
457 // whole program evaluated means the tag was yielded but its Layer was
458 // never provided — fail fast naming the class and its Layer instead of
459 // letting a provider read `undefined` props (#1054). Plain resources
460 // may legitimately be yielded without props (a reference to
461 // already-deployed state), so the check is scoped to platform tags.
462 for (const resource of resources) {
463 if (resource.RequiresImplementation && resource.Props === undefined) {
464 yield* Effect.die(
465 missingImplementation(resource.Type, resource.LogicalId),
466 );
467 }
468 }
469
470 // TODO(sam): rename terminology to Stack
471 const stackName = stack.name;
472 const stage = stack.stage;
473
474 // Resolve the effective adoption setting for this plan. AdoptPolicy
475 // (provided by the CLI or scoped via `adopt(...)`) takes precedence; if
476 // unset, fall back to the AlchemyContext's `adopt` default; otherwise
477 // adoption is disabled.
478 const shouldAdopt = Effect.gen(function* () {
479 const fromService = yield* Effect.serviceOption(AdoptPolicy);
480 if (Option.isSome(fromService)) return fromService.value;
481 const ctx = yield* Effect.serviceOption(AlchemyContext);
482 return Option.match(ctx, {
483 onNone: () => false,
484 onSome: (c) => c.adopt,
485 });
486 });
487
488 // The run-level default provider mode (`alchemy dev` → "local",
489 // `alchemy deploy` → "live"). A resource-scoped `remote()` (captured on
490 // the resource at registration as `Mode`) opts out of local emulation.
491 const runDefaultMode = yield* defaultProviderMode;
492
493 /**
494 * Resolve the effective provider mode and the concrete provider
495 * service for a resource.
496 *
497 * The mode only "sticks" when the provider actually distinguishes
498 * modes (registered via `ProviderLayer.dual`). Mode-agnostic providers
499 * satisfy any requested mode with their single implementation — in a
500 * dev run, a construct that mixes emulatable resources with live-only
501 * ones (e.g. R2 buckets) just works.
502 */
503 const resolveProviderAndMode = Effect.fn(function* (resource: {
504 Type: string;
505 Mode?: ProviderMode | undefined;
506 }) {
507 const base = yield* tryFindProviderRegistrationByType(resource.Type).pipe(
508 Effect.flatMap(
509 Option.match({
510 onNone: () => Effect.die(`Provider not found for ${resource.Type}`),
511 onSome: Effect.succeed,
512 }),
513 ),
514 );
515 const mode =
516 base.modes !== undefined
517 ? (resource.Mode ?? runDefaultMode)
518 : undefined;
519 const provider = yield* providerForMode(base, mode);
520 return { provider, mode };
521 });
522
523 /**
524 * Has this resource switched provider modes since it was last
525 * reconciled? Rows without a persisted mode were written by a
526 * pre-provider-mode engine (or a provider that only became dual-mode
527 * later) — their physical resource is LIVE unless its attrs carry a
528 * `dev:` identity marker proving it was reconciled locally (see
529 * {@link stampedMode}). A dev run replaces unstamped live rows exactly
530 * like stamped live rows; live runs replace unstamped marker rows.
531 */
532 const hasModeSwitched = (
533 mode: ProviderMode | undefined,
534 oldState: ResourceState | undefined,
535 ): boolean =>
536 mode !== undefined &&
537 oldState !== undefined &&
538 stampedMode(oldState) !== mode;
539
540 const resourceFqns = yield* state.list({
541 stack: stackName,
542 stage: stage,
543 });
544 const oldResources = yield* Effect.all(
545 resourceFqns.map((fqn) =>
546 state.get({ stack: stackName, stage: stage, fqn }),
547 ),
548 { concurrency: "unbounded" },
549 );
550
551 // Snapshot of every persisted row, keyed by FQN. The rename resolution
552 // below reads from this map instead of issuing per-FQN `state.get`s —
553 // every renamedFrom-decorated resource (each StaticSite carries one
554 // forever) would otherwise add two round-trips per resource to every
555 // plan against a remote state store. Plan is read-only, so the
556 // snapshot cannot go stale within this run.
557 const persistedRows = new Map(
558 resourceFqns.map((fqn, i) => [fqn, oldResources[i]]),
559 );
560
561 // Updating snapshots also retain prior bindings and downstream metadata.
562 type DependencySnapshot = {
563 downstream?: ReadonlyArray<string>;
564 bindings?: ReadonlyArray<unknown>;
565 old?: DependencySnapshot;
566 };
567 function* historicalRows(row: DependencySnapshot | undefined) {
568 while (row) {
569 yield row;
570 row = row.old;
571 }
572 }
573
574 if (selected) {
575 for (const action of actions) {
576 const row = persistedRows.get(action.FQN);
577 if (row && !isActionState(row)) {
578 return yield* Effect.die(
579 new UnsafeSelectionBoundary({
580 message: `Filtered Action '${action.FQN}' cannot overwrite persisted resource state. Finish resource cleanup before reusing its FQN for an Action.`,
581 }),
582 );
583 }
584 }
585 for (const resource of declaredResources) {
586 for (const former of resource.FormerFqns ?? []) {
587 if (former === resource.FQN) continue;
588 if (
589 (selected.has(resource.FQN) && persistedRows.has(former)) ||
590 (!selected.has(resource.FQN) && selected.has(former))
591 ) {
592 return yield* Effect.die(
593 new UnsafeSelectionBoundary({
594 message: `Filtered reconciliation cannot migrate '${former}' to '${resource.FQN}'. Run a full deployment for renames.`,
595 }),
596 );
597 }
598 }
599 }
600 }
601
602 // Credential-free dev: the adoption probe below runs each new row's
603 // `read` — for `Alchemy.remote()` rows that is a real cloud call, and
604 // it happens before apply's demand gate. Demand those credentials up
605 // front so a dev plan with nothing configured fails with the typed
606 // CredentialsRequired rather than the first read's raw auth error.
607 if (runDefaultMode === "local") {
608 const remote: Array<{ Type: string; FQN: string }> = [];
609 for (const resource of resources) {
610 const { mode } = yield* resolveProviderAndMode(resource);
611 if (mode === "live") remote.push(resource);
612 }
613 if (remote.length > 0) yield* demandRemoteCredentials(remote);
614 }
615
616 yield* reportPlanned({ _tag: "plan.phase", phase: "computing-plan" });
617
618 // ── FQN renames ──────────────────────────────────────────────────────
619 // Map every former FQN claimed via `renamedFrom(...)` to its claimant's
620 // current FQN. Two resources claiming the same former FQN is ambiguous
621 // and fatal. A former FQN MAY still be actively declared — that is the
622 // "old id reused by a new resource" case: the rename claim wins the
623 // row (it is an explicit user statement that the row was theirs), and
624 // the reusing resource plans a fresh create.
625 const formerFqnClaims = new Map<string, string>();
626 for (const resource of resources) {
627 for (const formerFqn of resource.FormerFqns ?? []) {
628 if (formerFqn === resource.FQN) continue;
629 const claimant = formerFqnClaims.get(formerFqn);
630 if (claimant !== undefined && claimant !== resource.FQN) {
631 return yield* Effect.die(
632 new Error(
633 `Resources '${claimant}' and '${resource.FQN}' both claim ` +
634 `former FQN '${formerFqn}' via renamedFrom(...). A former ` +
635 "FQN can migrate to exactly one resource — remove the " +
636 "decoration from one of them.",
637 ),
638 );
639 }
640 formerFqnClaims.set(formerFqn, resource.FQN);
641 }
642 }
643
644 // Resolve every rename migration up-front against the state snapshot,
645 // BEFORE any node is built — other resources' planning depends on the
646 // outcome (a resource declared at a former FQN whose row is migrating
647 // away must start from scratch).
648 //
649 // For each renamer, in former-id declaration order (most recent
650 // first):
651 //
652 // - `source` is the row that defines the resource's physical identity:
653 // the row at its own FQN when present AND type-matching, otherwise
654 // the first type-matching former row (→ `moved`).
655 // - every OTHER type-matching former row sharing `source.instanceId`
656 // is a leftover from an interrupted migration (only migration copies
657 // instanceIds) — collected for state-only cleanup.
658 // - former rows with a different instanceId belong to someone else and
659 // are left to normal orphan handling; former rows with a different
660 // resourceType (modulo registered type-aliases) can never be this
661 // resource's row and are skipped entirely.
662 // - a FOREIGN-typed row at the renamer's own FQN blocks the migration
663 // fatally: landing the migrated row there would silently abandon
664 // that row's cloud resource.
665 const renameMigrations = new Map<
666 string,
667 { row: ResourceState; renamedFrom: string[]; moved: boolean }
668 >();
669 const migratedRowFqns = new Set<string>();
670
671 const resolveRenamer = Effect.fn(function* (resource: ResourceLike) {
672 const provider = Option.getOrUndefined(
673 yield* tryFindProviderRegistrationByType(resource.Type),
674 );
675 const allowedTypes = new Set([
676 resource.Type,
677 ...(provider?.aliases ?? []),
678 ]);
679 const persisted = persistedRows.get(resource.FQN);
680 const persistedRow = isActionState(persisted)
681 ? undefined
682 : (persisted as ResourceState | undefined);
683 // A row at this resource's own FQN that a PREVIOUSLY RESOLVED
684 // renamer claimed (a same-deploy shift: A→B while B→C — C took the
685 // row at B) is moving away: it is not ours to keep and it does not
686 // block the migration landing here.
687 const rowTaken = migratedRowFqns.has(resource.FQN);
688 const ownRow =
689 !rowTaken &&
690 persistedRow !== undefined &&
691 allowedTypes.has(persistedRow.resourceType)
692 ? persistedRow
693 : undefined;
694
695 let source: ResourceState | undefined = ownRow;
696 let adopted: ResourceState | undefined = ownRow;
697 const renamedFrom: string[] = [];
698 for (const formerFqn of resource.FormerFqns!) {
699 if (formerFqnClaims.get(formerFqn) !== resource.FQN) continue;
700 // The same former id may be listed twice (or resolve identically);
701 // collect each former row once.
702 if (renamedFrom.includes(formerFqn)) continue;
703 const formerPersisted = persistedRows.get(formerFqn);
704 if (formerPersisted === undefined || isActionState(formerPersisted)) {
705 continue;
706 }
707 const formerRow = formerPersisted as ResourceState;
708 if (!allowedTypes.has(formerRow.resourceType)) continue;
709 if (source === undefined) {
710 source = formerRow;
711 adopted = {
712 ...formerRow,
713 fqn: resource.FQN,
714 logicalId: resource.LogicalId,
715 namespace: resource.Namespace,
716 } as ResourceState;
717 renamedFrom.push(formerFqn);
718 } else if (source.instanceId === formerRow.instanceId) {
719 renamedFrom.push(formerFqn);
720 }
721 }
722 if (renamedFrom.length === 0) return;
723 if (persistedRow !== undefined && ownRow === undefined && !rowTaken) {
724 return yield* Effect.die(
725 new Error(
726 `Cannot migrate '${renamedFrom[0]}' to '${resource.FQN}': a ` +
727 `state row of a different type ('${persistedRow.resourceType}') ` +
728 `already occupies '${resource.FQN}'. Migrating over it would ` +
729 "silently abandon that row's cloud resource. Delete or " +
730 "rename the conflicting resource first, then re-deploy.",
731 ),
732 );
733 }
734 renameMigrations.set(resource.FQN, {
735 row: adopted!,
736 renamedFrom,
737 moved: adopted !== ownRow,
738 });
739 for (const formerFqn of renamedFrom) migratedRowFqns.add(formerFqn);
740 });
741
742 // Resolve renamers in claim-dependency order: when resource R's OWN
743 // FQN is claimed as a former id by resource S (a same-deploy shift:
744 // A→B while B→C), S must resolve first — whether S takes R's row
745 // decides whether R still owns it, or falls back to ITS former rows.
746 // Iterate to fixpoint; anything left is a claim CYCLE (a swap: A⇄B),
747 // which cannot be persisted safely — the migrations would overwrite
748 // and delete each other's rows — and dies loudly.
749 let pendingRenamers = resources.filter((r) => r.FormerFqns?.length);
750 const resolvedRenamers = new Set<string>();
751 while (pendingRenamers.length > 0) {
752 const ready = pendingRenamers.filter((resource) => {
753 const claimant = formerFqnClaims.get(resource.FQN);
754 return claimant === undefined || resolvedRenamers.has(claimant);
755 });
756 if (ready.length === 0) {
757 return yield* Effect.die(
758 new Error(
759 `Rename cycle detected among [${pendingRenamers
760 .map((r) => `'${r.FQN}'`)
761 .join(
762 ", ",
763 )}]: their renamedFrom(...) declarations claim each other's ` +
764 "FQNs. Swapping ids in one deploy is not supported — rename " +
765 "through a temporary id across two deploys instead.",
766 ),
767 );
768 }
769 for (const resource of ready) {
770 yield* resolveRenamer(resource);
771 resolvedRenamers.add(resource.FQN);
772 }
773 pendingRenamers = pendingRenamers.filter(
774 (r) => !resolvedRenamers.has(r.FQN),
775 );
776 }
777
778 /**
779 * Fetch the persisted row for a declared resource, resolving renames
780 * (see the pre-computed `renameMigrations` above):
781 *
782 * - a renamer plans from its migrated row (`renamedFrom` rides onto
783 * the plan node; apply persists the move before any lifecycle op)
784 * - a resource declared at a former FQN whose row is migrating away
785 * starts from scratch — the row is NOT its state, whatever the FQN
786 * says
787 */
788 const getPersistedRow = Effect.fn(function* (
789 resource: Pick<ResourceLike, "FQN">,
790 ) {
791 const migration = renameMigrations.get(resource.FQN);
792 if (migration !== undefined) {
793 return {
794 row: migration.row,
795 renamedFrom: migration.renamedFrom,
796 renameMoved: migration.moved,
797 };
798 }
799 const persisted = yield* state.get({
800 stack: stackName,
801 stage: stage,
802 fqn: resource.FQN,
803 });
804 const row = isActionState(persisted)
805 ? undefined
806 : (persisted as ResourceState | undefined);
807 if (row !== undefined && migratedRowFqns.has(resource.FQN)) {
808 return { row: undefined, renamedFrom: undefined, renameMoved: false };
809 }
810 return { row, renamedFrom: undefined, renameMoved: false };
811 });
812
813 // Shared resolutions run in fibers of the plan's own scope (see
814 // `cachedInScope`): a diff that fails or is cancelled must not take the
815 // resolution every other diff is waiting on down with it.
816 const memoScope = yield* Effect.scope;
817 const resolvedResources: Record<string, Effect.Effect<any>> = {};
818
819 const resolveResource = (
820 resourceExpr: Output.ResourceExpr<any, any>,
821 ): Effect.Effect<any, Config.ConfigError | StateStoreError> =>
822 Effect.gen(function* () {
823 if (selected && !selected.has(resourceExpr.src.FQN)) {
824 return yield* Effect.die(
825 new UnsafeSelectionBoundary({
826 message: `Output evaluation discovered unselected dependency '${resourceExpr.src.FQN}'. Declare the dependency explicitly or run a full deployment.`,
827 }),
828 );
829 }
830 if (isAction(resourceExpr.src)) {
831 const node = yield* diffAction(resourceExpr.src);
832 return node.action === "noop" ? node.state.output : resourceExpr;
833 }
834 // @ts-expect-error
835 return yield* (resolvedResources[resourceExpr.src.FQN] ??=
836 yield* cachedInScope(memoScope)(
837 Effect.gen(function* () {
838 const resource = resourceExpr.src;
839
840 const { provider, mode } =
841 yield* resolveProviderAndMode(resource);
842 const props = materializeStableRefs(
843 yield* resolveInput(resource.Props),
844 );
845 // Falls back to the row at a former FQN (`renamedFrom`) so a
846 // renamed resource's stable attributes keep flowing to
847 // downstream diffs across the migration.
848 const { row: oldState } = yield* getPersistedRow(resource);
849
850 if (!oldState || oldState.status === "creating") {
851 return resourceExpr;
852 }
853
854 // The resource is switching provider modes (local ⇄ live):
855 // it will be replaced, so nothing about the persisted attrs
856 // is stable for downstream consumers.
857 if (hasModeSwitched(mode, oldState)) {
858 return resourceExpr;
859 }
860
861 const oldProps =
862 oldState.status === "updating"
863 ? oldState.old.props
864 : oldState.props;
865
866 // Normalize both sides through `dedupeBindings` so the binding
867 // sets handed to `diff` are deduped AND sid-sorted — provider
868 // diffs that hash/compare the arrays never churn on
869 // registration-order flips (or on legacy unsorted state).
870 const oldBindings = dedupeBindings(oldState.bindings ?? []);
871 const bindings = stack.bindings[resource.FQN] ?? [];
872 const newBindings = dedupeBindings(
873 combinedCycleMembers.has(resource.FQN)
874 ? bindings
875 : yield* resolveInput(bindings),
876 );
877
878 const diff = yield* provider.diff
879 ? provider
880 .diff({
881 id: resource.LogicalId,
882 fqn: resource.FQN,
883 olds: oldProps,
884 instanceId: oldState.instanceId,
885 news: props,
886 output: oldState.attr,
887 oldBindings,
888 newBindings,
889 })
890 .pipe(providePlanScope(resource.FQN, oldState.instanceId))
891 : Effect.succeed(undefined);
892
893 // A present `diff.stables` is authoritative for this update and
894 // overrides `provider.stables`. We only fall back to the
895 // provider-level "always stable" list when the diff does not
896 // return one (e.g. no diff fn, or a diff that omits `stables`).
897 const stables: string[] = diff?.stables ?? provider.stables ?? [];
898
899 const withStables = (output: any) =>
900 stables.length > 0
901 ? new Output.ResourceExpr(
902 resourceExpr.src,
903 Object.fromEntries(
904 stables.map((stable) => [stable, output?.[stable]]),
905 ),
906 )
907 : // if there are no stable properties, treat every property as changed
908 resourceExpr;
909
910 if (diff == null) {
911 if (
912 havePropsChanged(oldProps, props) ||
913 (!combinedCycleMembers.has(resource.FQN) &&
914 diffBindings(oldBindings, newBindings).some(
915 (binding) => binding.action !== "noop",
916 ))
917 ) {
918 // the props have changed but the provider did not provide any hints as to what is stable
919 // so we must assume everything has changed
920 return withStables(oldState?.attr);
921 }
922 } else if (diff.action === "update") {
923 return withStables(oldState?.attr);
924 } else if (diff.action === "replace") {
925 return resourceExpr;
926 }
927 // `--force` upgrades this resource's noop to an update (see the
928 // diff mapping in the resource-graph pass below), so its
929 // `reconcile` WILL re-run and may produce fresh attributes —
930 // that is the point of --force. Returning the persisted attr
931 // snapshot here would bake potentially-stale values into every
932 // consumer's plan props and binding data, so consumers would
933 // keep the stale attrs even though the upstream just
934 // re-reconciled. Expose only the stable attributes and let
935 // apply re-evaluate the rest against the forced reconcile's
936 // fresh output.
937 if (options.force) {
938 return withStables(oldState?.attr);
939 }
940 if (
941 oldState.status === "created" ||
942 oldState.status === "updated" ||
943 oldState.status === "replaced"
944 ) {
945 // we can safely return the attributes if we know they have stabilized
946 return oldState?.attr;
947 } else {
948 // we must assume the resource doesn't exist if it hasn't stabilized
949 return resourceExpr;
950 }
951 }),
952 ));
953 });
954
955 /**
956 * Resolve an input value as far as *truth* permits, keeping everything
957 * else evaluable. A whole-resource reference to an updating upstream
958 * stays a `ResourceExpr` (its stable attributes riding along): the
959 * non-stable attributes are unknown at plan time and MUST be
960 * re-evaluated — Apply runs `Output.evaluate(node.props, outputs)`
961 * against the upstream's fresh post-reconcile attributes right before
962 * `reconcile`. This is the value plan nodes carry; the diff-facing view
963 * is derived from it by {@link materializeStableRefs}.
964 */
965 const resolveInput = (
966 input: any,
967 // Ancestor chain of the current walk. A value that appears on its own
968 // ancestor path is a true cycle and is cut to `undefined` (a cycle can
969 // never persist or serialize anyway); an immutable per-level set (not
970 // a shared visited-set) keeps legitimately-shared diamond references
971 // intact and is race-free under `concurrency: "unbounded"` (#1082).
972 ancestors: ReadonlySet<object> = new Set(),
973 ): Effect.Effect<any, Config.ConfigError | StateStoreError> =>
974 Effect.gen(function* () {
975 if (!input) {
976 return input;
977 } else if (Output.isExpr(input)) {
978 return yield* resolveOutput(input);
979 } else if (Config.isConfig(input)) {
980 // Config is a lazy reference to the deploy environment. Resolve it
981 // here so the concrete value flows into diffing/hashing (an opaque
982 // Config hashes the same regardless of the underlying value) and so
983 // providers receive a resolved value instead of a Config object.
984 // `Config.Redacted` resolves to a `Redacted`, which stays opaque via
985 // the branch below.
986 return yield* resolveInput(yield* input, ancestors);
987 } else if (isResource(input)) {
988 // Resource objects have dynamic properties (path, hash, etc.) that are
989 // created on-demand by a Proxy getter and aren't enumerable via Object.entries.
990 // Resolve the ResourceExpr to get the actual resource output, then continue
991 // resolving any nested outputs in the result.
992 const resourceExpr = Output.of(input);
993 const resolved = yield* resolveOutput(resourceExpr);
994 if (Output.isResourceExpr(resolved)) {
995 // Still-unresolved reference (creating, replacing, or updating
996 // upstream) — keep it evaluable for Apply.
997 return resolved;
998 }
999 return yield* resolveInput(resolved, ancestors);
1000 } else if (isPlainData(input)) {
1001 if (ancestors.has(input)) {
1002 return undefined;
1003 }
1004 const nested = new Set(ancestors).add(input);
1005 if (Array.isArray(input)) {
1006 return yield* Effect.all(
1007 input.map((item) => resolveInput(item, nested)),
1008 { concurrency: "unbounded" },
1009 );
1010 }
1011 return Object.fromEntries(
1012 yield* Effect.all(
1013 Object.entries(input).map(([key, value]) =>
1014 resolveInput(value, nested).pipe(
1015 Effect.map((value) => [key, value]),
1016 ),
1017 ),
1018 { concurrency: "unbounded" },
1019 ),
1020 );
1021 }
1022 // Everything else is an opaque leaf returned by identity: Duration,
1023 // Redacted, Date, and effect runtime values (a Worker's `exports`
1024 // carries each DO's `constructor` Effect and captured `services`
1025 // Context). Rebuilding a class instance entry-by-entry would strip
1026 // its prototype, and effect ≥4.0.0-beta.103's Context is cyclic
1027 // (#1082). Redacted additionally stays wrapped to preserve the
1028 // secrecy boundary. This sits after `Config.isConfig` on purpose —
1029 // Configs are Effects but must still resolve.
1030 return input;
1031 });
1032
1033 /**
1034 * Project a {@link resolveInput} resolution into the DIFF-facing view:
1035 * replace each whole-resource `ResourceExpr` that carries stable
1036 * attributes (an upstream being *updated* in place — see `withStables` in
1037 * `resolveResource`) with a plain object of those stables, so the known
1038 * values flow into the consumer's `diff` and `havePropsChanged` instead
1039 * of the whole reference looking unresolved and `isResolved`
1040 * short-circuiting (#670 — this forced the Neon `Branch` to hand-extract
1041 * `project.projectId`).
1042 *
1043 * The projection is deliberately lossy — diffs compare VALUES. Plan nodes
1044 * never carry it: they keep the evaluable resolution so `reconcile` sees
1045 * the upstream's fresh non-stable attributes (e.g. a Lambda Version's
1046 * `version` number — #993's alias promotion bug).
1047 */
1048 const materializeStableRefs = (
1049 input: any,
1050 // Ancestor-path cycle guard — see resolveInput (#1082). Sync DFS, so
1051 // add/delete around the recursion is race-free.
1052 ancestors: WeakSet<object> = new WeakSet(),
1053 ): any => {
1054 // Expr checks come first: Output proxies are callable, so a plain
1055 // `typeof input === "object"` guard would let them slip through.
1056 if (Output.isResourceExpr(input) && input.stables) {
1057 return materializeStableRefs(input.stables, ancestors);
1058 } else if (Output.isExpr(input)) {
1059 // Genuinely unknown (e.g. a creating upstream) — nothing to show diff.
1060 return input;
1061 } else if (!input || !isPlainData(input)) {
1062 // Primitives and non-plain instances (Duration, Redacted, Date,
1063 // Effect/Layer/Context) are leaves — see isPlainData (#1082).
1064 return input;
1065 }
1066 return mapPlainData(input, ancestors, (child) =>
1067 materializeStableRefs(child, ancestors),
1068 );
1069 };
1070
1071 const resolveOutput = (
1072 expr: Output.Expr<any>,
1073 ): Effect.Effect<any, Config.ConfigError | StateStoreError> =>
1074 Effect.gen(function* () {
1075 if (Output.isResourceExpr(expr)) {
1076 return yield* resolveResource(expr);
1077 } else if (Output.isPropExpr(expr)) {
1078 const upstream = yield* resolveOutput(expr.expr);
1079 return upstream?.[expr.identifier];
1080 } else if (Output.isApplyExpr(expr)) {
1081 const upstream = yield* resolveOutput(expr.expr);
1082 return Output.hasOutputs(upstream) ? expr : expr.f(upstream);
1083 } else if (Output.isEffectExpr(expr)) {
1084 const upstream = yield* resolveOutput(expr.expr);
1085 return Output.hasOutputs(upstream) ? expr : yield* expr.f(upstream);
1086 } else if (Output.isFlatMapExpr(expr)) {
1087 const upstream = yield* resolveOutput(expr.expr);
1088 // Source still unresolved -> keep the flatMap intact for a later pass.
1089 // Otherwise run `f` to produce the next Output and resolve into it.
1090 return Output.hasOutputs(upstream)
1091 ? expr
1092 : yield* resolveOutput(
1093 Output.asOutput(expr.f(upstream)) as Output.Expr<any>,
1094 );
1095 } else if (Output.isAllExpr(expr)) {
1096 return yield* Effect.all(expr.outs.map(resolveOutput), {
1097 concurrency: "unbounded",
1098 });
1099 } else if (Output.isLiteralExpr(expr)) {
1100 return expr.value;
1101 } else if (Output.isRefExpr(expr)) {
1102 const refStack = expr.stack ?? stackName;
1103 const refStage = expr.stage ?? stage;
1104 const refState = yield* state
1105 .get({
1106 stack: refStack,
1107 stage: refStage,
1108 fqn: expr.resourceId,
1109 })
1110 .pipe(Effect.orDie);
1111 if (!refState) {
1112 return yield* Effect.die(
1113 new Output.InvalidReferenceError({
1114 message: `Reference to '${expr.resourceId}' in stack '${refStack}' and stage '${refStage}' not found. Have you deployed '${refStage}' of '${refStack}'?`,
1115 stack: refStack,
1116 stage: refStage,
1117 resourceId: expr.resourceId,
1118 }),
1119 );
1120 }
1121 return (refState as any).attr ?? (refState as any).output;
1122 } else if (Output.isStackRefExpr(expr)) {
1123 const refStack = expr.stack;
1124 const refStage = expr.stage ?? stage;
1125 const output = yield* state
1126 .getOutput({
1127 stack: refStack,
1128 stage: refStage,
1129 })
1130 .pipe(Effect.orDie);
1131 if (output == null) {
1132 return yield* Effect.die(
1133 new Output.InvalidReferenceError({
1134 message: `Reference to stack '${refStack}' at stage '${refStage}' not found. Have you deployed stage '${refStage}' of '${refStack}'?`,
1135 stack: refStack,
1136 stage: refStage,
1137 resourceId: refStack,
1138 }),
1139 );
1140 }
1141 return output;
1142 } else if (Output.isNamedExpr(expr)) {
1143 return yield* resolveOutput(expr.expr);
1144 }
1145 return yield* Effect.die(
1146 new Error("Not implemented yet" + (expr as any).kind),
1147 );
1148 });
1149
1150 // Build a set of FQNs for the new resources to detect orphans
1151 const newResourceFqns = new Set(resources.map((r) => r.FQN));
1152 const newActionFqns = new Set(actions.map((t) => t.FQN));
1153 // Unified set used wherever the DAG must include both kinds.
1154 const newNodeFqns = new Set<string>([...newResourceFqns, ...newActionFqns]);
1155
1156 // Map FQN -> list of upstream FQNs (resources this one depends on via props).
1157 // Tasks contribute upstream edges through their `Input` expression.
1158 const newUpstreamDependencies: {
1159 [fqn: string]: string[];
1160 } = Object.fromEntries([
1161 ...resources.map(
1162 (resource) =>
1163 [
1164 resource.FQN,
1165 Object.values(Output.upstreamAny(resource.Props)).map((r) => r.FQN),
1166 ] as const,
1167 ),
1168 ...actions.map(
1169 (action) =>
1170 [
1171 action.FQN,
1172 // An Action depends on the resources referenced by its Input *and*
1173 // any Output captured via `yield* output` inside its init Effect.
1174 Array.from(
1175 new Set([
1176 ...Object.values(Output.upstreamAny(action.Input)).map(
1177 (r) => r.FQN,
1178 ),
1179 ...Object.values(Output.upstreamAny(action.Captures)).map(
1180 (r) => r.FQN,
1181 ),
1182 ]),
1183 ),
1184 ] as const,
1185 ),
1186 ]);
1187
1188 // Map FQN -> list of upstream FQNs from bindings
1189 const bindingUpstreamDependencies: {
1190 [fqn: string]: string[];
1191 } = Object.fromEntries(
1192 resources.map((resource) => [
1193 resource.FQN,
1194 Object.values(
1195 Output.upstreamAny(stack.bindings[resource.FQN] ?? []),
1196 ).map((r) => r.FQN),
1197 ]),
1198 );
1199
1200 // Combined prop + binding upstream for the desired graph, including
1201 // references to resources outside the current graph so delete validation can
1202 // tell whether any surviving resource still points at an orphan.
1203 const rawUpstreamDependencies: {
1204 [fqn: string]: string[];
1205 } = Object.fromEntries<string[]>([
1206 ...resources.map((resource): [string, string[]] => {
1207 const fqn = resource.FQN;
1208 const propDeps = newUpstreamDependencies[fqn] ?? [];
1209 const bindDeps = bindingUpstreamDependencies[fqn] ?? [];
1210 return [fqn, [...new Set([...propDeps, ...bindDeps])]];
1211 }),
1212 // Actions have no bindings — their upstream is input + init captures,
1213 // both already folded into newUpstreamDependencies above.
1214 ...actions.map((action): [string, string[]] => {
1215 const fqn = action.FQN;
1216 return [fqn, newUpstreamDependencies[fqn] ?? []];
1217 }),
1218 ]);
1219
1220 // Combined prop + binding upstream, filtered to resources/tasks in this
1221 // graph for scheduling and cycle detection.
1222 const allUpstreamDependencies: {
1223 [fqn: string]: string[];
1224 } = Object.fromEntries([
1225 ...resources.map((resource) => {
1226 const fqn = resource.FQN;
1227 const deps = rawUpstreamDependencies[fqn] ?? [];
1228 return [fqn, deps.filter((dep) => newNodeFqns.has(dep))] as const;
1229 }),
1230 ...actions.map((action) => {
1231 const fqn = action.FQN;
1232 const deps = newUpstreamDependencies[fqn] ?? [];
1233 return [fqn, deps.filter((dep) => newNodeFqns.has(dep))] as const;
1234 }),
1235 ]);
1236
1237 // Resources that participate in a cycle when both prop and binding
1238 // edges are considered. Used below to decide whether an acyclic
1239 // binding edge should also become a downstream edge.
1240 const combinedCycleMembers = findCycleMembers(allUpstreamDependencies);
1241 const inputCycleMembers = findCycleMembers({
1242 ...newUpstreamDependencies,
1243 ...Object.fromEntries(
1244 actions.map((action) => [
1245 action.FQN,
1246 Object.values(Output.upstreamAny(action.Input)).map((r) => r.FQN),
1247 ]),
1248 ),
1249 });
1250
1251 // Map FQN -> list of downstream FQNs (resources/actions that depend on
1252 // this one).
1253 //
1254 // Prop edges always become downstream edges — they can't form cycles
1255 // (the resource graph is a DAG by construction once props are fully
1256 // resolved). Binding edges become downstream edges too, except when
1257 // they participate in a cycle in the combined graph: mutual bindings
1258 // (A binds to B's data, B binds to A's data) intentionally do not
1259 // create downstream edges, so deletion does not deadlock waiting on
1260 // each other (see the "binding-only cycles inside a construct" test
1261 // in `plan.test.ts`).
1262 //
1263 // Including acyclic binding edges is required for cloud APIs that
1264 // enforce the binding at the provider level — e.g. a Cloudflare
1265 // Worker with a `service` binding to another Worker cannot delete
1266 // the upstream worker until the downstream worker's binding has been
1267 // removed. Without the binding edge in `downstream`, the two delete
1268 // concurrently and the upstream delete fails with
1269 // `ServiceBindingConflict`.
1270 //
1271 // Actions don't have bindings, so for action upstreams we use only
1272 // prop edges (which collapse to `newUpstreamDependencies` lookups).
1273 const computeDownstream = (upFqn: string): string[] => {
1274 const downstream: string[] = [];
1275 for (const [downFqn, upstreams] of Object.entries(
1276 rawUpstreamDependencies,
1277 )) {
1278 if (downFqn === upFqn) continue;
1279 if (!upstreams.includes(upFqn)) continue;
1280 const isPropEdge = (newUpstreamDependencies[downFqn] ?? []).includes(
1281 upFqn,
1282 );
1283 if (isPropEdge) {
1284 downstream.push(downFqn);
1285 continue;
1286 }
1287 // Binding-only edge — exclude when both endpoints sit inside
1288 // the same SCC of the combined graph.
1289 if (
1290 combinedCycleMembers.has(upFqn) &&
1291 combinedCycleMembers.has(downFqn)
1292 ) {
1293 continue;
1294 }
1295 downstream.push(downFqn);
1296 }
1297 if (selected) {
1298 for (const row of historicalRows(persistedRows.get(upFqn))) {
1299 for (const dep of row.downstream ?? []) {
1300 if (!selected.has(dep) && !downstream.includes(dep))
1301 downstream.push(dep);
1302 }
1303 }
1304 }
1305 return downstream;
1306 };
1307
1308 const newDownstreamDependencies: {
1309 [fqn: string]: string[];
1310 } = Object.fromEntries([
1311 ...resources.map(
1312 (resource) => [resource.FQN, computeDownstream(resource.FQN)] as const,
1313 ),
1314 ...actions.map(
1315 (action) => [action.FQN, computeDownstream(action.FQN)] as const,
1316 ),
1317 ]);
1318
1319 const actionPlans: Record<
1320 string,
1321 Effect.Effect<
1322 ActionRun | ActionNoop,
1323 Config.ConfigError | StateStoreError
1324 >
1325 > = {};
1326 const diffAction = Effect.fn("plan.diff.action")(function* (
1327 action: ActionLike,
1328 ) {
1329 return yield* (actionPlans[action.FQN] ??= yield* cachedInScope(
1330 memoScope,
1331 )(
1332 Effect.gen(function* () {
1333 const fqn = action.FQN;
1334 const downstream = newDownstreamDependencies[fqn] ?? [];
1335 const oldState = yield* state.get({ stack: stackName, stage, fqn });
1336 const prior = isActionState(oldState) ? oldState : undefined;
1337 const resolvedInput = inputCycleMembers.has(fqn)
1338 ? action.Input
1339 : yield* resolveInput(action.Input);
1340 // Actions are pure in their inputs. Only a completed, unforced run
1341 // with fully known, identical inputs has a reusable output.
1342 if (
1343 prior?.status === "ran" &&
1344 !options.force &&
1345 !inputCycleMembers.has(fqn) &&
1346 isResolved(resolvedInput) &&
1347 prior.inputHash === (yield* hashInput(resolvedInput))
1348 ) {
1349 return {
1350 kind: "action",
1351 action: "noop",
1352 def: action,
1353 state: prior,
1354 downstream,
1355 } satisfies ActionNoop;
1356 }
1357 return {
1358 kind: "action",
1359 action: "run",
1360 def: action,
1361 input: action.Input,
1362 state: prior,
1363 downstream,
1364 forced: !!options.force,
1365 } satisfies ActionRun;
1366 }),
1367 ));
1368 });
1369
1370 const plannedCount = yield* Ref.make(0);
1371 const resourcePlanned = (node: CRUD) =>
1372 Ref.updateAndGet(plannedCount, (count) => count + 1).pipe(
1373 Effect.flatMap((completed) =>
1374 reportPlanned({
1375 _tag: "plan.resource.completed",
1376 ...describeResource(node),
1377 completed,
1378 total: resources.length,
1379 }),
1380 ),
1381 );
1382
1383 const diffResource = Effect.fn("plan.diff.resource")(function* (
1384 resource: ResourceLike,
1385 ) {
1386 const { provider, mode } = yield* resolveProviderAndMode(resource);
1387 const id = resource.LogicalId;
1388 const fqn = resource.FQN;
1389 // Paired with the resource-planned completion below so renderers can
1390 // show what is actually in flight (diffs may read the cloud and run
1391 // concurrently — completions alone hide the slow tail).
1392 yield* reportPlanned({
1393 _tag: "plan.resource.started",
1394 fqn,
1395 logicalId: id,
1396 resourceType: resource.Type,
1397 total: resources.length,
1398 });
1399 // Apply-facing props (stored on the plan node): whole-resource
1400 // references to updating upstreams stay evaluable `ResourceExpr`s.
1401 // Apply runs `Output.evaluate(node.props, outputs)` right before
1402 // `reconcile`, so these references resolve to the upstream's
1403 // fresh post-reconcile attributes.
1404 const applyProps = yield* resolveInput(resource.Props);
1405 // Diff-facing view of the same resolution: stable attributes
1406 // materialized so their known values flow into `diff` /
1407 // `havePropsChanged`.
1408 const news = materializeStableRefs(applyProps);
1409 const downstream = newDownstreamDependencies[fqn] ?? [];
1410
1411 // Apply-facing binding rows, mirroring `applyProps`: payloads
1412 // whose data embeds a whole-resource reference to an updating
1413 // upstream keep it as an evaluable `ResourceExpr`. Apply runs
1414 // `Output.evaluate(node.bindings, outputs)` right before
1415 // `reconcile`, so the host receives the upstream's fresh
1416 // post-reconcile attributes. Collapse duplicates by sid so the
1417 // binding set handed to `diff` matches what `reconcile` receives
1418 // (see `dedupeBindings`).
1419 const applyBindings: ResourceBinding[] = dedupeBindings(
1420 yield* resolveInput(stack.bindings[fqn] ?? []),
1421 );
1422 // Diff-facing view of the same rows: stable attributes
1423 // materialized so `diffBindings` / `provider.diff` compare known
1424 // values. Terminal commits still persist the payload the provider
1425 // actually reconciled with (#874) — Apply commits the evaluated
1426 // `bindingOutputs`, not these plan-time shapes.
1427 const newBindings: ResourceBinding[] =
1428 materializeStableRefs(applyBindings);
1429 // The row is looked up at the resource's FQN with a fallback to
1430 // its former FQNs (`renamedFrom`); a row found under a former
1431 // FQN arrives here already remapped to the new identity, and
1432 // `renamedFrom` rides onto the plan node so apply persists the
1433 // move. (A Task previously holding this FQN is treated as no
1434 // prior state — its row is reaped by `actionDeletions` below.)
1435 const {
1436 row: persistedRow,
1437 renamedFrom,
1438 renameMoved,
1439 } = yield* getPersistedRow(resource);
1440 let oldState: ResourceState | undefined = persistedRow;
1441
1442 // Engine-level adoption. When there is no prior state, always
1443 // consult `provider.read` (if implemented) so the engine — not
1444 // each lifecycle method — owns the existence/ownership decision.
1445 //
1446 // The provider returns one of:
1447 // - `undefined` → resource doesn't exist; create it
1448 // - plain attrs → exists and is owned by us; silent adopt
1449 // - `Unowned(attrs)` → exists but is *not* ours
1450 //
1451 // Routing:
1452 // - owned → adopt the `created` state
1453 // from attrs and continue
1454 // through the normal diff
1455 // path (so subsequent props
1456 // drift produces an update).
1457 // - unowned + adopt enabled → take over: adopt the
1458 // `created` state and let the
1459 // next update overwrite tags.
1460 // - unowned + adopt disabled → fail with
1461 // `OwnedBySomeoneElse`.
1462 //
1463 // Plan construction is side-effect-free: the adopted `created`
1464 // state is only held in-memory here (to drive the diff) and rides
1465 // onto the plan node as `node.state`. Persisting it to the state
1466 // store happens exclusively during APPLY of that node (the update
1467 // lifecycle commits `updating` / `updated` carrying this state).
1468 // If planning persisted here, a mere `alchemy plan` / `--dry-run`
1469 // would claim ownership of an unowned cloud resource, arming a
1470 // later unrelated deploy to orphan-delete it. See
1471 // https://github.com/alchemy-run/alchemy/issues/793.
1472 //
1473 // After a cold-start adoption (engine just discovered an
1474 // existing cloud resource via `read`), force the engine's
1475 // normal `update` path so the provider can re-sync ownership
1476 // tags, configuration, etc. against the desired props.
1477 // Adoption carries state with `props: news`, so the default
1478 // diff sees no drift and would noop — which would leave any
1479 // foreign-owned tags / divergent config in place. Forcing
1480 // update keeps the deploy idempotent: if cloud state already
1481 // matches news, the provider's update is a no-op write.
1482 //
1483 // Skip the adoption probe entirely when `news` still contains
1484 // unresolved upstream Outputs (e.g. a `streamArn` referencing
1485 // a stream being created in the same plan). Calling `read` with
1486 // an unresolved value would surface as `ParseError` from the
1487 // SDK protocol layer. Upstream creation can produce existing children
1488 // (e.g. a cloned branch's Auth integration), so Apply must perform the
1489 // deferred ownership probe once those identities resolve.
1490 // A resource declared at a former FQN whose row just migrated
1491 // away is genuinely NEW by declaration — skip the probe. Its
1492 // predecessor's physical resource still carries tags branded
1493 // with THIS logical id (the migrated row's reconcile hasn't
1494 // re-branded them yet), so a tag-based `read` would find it
1495 // and silently adopt the very resource that was renamed away.
1496 const claimant = formerFqnClaims.get(fqn);
1497 const claimantRow =
1498 claimant === undefined ? undefined : persistedRows.get(claimant);
1499 const reusesMigratedFqn =
1500 migratedRowFqns.has(fqn) ||
1501 oldState?.adoptionBlocked === "migrated-fqn" ||
1502 // Migration can commit before the fresh create records its exclusion.
1503 (oldState === undefined &&
1504 claimantRow !== undefined &&
1505 !isActionState(claimantRow));
1506 let forceUpdateAfterAdoption = false;
1507 if (
1508 oldState === undefined &&
1509 provider.read &&
1510 isResolved(news) &&
1511 !reusesMigratedFqn
1512 ) {
1513 const adoptInstanceId = yield* generateInstanceId();
1514 const readResult = yield* provider
1515 .read({
1516 id,
1517 fqn,
1518 instanceId: adoptInstanceId,
1519 olds: news,
1520 output: undefined,
1521 })
1522 .pipe(providePlanScope(fqn, adoptInstanceId));
1523 if (readResult !== undefined) {
1524 const isUnowned = Unowned.is(readResult);
1525 // A resource-scoped `adopt(...)` (captured on the resource at
1526 // registration) overrides the stack/CLI default.
1527 const adoptThis = resource.Adopt ?? (yield* shouldAdopt);
1528 if (isUnowned && !adoptThis) {
1529 return yield* new OwnedBySomeoneElse({
1530 message:
1531 `Cannot adopt resource '${fqn}' (${resource.Type}): ` +
1532 "it exists in the cloud but is not owned by this " +
1533 "stack/stage/logical-id. Re-run with `--adopt` (or " +
1534 "wrap the effect in `adopt(true)`) to take it over.",
1535 resourceType: resource.Type,
1536 logicalId: id,
1537 });
1538 }
1539 const adoptedState = {
1540 status: "created" as const,
1541 fqn,
1542 logicalId: id,
1543 instanceId: adoptInstanceId,
1544 namespace: resource.Namespace,
1545 resourceType: resource.Type,
1546 props: news,
1547 attr: stripUnowned(readResult),
1548 providerVersion: provider.version ?? 0,
1549 bindings: [],
1550 downstream,
1551 removalPolicy: resource.RemovalPolicy,
1552 providerMode: mode,
1553 } satisfies CreatedResourceState;
1554 // In-memory only — do NOT persist here. Plan.make runs for
1555 // `alchemy plan` / `deploy --dry-run` too, so a `state.set`
1556 // would mutate persistent state during a read-only preview.
1557 // The adopted state rides onto the plan node via `oldState`
1558 // (→ `node.state`) and is persisted at APPLY time by the
1559 // update lifecycle's `updating` / `updated` commits. See
1560 // https://github.com/alchemy-run/alchemy/issues/793.
1561 oldState = adoptedState;
1562 forceUpdateAfterAdoption = true;
1563 }
1564 }
1565
1566 // Sid-sorted like `newBindings` (see the resolveResource note).
1567 const oldBindings = dedupeBindings(oldState?.bindings ?? []);
1568 // Actions come from the materialized comparison (`newBindings`);
1569 // the payloads the node carries into Apply come from the
1570 // apply-faithful rows, joined by sid — both are views of the same
1571 // deduped `stack.bindings[fqn]` rows, so action and payload can
1572 // never drift. `delete` rows keep the persisted old data.
1573 const applyBindingData = new Map(
1574 applyBindings.map((b) => [b.sid, b.data]),
1575 );
1576 const bindingDiffs = diffBindings(oldBindings, newBindings).map((b) =>
1577 b.action === "delete" || !applyBindingData.has(b.sid)
1578 ? b
1579 : { ...b, data: applyBindingData.get(b.sid) },
1580 );
1581
1582 // Local ⇄ live switch: the persisted row was reconciled by a
1583 // different provider mode than the one resolved for this run.
1584 // The two runtimes host distinct physical instances, so this is
1585 // always a replacement — the new instance is created with the
1586 // new mode's provider, and Apply deletes the old generation
1587 // with the provider of the mode that created it (see
1588 // `deleteOldGenerations` / `collectGarbage`).
1589 const modeSwitched = hasModeSwitched(mode, oldState);
1590 if (selected && modeSwitched && oldState?.status === "deleting") {
1591 return yield* Effect.die(
1592 new UnsafeSelectionBoundary({
1593 message: `Cannot switch provider mode for '${fqn}' during filtered recovery from deleting. Finish interrupted destruction in its recorded '${stampedMode(oldState)}' mode before deploying in '${mode}' mode; a full mode-switch deployment is not a safe recovery.`,
1594 }),
1595 );
1596 }
1597
1598 const adoptThis = resource.Adopt ?? (yield* shouldAdopt);
1599 const Node = <T extends Apply>(
1600 node: Omit<
1601 T,
1602 "provider" | "resource" | "bindings" | "downstream" | "mode"
1603 >,
1604 ) =>
1605 ({
1606 ...node,
1607 provider,
1608 resource,
1609 bindings: bindingDiffs,
1610 downstream,
1611 mode,
1612 renamedFrom,
1613 adoptionBlocked: reusesMigratedFqn ? "migrated-fqn" : undefined,
1614 deferredAdoption:
1615 node.action === "create" &&
1616 provider.read &&
1617 !reusesMigratedFqn &&
1618 ((oldState === undefined && !isResolved(news)) ||
1619 (oldState?.status === "creating" && oldState.attr === undefined))
1620 ? { adopt: adoptThis }
1621 : undefined,
1622 }) as any as T;
1623
1624 // Plan against the persisted state we have, not the ideal final state we
1625 // hoped to reach last time. Recovery is expressed by mapping each
1626 // intermediate state back onto a fresh CRUD action.
1627 if (oldState === undefined) {
1628 return Node<Create>({
1629 action: "create",
1630 props: applyProps,
1631 state: oldState,
1632 });
1633 } else if (
1634 !modeSwitched &&
1635 oldState.status === "creating" &&
1636 oldState.attr === undefined
1637 ) {
1638 // A create may have succeeded before state persistence failed. If the
1639 // provider can recover an attribute snapshot, keep driving the same
1640 // create instead of starting over blindly.
1641 //
1642 // Early `creating` checkpoints contain raw plan-time props, which may
1643 // still contain unresolved Output expressions (e.g. a name
1644 // referencing an upstream created in the same failed deploy).
1645 // `read` implementations derive identity from `olds` when
1646 // `output` is undefined (as it is here), so handing them
1647 // unresolved exprs crashes. Skip the probe — same behavior as
1648 // a read that found nothing — and re-drive the create.
1649 if (provider.read && !reusesMigratedFqn && isResolved(oldState.props)) {
1650 const attr = yield* provider
1651 .read({
1652 id,
1653 fqn,
1654 instanceId: oldState.instanceId,
1655 olds: oldState.props,
1656 output: oldState.attr,
1657 })
1658 .pipe(
1659 providePlanScope(fqn, oldState.instanceId),
1660 // `creating` props pass `isResolved` yet can still carry
1661 // holes where unresolved Outputs were stripped at commit
1662 // time (see stripUnresolved) — e.g. a parent reference
1663 // persisted as `{}`. A provider that dereferences one
1664 // crashes deep inside its SDK client (a SchemaError
1665 // defect), which would brick every subsequent plan on
1666 // the stage. Recovery is best-effort: degrade the defect
1667 // to "nothing recovered" and re-drive the create (#995).
1668 Effect.catchDefect((defect) =>
1669 Effect.logWarning(
1670 `Recovery read for '${fqn}' crashed; treating the ` +
1671 "interrupted create as not recoverable and " +
1672 "re-driving it.",
1673 defect,
1674 ).pipe(Effect.as(undefined)),
1675 ),
1676 );
1677 if (attr !== undefined) {
1678 // The recovered resource may be foreign: our interrupted
1679 // create could have lost a name race, or died before
1680 // stamping ownership. Route `Unowned` through the same
1681 // adoption table as the cold-start probe above — never
1682 // silently take over (and later mutate/delete) a resource
1683 // we cannot prove we created.
1684 if (Unowned.is(attr)) {
1685 const adoptThis = resource.Adopt ?? (yield* shouldAdopt);
1686 if (!adoptThis) {
1687 return yield* new OwnedBySomeoneElse({
1688 message:
1689 `Cannot resume creating resource '${fqn}' ` +
1690 `(${resource.Type}): a resource with its physical ` +
1691 "identity exists in the cloud but is not owned by " +
1692 "this stack/stage/logical-id. Re-run with `--adopt` " +
1693 "(or wrap the effect in `adopt(true)`) to take it " +
1694 "over.",
1695 resourceType: resource.Type,
1696 logicalId: id,
1697 });
1698 }
1699 }
1700 // Continue through the normal diff below with the recovered
1701 // live snapshot. Desired props may have changed while the
1702 // previous create was interrupted; bypassing diff here can
1703 // drive an immutable change through reconcile and falsely
1704 // persist the old physical resource as converged.
1705 oldState = { ...oldState, attr: stripUnowned(attr) };
1706 }
1707 }
1708 }
1709
1710 // Diff against whatever props represent the best-known current attempt.
1711 // For replacement recovery that means the top-level replacement props,
1712 // not the older generations stored under `old`.
1713 const oldProps = oldState.props;
1714
1715 // On a mode switch the provider diff is skipped entirely:
1716 // comparing props across runtimes is meaningless (and the new
1717 // mode's provider has never seen the old mode's state). The
1718 // action is a replacement by definition.
1719 const diff = modeSwitched
1720 ? ({
1721 action: "replace",
1722 deleteFirst: false,
1723 } satisfies ReplaceDiff)
1724 : yield* asEffect(
1725 provider
1726 ?.diff?.({
1727 id,
1728 fqn,
1729 olds: oldProps,
1730 instanceId: oldState.instanceId,
1731 output: oldState.attr,
1732 news,
1733 oldBindings,
1734 newBindings,
1735 })
1736 .pipe(providePlanScope(fqn, oldState.instanceId)),
1737 ).pipe(
1738 Effect.map(
1739 (diff) =>
1740 diff ??
1741 ({
1742 action:
1743 havePropsChanged(oldProps, news) ||
1744 bindingDiffs.some((b) => b.action !== "noop")
1745 ? "update"
1746 : "noop",
1747 } as UpdateDiff | NoopDiff),
1748 ),
1749 // Values a Platform's Init captured (`yield* Config.x(...)`,
1750 // `yield* output`) reach the provider only through `props.env`,
1751 // and a provider's diff may not compare `env` (#1831). The engine
1752 // compares them itself so a changed value always deploys.
1753 Effect.map((diff) =>
1754 diff.action === "noop" &&
1755 capturedEnvChanged(resource, oldProps, news)
1756 ? ({ action: "update" } satisfies UpdateDiff)
1757 : diff,
1758 ),
1759 Effect.map((diff) =>
1760 options.force && diff.action === "noop"
1761 ? ({
1762 action: "update",
1763 } satisfies UpdateDiff)
1764 : diff,
1765 ),
1766 // After a cold-start adoption (silent or takeover), force at
1767 // least an update so the provider re-syncs ownership tags /
1768 // config against the desired props (otherwise the engine
1769 // would noop and any drift between the existing cloud
1770 // resource and `news` — including foreign-owned tags after a
1771 // takeover — would persist).
1772 //
1773 // A row that just migrated from a former FQN (`renameMoved`)
1774 // gets the same treatment: its cloud resource is still
1775 // branded with the OLD logical id's tags, and if the old id
1776 // is being reused by a new resource, leaving them stale
1777 // would let the reuser's future adoption probes match the
1778 // wrong physical resource.
1779 Effect.map((diff) =>
1780 (forceUpdateAfterAdoption || renameMoved) &&
1781 diff.action === "noop"
1782 ? ({ action: "update" } satisfies UpdateDiff)
1783 : diff,
1784 ),
1785 );
1786
1787 if (oldState.status === "creating") {
1788 if (diff.action === "noop") {
1789 // we're in the creating state and props are un-changed
1790 // let's just continue where we left off
1791 return Node<Create>({
1792 action: "create",
1793 props: applyProps,
1794 state: oldState,
1795 });
1796 } else if (diff.action === "update") {
1797 // props have changed in a way that is updatable
1798 // again, just continue with the create
1799 // TODO(sam): should we maybe try an update instead?
1800 return Node<Create>({
1801 action: "create",
1802 props: applyProps,
1803 state: oldState,
1804 });
1805 } else {
1806 // props have changed in an incompatible way
1807 // because it's possible that an un-updatable resource has already been created
1808 // we must use a replace step to create a new one and delete the potential old one
1809 return Node<Replace>({
1810 action: "replace",
1811 props: applyProps,
1812 deleteFirst: diff.deleteFirst ?? false,
1813 state: oldState,
1814 });
1815 }
1816 } else if (oldState.status === "updating") {
1817 // Updating already targets the live resource, so noop/update both mean
1818 // "finish the interrupted update". Only a replace diff escalates it
1819 // into a fresh replacement.
1820 if (diff.action === "update" || diff.action === "noop") {
1821 // we can continue where we left off
1822 return Node<Update>({
1823 action: "update",
1824 props: applyProps,
1825 state: oldState,
1826 });
1827 } else {
1828 // we started to update a resource but now believe we should replace it
1829 return Node<Replace>({
1830 action: "replace",
1831 deleteFirst: diff.deleteFirst ?? false,
1832 props: applyProps,
1833 // TODO(sam): can Apply handle replacements when the oldState is UpdatingResourceState?
1834 // -> or should we do a provider.read to try and reconcile back to UpdatedResourceState?
1835 state: oldState,
1836 });
1837 }
1838 } else if (oldState.status === "replacing") {
1839 // The replacement candidate is still being created. Noop/update keep
1840 // driving the same generation; replace means that candidate itself is
1841 // now obsolete and must be wrapped in a new outer generation.
1842 if (diff.action === "noop") {
1843 // this is the stable case - noop means just continue with the replacement
1844 return Node<Replace>({
1845 action: "replace",
1846 deleteFirst: oldState.deleteFirst,
1847 props: applyProps,
1848 state: oldState,
1849 });
1850 } else if (diff.action === "update") {
1851 // potential problem here - the props have changed since we tried to replace,
1852 // but not enough to trigger another replacement. the resource provider should
1853 // be designed as idempotent to converge to the right state when creating the new resource
1854 // the newly generated instanceId is intended to assist with this
1855 return Node<Replace>({
1856 action: "replace",
1857 deleteFirst: oldState.deleteFirst,
1858 props: applyProps,
1859 state: oldState,
1860 });
1861 } else {
1862 // The in-flight replacement candidate itself now needs replacement.
1863 // Mark this as a restart so Apply creates a fresh generation instead
1864 // of resuming the old replacement instance.
1865 return Node<Replace>({
1866 restart: true,
1867 action: "replace",
1868 deleteFirst: diff.deleteFirst ?? oldState.deleteFirst,
1869 props: applyProps,
1870 state: oldState,
1871 });
1872 }
1873 } else if (oldState.status === "replaced") {
1874 // The new resource already exists. Noop means "just let GC finish",
1875 // update means "mutate the current replacement before GC finishes",
1876 // and replace means "the current replacement also became obsolete".
1877 if (diff.action === "noop") {
1878 // this is the stable case - noop means just continue cleaning up the replacement
1879 return Node<Replace>({
1880 action: "replace",
1881 deleteFirst: oldState.deleteFirst,
1882 props: applyProps,
1883 state: oldState,
1884 });
1885 } else if (diff.action === "update") {
1886 // the replacement has been created but now also needs to be updated
1887 // the resource provider should:
1888 // 1. Update the newly created replacement resource
1889 // 2. Then proceed as normal to delete the replaced resources (after all downstream references are updated)
1890 return Node<Update>({
1891 action: "update",
1892 props: applyProps,
1893 state: oldState,
1894 });
1895 } else {
1896 // Cleanup is still pending, but the current "new" resource has already
1897 // become obsolete. Start another replacement generation and preserve
1898 // the existing replaced node as part of the recursive old chain.
1899 return Node<Replace>({
1900 restart: true,
1901 action: "replace",
1902 deleteFirst: diff.deleteFirst ?? oldState.deleteFirst,
1903 props: applyProps,
1904 state: oldState,
1905 });
1906 }
1907 } else if (oldState.status === "deleting") {
1908 // we're in a partially deleted state, it is unclear whether it was or was not deleted
1909 // so continue by re-creating it with the same instanceId and desired props
1910 return Node<Create>({
1911 action: "create",
1912 props: applyProps,
1913 state: {
1914 ...oldState,
1915 status: "creating",
1916 props: news,
1917 },
1918 });
1919 } else if (diff.action === "update") {
1920 // Stable created/updated resources follow the normal CRUD mapping.
1921 return Node<Update>({
1922 action: forceUpdateAfterAdoption ? "adopted" : "update",
1923 adopting: forceUpdateAfterAdoption,
1924 props: applyProps,
1925 state: oldState,
1926 });
1927 } else if (diff.action === "replace") {
1928 return Node<Replace>({
1929 action: "replace",
1930 props: applyProps,
1931 state: oldState,
1932 deleteFirst: diff?.deleteFirst ?? false,
1933 });
1934 } else {
1935 return Node<NoopUpdate>({
1936 action: "noop",
1937 state: oldState,
1938 });
1939 }
1940 });
1941
1942 // Every diff runs to completion and every failure is reported. With
1943 // fail-fast `Effect.all`, the first exit to arrive wins the aggregate
1944 // cause — and when a diff is cancelled by a sibling's failure (or ends
1945 // interrupt-only for any other reason) that can be a bare interruption,
1946 // hiding the InvalidReferenceError or provider error that actually
1947 // broke the plan.
1948 const diffExits = yield* Effect.all(
1949 resources.map((resource) =>
1950 Effect.exit(Effect.tap(diffResource(resource), resourcePlanned)),
1951 ),
1952 { concurrency: "unbounded" },
1953 );
1954 const diffFailures = diffExits.filter(Exit.isFailure);
1955 if (diffFailures.length > 0) {
1956 const reasons = diffFailures.flatMap((exit) => exit.cause.reasons);
1957 const failures = reasons.filter(
1958 (reason) => !Cause.isInterruptReason(reason),
1959 );
1960 return yield* Effect.failCause(
1961 Cause.fromReasons(failures.length > 0 ? failures : reasons),
1962 );
1963 }
1964 const resourceGraph = Object.fromEntries(
1965 diffExits
1966 .filter(Exit.isSuccess)
1967 .map((exit) => [exit.value.resource.FQN, exit.value]),
1968 ) as Plan["resources"];
1969
1970 if (selected) {
1971 const downstream = new Map<string, Set<string>>();
1972 const addEdge = (source: string, consumer: string) => {
1973 let edges = downstream.get(source);
1974 if (!edges) downstream.set(source, (edges = new Set()));
1975 edges.add(consumer);
1976 };
1977 for (const node of declared) {
1978 for (const source of upstream(node)) addEdge(source, node.FQN);
1979 }
1980 let bindingEvidence: string | undefined;
1981 let incompleteHistory: string | undefined;
1982 for (const [fqn, row] of persistedRows) {
1983 for (const snapshot of historicalRows(row)) {
1984 for (const consumer of snapshot.downstream ?? []) {
1985 addEdge(fqn, consumer);
1986 }
1987 if (snapshot.bindings?.length) bindingEvidence ??= fqn;
1988 if (snapshot.downstream === undefined) incompleteHistory ??= fqn;
1989 }
1990 }
1991 const unselectedConsumers = (fqn: string) => {
1992 const reachable = new Set([fqn]);
1993 const outside = new Set<string>();
1994 for (const source of reachable) {
1995 for (const consumer of downstream.get(source) ?? []) {
1996 reachable.add(consumer);
1997 if (!selected.has(consumer)) outside.add(consumer);
1998 }
1999 }
2000 return outside;
2001 };
2002 for (const source of selected) {
2003 for (const row of historicalRows(persistedRows.get(source))) {
2004 for (const consumer of row.downstream ?? []) {
2005 // SCC filtering alone does not detach an edge in the desired graph.
2006 if (
2007 !selected.has(consumer) ||
2008 rawUpstreamDependencies[consumer]?.includes(source)
2009 )
2010 continue;
2011 const outside = unselectedConsumers(consumer);
2012 if (outside.size > 0) {
2013 return yield* Effect.die(
2014 new UnsafeSelectionBoundary({
2015 message: `Cannot detach '${source}' from '${consumer}' while downstream consumers are unselected: ${[...outside].sort().join(", ")}. Select them or run a full deployment.`,
2016 }),
2017 );
2018 }
2019 }
2020 }
2021 }
2022 const excludedRows = [...persistedRows].filter(
2023 ([fqn, row]) => row && !selected.has(fqn),
2024 );
2025 const excluded = excludedRows[0];
2026 for (const [fqn, node] of Object.entries(resourceGraph)) {
2027 const pending =
2028 node.state?.status === "replacing" ||
2029 node.state?.status === "replaced";
2030 if (node.action !== "replace" && !pending) continue;
2031 // Resolved payloads do not identify binding sources, including cycles
2032 // omitted from downstream. Evidence anywhere can involve this resource.
2033 if (excluded && (bindingEvidence || incompleteHistory)) {
2034 return yield* Effect.die(
2035 new UnsafeSelectionBoundary({
2036 message: `Cannot replace or collect old generations of '${fqn}' while '${excluded[0]}' is unselected. ${bindingEvidence ? `Historical binding-cycle dependencies recorded on '${bindingEvidence}'` : `Historical dependencies missing from '${incompleteHistory}'`} cannot be proven safe; run a full deployment.`,
2037 }),
2038 );
2039 }
2040 const outside = unselectedConsumers(fqn);
2041 if (outside.size > 0) {
2042 return yield* Effect.die(
2043 new UnsafeSelectionBoundary({
2044 message: `Cannot replace or collect old generations of '${fqn}' while dependents are unselected: ${[...outside].sort().join(", ")}. Select them or run a full deployment.`,
2045 }),
2046 );
2047 }
2048 }
2049 if (excludedRows.length > 0) {
2050 for (const fqn of selected) {
2051 if (
2052 [...historicalRows(persistedRows.get(fqn))].some(
2053 (snapshot) => snapshot.downstream === undefined,
2054 )
2055 ) {
2056 return yield* Effect.die(
2057 new UnsafeSelectionBoundary({
2058 message: `Cannot reconcile '${fqn}' with incomplete historical downstream metadata while persisted nodes are unselected: ${excludedRows
2059 .map(([id]) => id)
2060 .sort()
2061 .join(", ")}. Select them or run a full deployment.`,
2062 }),
2063 );
2064 }
2065 }
2066 }
2067 if (bindingEvidence && excludedRows.length > 0) {
2068 const hasHistoricalBindings = (row: DependencySnapshot | undefined) =>
2069 [...historicalRows(row)].some(
2070 (snapshot) => snapshot.bindings?.length,
2071 );
2072 // Independent writes can fail before planned replacement evidence is durable.
2073 const survivingBindings =
2074 excludedRows.some(([, row]) => hasHistoricalBindings(row)) ||
2075 Object.entries(resourceGraph).some(([fqn, node]) => {
2076 const persisted = persistedRows.get(fqn);
2077 if (
2078 !persisted ||
2079 isActionState(persisted) ||
2080 !hasHistoricalBindings(persisted)
2081 )
2082 return false;
2083 if (node.action === "noop") return true;
2084 return (
2085 (node.action === "update" ||
2086 node.action === "adopted" ||
2087 (node.action === "create" &&
2088 persisted.status === "creating")) &&
2089 persisted.status !== "deleting" &&
2090 persisted.status !== "replacing" &&
2091 persisted.status !== "replaced" &&
2092 node.bindings.some((binding) => binding.action !== "delete")
2093 );
2094 });
2095 if (!survivingBindings) {
2096 return yield* Effect.die(
2097 new UnsafeSelectionBoundary({
2098 message: `Cannot discard the last historical binding evidence while persisted nodes are unselected: ${excludedRows
2099 .map(([fqn]) => fqn)
2100 .sort()
2101 .join(", ")}. Select them or run a full deployment.`,
2102 }),
2103 );
2104 }
2105 }
2106 }
2107
2108 // ── Action plan nodes ────────────────────────────────────────────────
2109 // Per-action plan progress, mirroring the resource pass.
2110 const actionCount = yield* Ref.make(0);
2111 const actionPlanned = (entry: readonly [string, ActionApply]) =>
2112 Ref.updateAndGet(actionCount, (count) => count + 1).pipe(
2113 Effect.flatMap((completed) =>
2114 reportPlanned({
2115 _tag: "plan.action.completed",
2116 ...describeAction(entry[1]),
2117 completed,
2118 total: actions.length,
2119 }),
2120 ),
2121 );
2122
2123 const actionGraph = Object.fromEntries(
2124 (yield* Effect.all(
2125 actions.map((action) =>
2126 diffAction(action).pipe(
2127 Effect.map((node) => [action.FQN, node] as const),
2128 Effect.tap(actionPlanned),
2129 ),
2130 ),
2131 { concurrency: "unbounded" },
2132 )) as ReadonlyArray<readonly [string, ActionApply]>,
2133 ) as Plan["actions"];
2134
2135 // SCC membership of the combined upstream graph. Apply uses it to
2136 // decide whether an update node must publish its prior attr early to
2137 // break a cycle, or can simply wait for upstreams like a DAG node
2138 // (the common case). Computed once, above, for the downstream graph.
2139 const cycleMembers = combinedCycleMembers;
2140
2141 // Detect unsatisfiable dependency cycles among create/replace nodes.
2142 // Update/noop nodes signal their Deferred before waitForDeps when in a
2143 // cycle so they cannot deadlock. Create/replace nodes only signal
2144 // early when they have a precreate handler. Simulate the concurrent
2145 // execution: precreate nodes are immediately "resolved", then
2146 // iteratively resolve any node whose deps are all resolved. Remaining
2147 // nodes would deadlock.
2148 {
2149 const createReplaceNodes = new Set(
2150 Object.entries(resourceGraph)
2151 .filter(
2152 ([, node]) => node.action === "create" || node.action === "replace",
2153 )
2154 .map(([fqn]) => fqn),
2155 );
2156
2157 if (createReplaceNodes.size > 0) {
2158 const hasPrecreate = new Set(
2159 [...createReplaceNodes].filter(
2160 (fqn) => !!resourceGraph[fqn]?.provider?.precreate,
2161 ),
2162 );
2163
2164 const resolved = new Set(hasPrecreate);
2165 let changed = true;
2166 while (changed) {
2167 changed = false;
2168 for (const fqn of createReplaceNodes) {
2169 if (resolved.has(fqn)) continue;
2170 const deps = (allUpstreamDependencies[fqn] ?? []).filter((dep) =>
2171 createReplaceNodes.has(dep),
2172 );
2173 if (deps.every((dep) => resolved.has(dep))) {
2174 resolved.add(fqn);
2175 changed = true;
2176 }
2177 }
2178 }
2179
2180 const deadlocked = [...createReplaceNodes].filter(
2181 (fqn) => !resolved.has(fqn),
2182 );
2183 if (deadlocked.length > 0) {
2184 const missingPrecreate = deadlocked.filter(
2185 (fqn) => !hasPrecreate.has(fqn),
2186 );
2187 return yield* Effect.die(
2188 new UnsatisfiedResourceCycle({
2189 message:
2190 `Circular dependency detected that cannot be resolved: [${deadlocked.join(", ")}]. ` +
2191 `Resources lacking a precreate handler: [${missingPrecreate.join(", ")}]. ` +
2192 `All resources in a dependency cycle must implement precreate to allow early signaling.`,
2193 cycle: deadlocked,
2194 missingPrecreate,
2195 }),
2196 );
2197 }
2198 }
2199 }
2200
2201 // Both orphan passes (task rows below, resource rows further down)
2202 // examine every persisted row of this stack instance.
2203 const persistedFqns = selected
2204 ? []
2205 : yield* state.list({ stack: stackName, stage });
2206
2207 // Orphan progress mirrors the diff passes: every persisted row is
2208 // examined, but only rows that actually become deletion nodes are
2209 // reported — `completed` tracks examination so progress stays monotonic.
2210 const actionExaminedCount = yield* Ref.make(0);
2211 const actionDeletionPlanned = (
2212 entry: readonly [string, ActionDelete] | undefined,
2213 ) =>
2214 Ref.updateAndGet(actionExaminedCount, (count) => count + 1).pipe(
2215 Effect.flatMap((completed) =>
2216 entry === undefined
2217 ? Effect.void
2218 : reportPlanned({
2219 _tag: "plan.action.completed",
2220 ...describeAction(entry[1]),
2221 completed,
2222 total: persistedFqns.length,
2223 }),
2224 ),
2225 );
2226
2227 // Task deletions: state rows previously written by tasks that no
2228 // longer appear in the stack. The body is NOT invoked — we just drop
2229 // the row.
2230 const diffActionDeletion = Effect.fn("plan.diff.actionDeletion")(function* (
2231 fqn: string,
2232 ) {
2233 if (newActionFqns.has(fqn) || newResourceFqns.has(fqn)) return;
2234 const persisted = yield* state.get({
2235 stack: stackName,
2236 stage,
2237 fqn,
2238 });
2239 if (!isActionState(persisted)) return;
2240 const { logicalId } = parseFqn(fqn);
2241 return [
2242 fqn,
2243 {
2244 kind: "action",
2245 action: "delete",
2246 state: persisted,
2247 downstream: persisted.downstream ?? [],
2248 def: {
2249 Kind: "action",
2250 Namespace: persisted.namespace,
2251 FQN: fqn,
2252 LogicalId: logicalId,
2253 Type: persisted.actionType,
2254 Input: persisted.input,
2255 Captures: {},
2256 Run: () => undefined as any,
2257 Output: undefined as any,
2258 } satisfies ActionLike,
2259 } satisfies ActionDelete,
2260 ] as const;
2261 });
2262
2263 const actionDeletions: Plan["actionDeletions"] = Object.fromEntries(
2264 (yield* Effect.all(
2265 persistedFqns.map((fqn) =>
2266 diffActionDeletion(fqn).pipe(
2267 Effect.tap((entry) => actionDeletionPlanned(entry ?? undefined)),
2268 ),
2269 ),
2270 { concurrency: "unbounded" },
2271 )).filter((v): v is NonNullable<typeof v> => !!v),
2272 );
2273
2274 // Deletion progress mirrors the resource pass: every persisted row is
2275 // examined, but only rows that actually become deletion nodes are
2276 // reported — `completed` tracks examination so progress stays monotonic.
2277 const deletionCandidates = persistedFqns;
2278 const examinedCount = yield* Ref.make(0);
2279 const deletionPlanned = (entry: readonly [string, Delete] | undefined) =>
2280 Ref.updateAndGet(examinedCount, (count) => count + 1).pipe(
2281 Effect.flatMap((completed) =>
2282 entry === undefined
2283 ? Effect.void
2284 : reportPlanned({
2285 _tag: "plan.resource.completed",
2286 ...describeResource(entry[1]),
2287 completed,
2288 total: deletionCandidates.length,
2289 }),
2290 ),
2291 );
2292
2293 const diffDeletion = Effect.fn("plan.diff.deletion")(function* (
2294 fqn: string,
2295 ) {
2296 if (newResourceFqns.has(fqn) || newActionFqns.has(fqn)) {
2297 return;
2298 }
2299 const persisted = yield* state.get({
2300 stack: stackName,
2301 stage: stage,
2302 fqn,
2303 });
2304 // Tasks are routed through `actionDeletions` above.
2305 if (isActionState(persisted)) return;
2306 const oldState = persisted as ResourceState | undefined;
2307 if (oldState) {
2308 // A row being migrated by a rename (`renameMigrations`) is
2309 // moving, not orphaned — apply drops it state-only after
2310 // committing the migrated row at its new FQN. Rows at former
2311 // FQNs that did NOT migrate (foreign type, different
2312 // instanceId, unclaimed) are absent from this set and fall
2313 // through to normal orphan deletion.
2314 if (migratedRowFqns.has(fqn)) {
2315 return;
2316 }
2317 const { logicalId } = parseFqn(fqn);
2318 const resourceType = oldState.resourceType;
2319 // A "zombie" row references a type with no registered provider
2320 // (removed from the program, or renamed without an alias).
2321 // That is fatal: the program and state disagree, and without
2322 // the provider the row's physical resource cannot be deleted
2323 // anyway. Die at plan time with a typed error naming the row
2324 // and the remediation instead of limping into a partial apply.
2325 //
2326 // Orphan deletes resolve the provider variant for the mode
2327 // that created the row (`providerMode`, or the `dev:` marker
2328 // inference for legacy unstamped rows), so e.g. a local dev
2329 // worker's row is deleted by the local provider even during a
2330 // live deploy — and vice versa. Unstamped rows are physically
2331 // live unless their attrs carry the marker (see stampedMode),
2332 // never the run default.
2333 const rowMode = stampedMode(oldState);
2334 const providerOption = yield* tryFindProviderByType(
2335 resourceType,
2336 rowMode,
2337 );
2338 if (Option.isNone(providerOption)) {
2339 return yield* Effect.die(missingProviderError(resourceType, fqn));
2340 }
2341 const provider = providerOption.value;
2342 const downstream = new Set(oldState.downstream);
2343 let generation = oldState;
2344 while (
2345 generation.status === "replacing" ||
2346 generation.status === "replaced"
2347 ) {
2348 generation = generation.old;
2349 for (const dep of generation.downstream) downstream.add(dep);
2350 }
2351 // NOTE: an attr-less row (interrupted create) is NOT recovered
2352 // here. Apply's `deleteResource` performs the authoritative
2353 // read-then-delete recovery — it also covers replaced-chain
2354 // old generations that never pass through plan, and routes
2355 // `Unowned` results away from `provider.delete`.
2356 return [
2357 fqn,
2358 {
2359 action: oldState.removalPolicy === "retain" ? "orphaned" : "delete",
2360 state: oldState,
2361 provider: provider,
2362 mode: oldState.providerMode,
2363 resource: {
2364 Namespace: oldState.namespace,
2365 FQN: fqn,
2366 LogicalId: logicalId,
2367 Type: oldState.resourceType,
2368 Attributes: oldState.attr,
2369 Props: oldState.props,
2370 Binding: undefined!,
2371 Provider: Provider(resourceType),
2372 RemovalPolicy: oldState.removalPolicy,
2373 Adopt: undefined,
2374 RequiresImplementation: undefined,
2375 Mode: oldState.providerMode,
2376 FormerFqns: undefined,
2377 RuntimeContext: undefined!,
2378 Providers: undefined,
2379 } as ResourceLike,
2380 downstream: [...downstream],
2381 bindings: oldState.bindings.map((binding) => ({
2382 sid: binding.sid,
2383 action: "delete" as const,
2384 data: binding.data,
2385 })),
2386 } satisfies Delete,
2387 ] as const;
2388 }
2389 });
2390
2391 const deletions = Object.fromEntries(
2392 (yield* Effect.all(
2393 deletionCandidates.map((fqn) =>
2394 diffDeletion(fqn).pipe(
2395 Effect.tap((entry) => deletionPlanned(entry ?? undefined)),
2396 ),
2397 ),
2398 { concurrency: "unbounded" },
2399 )).filter((v) => !!v),
2400 );
2401
2402 for (const resourceFqn of Object.keys(deletions)) {
2403 const dependencies = Object.entries(rawUpstreamDependencies)
2404 .filter(
2405 ([survivorFqn, upstream]) =>
2406 survivorFqn in resourceGraph && upstream.includes(resourceFqn),
2407 )
2408 .map(([survivorFqn]) => survivorFqn);
2409 if (dependencies.length > 0) {
2410 return yield* new DeleteResourceHasDownstreamDependencies({
2411 message: `Resource ${resourceFqn} has downstream dependencies`,
2412 resourceId: resourceFqn,
2413 dependencies,
2414 });
2415 }
2416 }
2417
2418 return {
2419 resources: resourceGraph,
2420 actions: actionGraph,
2421 deletions,
2422 actionDeletions,
2423 output: selected ? undefined : stack.output,
2424 selectedFqns: selected,
2425 cycleMembers,
2426 defaultMode: runDefaultMode,
2427 } satisfies Plan<A | undefined> as Plan<A | undefined>;
2428 }).pipe(
2429 // Owns the memoized resolutions' fibers for the plan's duration.
2430 Effect.scoped,
2431 ensureArtifactStore,
2432 Effect.withSpan("plan.make", {
2433 attributes: {
2434 "alchemy.stack": stack.name,
2435 "alchemy.stage": stack.stage,
2436 "alchemy.resources.count": Object.keys(stack.resources).length,
2437 "alchemy.force": !!options.force,
2438 },
2439 }),
2440 );
2441
2442/**
2443 * Build the plan that destroys every resource of `(stack.name, stack.stage)`.
2444 *
2445 * The spec is emptied out so every persisted resource becomes an orphan
2446 * deletion, and `output` is left undefined so `apply` does not overwrite the
2447 * last deploy's persisted stack output with an empty husk. `apply` recognizes
2448 * the `destroy` marker and deletes the stage's remaining persisted state (the
2449 * stack output record) once the destroy has converged, so `state.getOutput`
2450 * and `state.listStages` agree the stage is gone.
2451 *
2452 * @see https://github.com/alchemy-run/alchemy/issues/961
2453 */
2454export const destroy = (stack: {
2455 name: string;
2456 stage: string;
2457 include?: never;
2458 exclude?: never;
2459}): Effect.Effect<Plan<undefined>, never, State> =>
2460 stack.include !== undefined || stack.exclude !== undefined
2461 ? Effect.die(
2462 new InvalidResourceSelection({
2463 message: "Filtered destroy is not supported.",
2464 }),
2465 )
2466 : make({
2467 name: stack.name,
2468 stage: stack.stage,
2469 resources: {},
2470 bindings: {},
2471 actions: {},
2472 output: undefined,
2473 }).pipe(Effect.map((plan) => ({ ...plan, destroy: true })));
2474
2475/**
2476 * Whether any `env` value a Platform's Init captured differs from the
2477 * persisted props. An unresolved value counts as changed.
2478 */
2479const capturedEnvChanged = (
2480 resource: ResourceLike,
2481 olds: { env?: Record<string, unknown> } | undefined,
2482 news: { env?: Record<string, unknown> } | undefined,
2483): boolean => {
2484 const keys = capturedEnvKeys(resource);
2485 if (keys.length === 0) return false;
2486 const pick = (props: { env?: Record<string, unknown> } | undefined) =>
2487 Object.fromEntries(keys.map((key) => [key, props?.env?.[key]]));
2488 return havePropsChanged(pick(olds), pick(news));
2489};
2490
2491const providePlanScope =
2492 (fqn: string, instanceId: string) =>
2493 <A, E, R>(effect: Effect.Effect<A, E, R>) =>
2494 Effect.serviceOption(ArtifactStore).pipe(
2495 Effect.map(Option.getOrElse(createArtifactStore)),
2496 Effect.flatMap((store) =>
2497 effect.pipe(
2498 failCredentialsRequired(fqn),
2499 Effect.provideService(Artifacts, makeScopedArtifacts(store, fqn)),
2500 Effect.provideService(InstanceId, instanceId),
2501 ),
2502 ),
2503 );
2504
2505export class DeleteResourceHasDownstreamDependencies extends Data.TaggedError(
2506 "DeleteResourceHasDownstreamDependencies",
2507)<{
2508 message: string;
2509 resourceId: string;
2510 dependencies: string[];
2511}> {}
2512
2513export class UnsatisfiedResourceCycle extends Data.TaggedError(
2514 "UnsatisfiedResourceCycle",
2515)<{
2516 message: string;
2517 cycle: string[];
2518 missingPrecreate: string[];
2519}> {}
2520
2521// TODO(sam): compare props
2522// oldBinding.props !== newBinding.props;
2523
2524/**
2525 * Print a plan in a human-readable format that shows the graph topology.
2526 */
2527export const printPlan = (plan: Plan): string => {
2528 const lines: string[] = [];
2529 const allNodes = { ...plan.resources, ...plan.deletions };
2530
2531 // Build reverse mapping: upstream -> downstream
2532 const upstreamMap: Record<string, string[]> = {};
2533 for (const [id] of Object.entries(allNodes)) {
2534 upstreamMap[id] = [];
2535 }
2536 for (const [id, node] of Object.entries(allNodes)) {
2537 if (!node) continue;
2538 for (const downstreamId of node.state?.downstream ?? []) {
2539 if (upstreamMap[downstreamId]) {
2540 upstreamMap[downstreamId].push(id);
2541 }
2542 }
2543 }
2544
2545 // Action symbols
2546 const actionSymbol = (action: string) => {
2547 switch (action) {
2548 case "create":
2549 return "+";
2550 case "update":
2551 return "~";
2552 case "adopted":
2553 return "(+)";
2554 case "delete":
2555 return "-";
2556 case "orphaned":
2557 return "(-)";
2558 case "replace":
2559 return "±";
2560 case "noop":
2561 return "=";
2562 default:
2563 return "?";
2564 }
2565 };
2566
2567 // Print header
2568 lines.push(
2569 "╔════════════════════════════════════════════════════════════════╗",
2570 );
2571 lines.push(
2572 "║ PLAN ║",
2573 );
2574 lines.push(
2575 "╠════════════════════════════════════════════════════════════════╣",
2576 );
2577 lines.push(
2578 "║ Legend: + create, ~ update, - delete, ± replace, = noop, ║",
2579 );
2580 lines.push(
2581 "║ λ run task, · skip task ║",
2582 );
2583 lines.push(
2584 "╚════════════════════════════════════════════════════════════════╝",
2585 );
2586 lines.push("");
2587
2588 // Print resources section
2589 lines.push(
2590 "┌─ Resources ────────────────────────────────────────────────────┐",
2591 );
2592 const resourceIds = Object.keys(plan.resources).sort();
2593 for (const id of resourceIds) {
2594 const node = plan.resources[id];
2595 const symbol = actionSymbol(node.action);
2596 const type = node.resource?.type ?? "unknown";
2597 const downstream = node.state?.downstream?.length
2598 ? ` → [${node.state?.downstream.join(", ")}]`
2599 : "";
2600 const renamed = node.renamedFrom?.length
2601 ? ` (renamed from ${node.renamedFrom.join(", ")})`
2602 : "";
2603 lines.push(`│ [${symbol}] ${id} (${type})${downstream}${renamed}`);
2604 }
2605 if (resourceIds.length === 0) {
2606 lines.push("│ (none)");
2607 }
2608 lines.push(
2609 "└────────────────────────────────────────────────────────────────┘",
2610 );
2611 lines.push("");
2612
2613 // Print tasks section
2614 lines.push(
2615 "┌─ Tasks ────────────────────────────────────────────────────────┐",
2616 );
2617 const taskIds = Object.keys(plan.actions ?? {}).sort();
2618 for (const id of taskIds) {
2619 const node = plan.actions[id];
2620 const symbol = node.action === "run" ? "λ" : "·";
2621 const type = node.def.Type;
2622 const downstream = node.downstream.length
2623 ? ` → [${node.downstream.join(", ")}]`
2624 : "";
2625 lines.push(`│ [${symbol}] ${id} (${type})${downstream}`);
2626 }
2627 if (taskIds.length === 0) {
2628 lines.push("│ (none)");
2629 }
2630 lines.push(
2631 "└────────────────────────────────────────────────────────────────┘",
2632 );
2633 lines.push("");
2634
2635 // Print deletions section
2636 lines.push(
2637 "┌─ Deletions ────────────────────────────────────────────────────┐",
2638 );
2639 const deletionIds = Object.keys(plan.deletions).sort();
2640 for (const id of deletionIds) {
2641 const node = plan.deletions[id]!;
2642 const type = node.resource?.Type ?? "unknown";
2643 const downstream = node.state.downstream?.length
2644 ? ` → [${node.state.downstream.join(", ")}]`
2645 : "";
2646 lines.push(`│ [-] ${id} (${type})${downstream}`);
2647 }
2648 const taskDeletionIds = Object.keys(plan.actionDeletions ?? {}).sort();
2649 for (const id of taskDeletionIds) {
2650 const node = plan.actionDeletions[id]!;
2651 lines.push(`│ [-] ${id} (${node.def.Type}) [action]`);
2652 }
2653 if (deletionIds.length === 0 && taskDeletionIds.length === 0) {
2654 lines.push("│ (none)");
2655 }
2656 lines.push(
2657 "└────────────────────────────────────────────────────────────────┘",
2658 );
2659 lines.push("");
2660
2661 return lines.join("\n");
2662};