| 1 | /** @effect-diagnostics anyUnknownInErrorContext:off */ |
| 2 | /** @effect-diagnostics missingEffectError:off */ |
| 3 | import * as Cause from "effect/Cause"; |
| 4 | import * as Config from "effect/Config"; |
| 5 | import * as Data from "effect/Data"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as Exit from "effect/Exit"; |
| 8 | import * as Option from "effect/Option"; |
| 9 | import * as Ref from "effect/Ref"; |
| 10 | import { cachedInScope } from "./Util/Memoize.ts"; |
| 11 | import { asEffect } from ".//Util/types.ts"; |
| 12 | import { isAction, type ActionLike } from "./Action.ts"; |
| 13 | import { |
| 14 | AdoptPolicy, |
| 15 | OwnedBySomeoneElse, |
| 16 | stripUnowned, |
| 17 | Unowned, |
| 18 | } from "./AdoptPolicy.ts"; |
| 19 | import { AlchemyContext } from "./AlchemyContext.ts"; |
| 20 | import { |
| 21 | demandRemoteCredentials, |
| 22 | failCredentialsRequired, |
| 23 | } from "./Auth/Demand.ts"; |
| 24 | import { |
| 25 | Artifacts, |
| 26 | ArtifactStore, |
| 27 | createArtifactStore, |
| 28 | ensureArtifactStore, |
| 29 | makeScopedArtifacts, |
| 30 | } from "./Artifacts.ts"; |
| 31 | import { |
| 32 | dedupeBindings, |
| 33 | diffBindings, |
| 34 | havePropsChanged, |
| 35 | isResolved, |
| 36 | type NoopDiff, |
| 37 | type ReplaceDiff, |
| 38 | type UpdateDiff, |
| 39 | } from "./Diff.ts"; |
| 40 | import { capturedEnvKeys } from "./RuntimeContext.ts"; |
| 41 | import { parseFqn } from "./FQN.ts"; |
| 42 | import { generateInstanceId, InstanceId } from "./InstanceId.ts"; |
| 43 | import * as Output from "./Output.ts"; |
| 44 | import { |
| 45 | tryFindProviderRegistrationByType, |
| 46 | missingProviderError, |
| 47 | Provider, |
| 48 | providerForMode, |
| 49 | tryFindProviderByType, |
| 50 | type ProviderService, |
| 51 | } from "./Provider.ts"; |
| 52 | import { |
| 53 | defaultProviderMode, |
| 54 | stampedMode, |
| 55 | type ProviderMode, |
| 56 | } from "./ProviderMode.ts"; |
| 57 | import { |
| 58 | Progress, |
| 59 | type PlannedAction, |
| 60 | type PlannedBinding, |
| 61 | type PlannedResource, |
| 62 | } from "./Report.ts"; |
| 63 | import { |
| 64 | isResource, |
| 65 | missingImplementation, |
| 66 | type ResourceBinding, |
| 67 | type ResourceLike, |
| 68 | } from "./Resource.ts"; |
| 69 | import { |
| 70 | InvalidResourceSelection, |
| 71 | UnsafeSelectionBoundary, |
| 72 | selectResources, |
| 73 | type ResourceSelection, |
| 74 | type SelectionOutput, |
| 75 | } from "./ResourceSelection.ts"; |
| 76 | export { |
| 77 | InvalidResourceSelection, |
| 78 | UnsafeSelectionBoundary, |
| 79 | } from "./ResourceSelection.ts"; |
| 80 | import { type StackSpec } from "./Stack.ts"; |
| 81 | import { |
| 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"; |
| 95 | import { isPlainData, mapPlainData } from "./Util/data.ts"; |
| 96 | import { findCycleMembers } from "./Util/scc.ts"; |
| 97 | import { hashInput } from "./Util/sha256.ts"; |
| 98 | |
| 99 | export type PlanError = never; |
| 100 | |
| 101 | /** |
| 102 | * A node in the plan that represents a resource CRUD operation. |
| 103 | */ |
| 104 | export type CRUD<R extends ResourceLike = ResourceLike> = |
| 105 | | Create<R> |
| 106 | | Update<R> |
| 107 | | Delete<R> |
| 108 | | Replace<R> |
| 109 | | NoopUpdate<R>; |
| 110 | |
| 111 | export type Apply<R extends ResourceLike = ResourceLike> = |
| 112 | | Create<R> |
| 113 | | Update<R> |
| 114 | | Replace<R> |
| 115 | | NoopUpdate<R>; |
| 116 | |
| 117 | export type BindingAction = "create" | "update" | "delete" | "noop"; |
| 118 | |
| 119 | export interface BindingNode<Data = any> extends ResourceBinding { |
| 120 | action: BindingAction; |
| 121 | data: Data; |
| 122 | } |
| 123 | |
| 124 | export 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 | */ |
| 148 | export 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 | |
| 171 | export 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 | |
| 181 | export 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 | |
| 197 | export 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 | |
| 205 | export interface NoopUpdate< |
| 206 | R extends ResourceLike = ResourceLike, |
| 207 | > extends ApplyNodeBase<R> { |
| 208 | action: "noop"; |
| 209 | state: CreatedResourceState | UpdatedResourceState; |
| 210 | } |
| 211 | |
| 212 | export 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 | |
| 234 | export type ActionApply<T extends ActionLike = ActionLike> = |
| 235 | | ActionRun<T> |
| 236 | | ActionNoop<T>; |
| 237 | |
| 238 | export interface ActionNodeBase<T extends ActionLike = ActionLike> { |
| 239 | readonly kind: "action"; |
| 240 | def: T; |
| 241 | downstream: string[]; |
| 242 | } |
| 243 | |
| 244 | export 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 | |
| 256 | export interface ActionNoop< |
| 257 | T extends ActionLike = ActionLike, |
| 258 | > extends ActionNodeBase<T> { |
| 259 | action: "noop"; |
| 260 | state: RanActionState; |
| 261 | } |
| 262 | |
| 263 | export interface ActionDelete< |
| 264 | T extends ActionLike = ActionLike, |
| 265 | > extends ActionNodeBase<T> { |
| 266 | action: "delete"; |
| 267 | state: ActionState; |
| 268 | } |
| 269 | |
| 270 | export 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 | |
| 316 | const 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}). */ |
| 322 | export 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. */ |
| 339 | export 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 | */ |
| 353 | export 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 | |
| 373 | export interface MakePlanOptions { |
| 374 | force?: boolean; |
| 375 | include?: never; |
| 376 | exclude?: never; |
| 377 | } |
| 378 | |
| 379 | export 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 | |
| 403 | type FullPlanCall = [options?: MakePlanOptions]; |
| 404 | |
| 405 | export 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>; |
| 412 | export function make<A>( |
| 413 | stack: StackSpec<A>, |
| 414 | options: FilteredPlanOptions = {}, |
| 415 | ) { |
| 416 | return makePlan(stack, options); |
| 417 | } |
| 418 | |
| 419 | const 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 | */ |
| 2454 | export 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 | */ |
| 2479 | const 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 | |
| 2491 | const 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 | |
| 2505 | export class DeleteResourceHasDownstreamDependencies extends Data.TaggedError( |
| 2506 | "DeleteResourceHasDownstreamDependencies", |
| 2507 | )<{ |
| 2508 | message: string; |
| 2509 | resourceId: string; |
| 2510 | dependencies: string[]; |
| 2511 | }> {} |
| 2512 | |
| 2513 | export 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 | */ |
| 2527 | export 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 | }; |