> =>
effect.pipe(
provideLifecycleScope(fqn, instanceId),
recordResourceOp(resourceType, op),
Effect.withSpan(`provider.${op}`, {
attributes: {
"alchemy.resource.fqn": fqn,
"alchemy.resource.type": resourceType,
"alchemy.resource.logical_id": logicalId,
"alchemy.resource.instance_id": instanceId,
"alchemy.resource.op": op,
},
}),
);
export const apply = (
plan: P,
): Effect.Effect<
Input.Resolve
,
| Output.InvalidReferenceError
| Output.MissingSourceError
| StateStoreError
| DestroyError
| CredentialsRequired
| AuthError
| PlatformError
| ConfigError,
Cli | State | Stack | Stage
> =>
Effect.gen(function* () {
// Credential-free dev: a dev-mode plan that needs the real cloud
// (`Alchemy.remote()` rows, remote-proxied bindings, deletions of rows
// stamped `providerMode: "live"`) demands cloud credentials exactly
// once, up front, BEFORE any lifecycle operation runs — a fully-local
// dev plan demands nothing. Non-dev runs never enter the seam: live
// providers keep the pre-existing lazy credential flow. Wired here (not
// in Deploy/Destroy) because `apply` is the single choke point every
// path shares — CLI deploy/destroy, `Test.make` deploys, and
// `test.provider` scratch stacks. See `Auth/Demand.ts`.
const alchemy = yield* Effect.serviceOption(AlchemyContext);
if (Option.isSome(alchemy) && alchemy.value.dev) {
yield* demandPlanCredentials(plan);
}
const cli = yield* Cli;
const session = yield* cli.startApplySession(plan);
const state = yield* yield* State;
const stack = yield* Stack;
const stage = yield* Stage;
const stackName = stack.name;
const tracker: Record = {};
const terminalStatuses = new Map<
string,
{
id: string;
type: string;
status: Extract;
providerMode?: ProviderMode;
}
>();
// ── FQN migrations (renamedFrom) ──
// Persist renames before any lifecycle operation runs: a node whose row
// was found under a former FQN carries that row pre-remapped in
// `node.state` (see Plan's rename resolution). Commit it at the current
// FQN FIRST, then drop the former row — in that order, so an
// interruption leaves rows at both FQNs with the same instanceId, which
// the next plan recognizes as an in-flight migration (and never as an
// orphan to delete).
//
// In a same-deploy shift (A→B while B→C), C's former FQN `B` is
// simultaneously B's migration TARGET. C must NOT delete it: B's own
// `state.set` supersedes the stale copy, and the migrations run
// concurrently — the delete could land after B's write and destroy the
// freshly migrated row.
const migrationTargets = new Set(
Object.values(plan.resources)
.filter((node) => node.renamedFrom?.length && node.state !== undefined)
.map((node) => node.resource.FQN),
);
yield* Effect.forEach(
Object.values(plan.resources),
(node) => {
const { renamedFrom, state: row } = node;
return renamedFrom === undefined ||
renamedFrom.length === 0 ||
row === undefined
? Effect.void
: Effect.gen(function* () {
yield* state.set({
stack: stackName,
stage,
fqn: node.resource.FQN,
value: row,
});
yield* Effect.forEach(
renamedFrom.filter(
(formerFqn) => !migrationTargets.has(formerFqn),
),
(formerFqn) =>
state.delete({ stack: stackName, stage, fqn: formerFqn }),
{ concurrency: "unbounded" },
);
});
},
{ concurrency: "unbounded" },
);
yield* executePlan(
plan,
tracker,
terminalStatuses,
session,
state,
stackName,
stage,
);
// TODO(sam): support roll back to previous state if errors occur during expansion
// -> RISK: some UPDATEs may not be reversible (i.e. trigger replacements)
// TODO(sam): should pivot be done separately? E.g shift traffic?
yield* collectGarbage(plan, session);
yield* converge(
plan,
tracker,
terminalStatuses,
session,
state,
stackName,
stage,
);
yield* Effect.forEach(
Array.from(terminalStatuses.values()),
({ id, type, status, providerMode }) =>
session.emit({ kind: "status-change", id, type, status, providerMode }),
{ concurrency: "unbounded" },
);
yield* session.done();
if (plan.destroy) {
// The destroy converged: every resource row was deleted above. Drop
// the rest of the stage's persisted state — notably the stack output
// record written by the last deploy — so `getOutput` returns
// undefined and `listStages` no longer reports the stage.
// https://github.com/alchemy-run/alchemy/issues/961
yield* state.deleteStack({ stack: stackName, stage });
// Invariant: a successful destroy leaves the stage EMPTY. If rows
// survive, this destroy session could not actually see (or delete)
// the stack's state — e.g. its plan listed an empty store while
// committed rows existed — and reporting success here would silently
// leak every cloud resource those rows track. Fail loudly instead so
// the leak surfaces in the run that caused it.
const remaining = yield* state.list({ stack: stackName, stage });
if (remaining.length > 0) {
return yield* Effect.fail(
new StateStoreError({
message:
`destroy of ${stackName}/${stage} reported success but ${remaining.length} ` +
`state row(s) remain (${remaining.join(", ")}) — the destroy session could ` +
`not see the stack's persisted state, so its cloud resources were NOT deleted`,
}),
);
}
return undefined;
}
if (!plan.output) {
return undefined;
}
const outputs = Object.fromEntries(
Object.entries(tracker).map(([fqn, t]) => [fqn, t.output]),
);
const resolved = yield* Output.evaluate(plan.output, outputs);
// Persist the stack's evaluated outputs so cross-stack references
// (`yield* OtherStack` / `OtherStack.stage.` / `Output.stackRef`)
// can read them back out of the state store.
yield* state.setOutput({ stack: stackName, stage, value: resolved });
return resolved;
}).pipe(
ensureArtifactStore,
Effect.withSpan("apply", {
attributes: {
"alchemy.resources.count": Object.keys(plan.resources).length,
"alchemy.deletions.count": Object.keys(plan.deletions).length,
},
}),
);
// ── Phase 1: concurrent initial execution ──────────────────────────────────
//
// Each resource gets a Deferred that signals "I have some output
// available in `tracker`." Resources with `precreate` signal early so that
// downstream resources can resolve stable identifiers without deadlocking.
// The actual output lives in the mutable `tracker` map, not in the Deferred.
const executePlan = Effect.fn(function* (
plan: Plan,
tracker: Record,
terminalStatuses: Map<
string,
{
id: string;
type: string;
status: Extract;
providerMode?: ProviderMode;
}
>,
session: PlanStatusSession,
state: {
set: (req: {
stack: string;
stage: string;
fqn: string;
value: V;
}) => Effect.Effect;
},
stackName: string,
stage: string,
) {
// Resources and tasks share the same FQN namespace and DAG, so the
// scheduler tracks them together. Each entry gets a single Deferred that
// signals "my output is available in `tracker`."
const allNodes: Record = {
...plan.resources,
...plan.actions,
};
const ready = Object.fromEntries(
yield* Effect.all(
Object.keys(allNodes).map((fqn) =>
Effect.map(Deferred.make(), (d) => [fqn, d] as const),
),
),
) as Record>;
// `readyStable` fires only when a node has reached its TERMINAL output —
// resources after `reconcile`, tasks after the body completes. Resource
// precreate stubs do NOT signal `readyStable`, so any consumer that
// requires stable inputs (e.g. Tasks) waits past the precreate phase.
const readyStable = Object.fromEntries(
yield* Effect.all(
Object.keys(allNodes).map((fqn) =>
Effect.map(Deferred.make(), (d) => [fqn, d] as const),
),
),
) as Record>;
const getOutputs = (): Record =>
Object.fromEntries(
Object.entries(tracker).map(([fqn, t]) => [fqn, t.output]),
);
const waitForDeps = (fqns: string[]) =>
Effect.all(
fqns
.filter((fqn) => fqn in ready)
.map((fqn) =>
// Non-cycle upstreams must be observed at their TERMINAL output
// (`readyStable`), not their early precreate signal (`ready`). This
// is what makes a failed upstream actually interrupt its downstream:
// a resource with `precreate` resolves `ready` before its real
// `reconcile` runs, so a downstream waiting on `ready` would proceed
// (and even finish) even though the upstream's reconcile later
// failed. Waiting on `readyStable` means the downstream's
// `waitForDeps` short-circuits with the upstream's failure cause.
//
// Cycle members are the exception: peers in an SCC depend on each
// other, so they must rendezvous on the early `ready`/precreate
// signal to break the deadlock. Phase 3 (`converge`) re-runs them
// against final outputs once the cycle settles.
plan.cycleMembers.has(fqn)
? Deferred.await(ready[fqn])
: Deferred.await(readyStable[fqn]),
),
{ concurrency: "unbounded" },
);
const waitForStableDeps = (fqns: string[]) =>
Effect.all(
fqns
.filter((fqn) => fqn in readyStable)
.map((fqn) => Deferred.await(readyStable[fqn])),
{ concurrency: "unbounded" },
);
const failures: LifecycleFailure[] = [];
yield* Effect.all(
Object.entries(allNodes).map(([fqn, node]) =>
(node as ActionApply).kind === "action"
? executeActionNode(
fqn,
node as ActionApply,
tracker,
ready,
readyStable,
terminalStatuses,
session,
state,
stackName,
stage,
getOutputs,
waitForStableDeps,
failures,
)
: executeNode(
fqn,
node as Apply,
tracker,
ready,
readyStable,
terminalStatuses as any,
session,
state,
stackName,
stage,
getOutputs,
waitForDeps,
failures,
plan.cycleMembers.has(fqn),
),
),
{ concurrency: "unbounded" },
);
if (failures.length > 0) {
// Aggregate every collected lifecycle failure into a single parallel Cause
// so the apply ends with one combined error containing every distinct
// failure / defect that occurred across the concurrent fibers.
return yield* Effect.failCause(
failures.map((f) => f.cause).reduce(Cause.combine),
);
}
});
interface LifecycleFailure {
fqn: string;
logicalId: string;
type: string;
cause: Cause.Cause;
}
const executeNode = (
fqn: string,
node: Apply,
tracker: Record,
ready: Record>,
readyStable: Record>,
terminalStatuses: Map<
string,
{
id: string;
type: string;
status: Extract;
providerMode?: ProviderMode;
}
>,
session: PlanStatusSession,
state: {
set: (req: {
stack: string;
stage: string;
fqn: string;
value: V;
}) => Effect.Effect;
},
stackName: string,
stage: string,
getOutputs: () => Record,
waitForDeps: (fqns: string[]) => Effect.Effect,
failures: LifecycleFailure[],
inCycle: boolean,
): Effect.Effect =>
Effect.gen(function* () {
const logicalId = node.resource.LogicalId;
const namespace = node.resource.Namespace;
const commit = (value: Omit) =>
state.set({
stack: stackName,
stage,
fqn,
// Early commits (`creating`/`replacing`) persist plan props that may
// still hold unresolved Output exprs; strip them so state stores only
// plain data (see stripUnresolved in Diff.ts).
//
// Binding rows follow the same rule. Even the RESOLVED binding
// payload can carry Effect leaves — a tagged Worker/Function class in
// `env` (the circular-bindings pattern) is a function-typed Effect
// that `Output.evaluate` passes through untouched. A JSON state store
// would persist it via its `toJSON` as an `{"_id":"Effect",...}`
// relic, which the next plan's `diffBindings` compares against the
// live class stripped to `undefined` — a phantom binding "update" on
// every deploy, forever. Stripping at the commit boundary keeps both
// store kinds consistent with the comparison in `havePropsChanged`.
value: {
...value,
props: stripUnresolved(value.props),
bindings: stripUnresolved(value.bindings),
namespace,
} as S,
});
const scopedSession = {
...session,
note: (note: string) =>
session.emit({ id: logicalId, kind: "annotate", message: note }),
} satisfies ScopedPlanStatusSession;
// On a mode-switch replacement (local ⇄ live) surface the transition:
// the old generation's stamped mode → the mode resolved for this run.
const fromProviderMode =
node.action === "replace" &&
node.mode !== undefined &&
node.state.providerMode !== undefined &&
node.state.providerMode !== node.mode
? node.state.providerMode
: undefined;
const report = (status: ApplyStatus) =>
session.emit({
kind: "status-change",
id: logicalId,
type: node.resource.Type,
status,
providerMode: node.mode,
fromProviderMode,
});
const markTerminal = (status: "created" | "updated") =>
Effect.gen(function* () {
terminalStatuses.set(fqn, {
id: logicalId,
type: node.resource.Type,
status,
providerMode: node.mode,
});
// A local dev instance announces where it's serving: any
// local-mode row whose fresh Attributes carry a string `url`
// (Workers expose their dev-proxy URL this way) gets a
// `[id] ready at http://localhost:1337` line.
if (node.mode === "local") {
const url = (tracker[fqn]?.output as { url?: unknown })?.url;
if (typeof url === "string" && url.length > 0) {
yield* scopedSession.note(`ready at ${url}`);
}
}
// Emit immediately so the CLI surfaces the terminal status as soon
// as the resource is actually done — instead of batching every
// resource's "created"/"updated" event to the end of apply(), which
// makes long-running siblings appear stuck in "creating" until the
// entire deploy finishes.
//
// Cycle members are exempt: their initial pass produces an
// intermediate result that Phase 3 (`converge`) will overwrite once
// the SCC reaches a fixed point. Emitting "created"/"updated" here
// would surface that intermediate state to the CLI before the real
// terminal status is known. Their final status is flushed from
// `terminalStatuses` after `converge` completes.
if (inCycle) return;
yield* session.emit({
kind: "status-change",
id: logicalId,
type: node.resource.Type,
status,
providerMode: node.mode,
});
});
const signalReady = Deferred.succeed(ready[fqn], void 0);
// Signal only after reconcile completes — never during precreate. Tasks
// (and any other consumer that calls `waitForStableDeps`) block on this
// so they observe the resource's final attrs rather than a stub.
const signalReadyStable = Deferred.succeed(readyStable[fqn], void 0);
const storeAndSignal = (t: ResourceTracker) =>
Effect.gen(function* () {
tracker[fqn] = t;
yield* signalReady;
});
// ── noop ──
if (node.action === "noop") {
// No work to do on the cloud resource — the persisted attr is already
// stable. Two pieces of row METADATA can still have drifted from the
// declaration, and this is the only pass that will ever see them:
//
// 1. `resourceType` — the row was persisted under a legacy type name
// (the type was since renamed and carries the old name as an
// alias); migrate it so the state stops depending on the alias.
// 2. `removalPolicy` — `RemovalPolicy.retain()` / `.destroy()` is a
// decoration on the declaration, not a prop, so changing it never
// produces a diff. Without this commit the new policy would never
// reach state, and the orphan delete (which reads the policy from
// the persisted row, see `Plan.ts`'s delete node) would act on the
// stale one — destroying a resource the user had marked `retain`.
// See https://github.com/alchemy-run/alchemy/issues/1248.
const policyChanged =
node.state.removalPolicy !== node.resource.RemovalPolicy;
if (node.state.resourceType !== node.resource.Type || policyChanged) {
yield* commit({
...node.state,
resourceType: node.resource.Type,
removalPolicy: node.resource.RemovalPolicy,
});
}
// A policy flip is otherwise invisible (the row is a noop), and it is
// exactly the change a user wants confirmation of. Legacy rows with no
// persisted policy normalize silently — there is nothing to report.
if (policyChanged && node.state.removalPolicy !== undefined) {
yield* scopedSession.note(
`removal policy ${node.state.removalPolicy} → ${node.resource.RemovalPolicy}`,
);
}
yield* signalReadyStable;
yield* storeAndSignal({
output: node.state.attr,
props: node.state.props,
bindings: node.state.bindings ?? [],
instanceId: node.state.instanceId,
});
return;
}
const allUpstreamFqns = () => {
const propDeps = Object.keys(Output.resolveUpstream(node.props));
const bindingDeps = Object.keys(Output.resolveUpstream(node.bindings));
return [...new Set([...propDeps, ...bindingDeps])];
};
// ── instance ID ──
const instanceId = yield* Effect.gen(function* () {
if (node.action === "create" && !node.state?.instanceId) {
const id = yield* generateInstanceId();
yield* commit({
status: "creating",
fqn,
logicalId,
instanceId: id,
downstream: node.downstream,
props: node.props,
providerVersion: node.provider.version ?? 0,
resourceType: node.resource.Type,
bindings: excludeDeletedBindings(node.bindings),
removalPolicy: node.resource.RemovalPolicy,
providerMode: node.mode,
});
return id;
} else if (node.action === "replace") {
if (
(node.state.status === "replaced" ||
node.state.status === "replacing") &&
!node.restart
) {
// Ordinary replacement recovery keeps using the same replacement
// generation. Only `restart` is allowed to mint a new instance id.
return node.state.instanceId;
}
const id = yield* generateInstanceId();
yield* commit({
status: "replacing",
fqn,
logicalId,
instanceId: id,
downstream: node.downstream,
props: node.props,
providerVersion: node.provider.version ?? 0,
resourceType: node.resource.Type,
bindings: excludeDeletedBindings(node.bindings),
old: node.state,
deleteFirst: node.deleteFirst,
removalPolicy: node.resource.RemovalPolicy,
providerMode: node.mode,
});
return id;
} else if (node.state?.instanceId) {
return node.state.instanceId;
}
return yield* Effect.die(
`Instance ID not found for resource '${logicalId}' and action is '${node.action}'`,
);
});
// ── lifecycle ──
yield* Effect.gen(function* () {
// ── create ──
if (node.action === "create") {
if (!node.state) {
// First persistence point for a brand new logical resource. Once this is
// written, retries know they should resume creation instead of planning
// another fresh create from scratch.
yield* commit({
status: "creating",
fqn,
logicalId,
instanceId,
resourceType: node.resource.Type,
props: node.props,
attr: undefined,
providerVersion: node.provider.version ?? 0,
bindings: excludeDeletedBindings(node.bindings),
downstream: node.downstream,
removalPolicy: node.resource.RemovalPolicy,
providerMode: node.mode,
});
}
let attr: any = node.state?.attr;
if (attr !== undefined) {
// Precreate/read may already have produced a usable output snapshot. Publish
// it early so downstream resources can start resolving against it.
yield* storeAndSignal({
output: attr,
props: {},
bindings: [],
instanceId,
});
}
if (node.provider.precreate && attr === undefined) {
// Some resources need a placeholder physical resource before their real
// create can finish. Persist that stub so downstream evaluation can proceed.
yield* report("pre-creating");
attr = yield* node.provider
.precreate({
id: logicalId,
fqn,
news: node.props,
session: scopedSession,
instanceId,
bindings: excludeDeletedBindings(node.bindings),
})
.pipe(
instrumentLifecycle(
"precreate",
fqn,
node.resource.Type,
logicalId,
instanceId,
),
);
yield* commit({
status: "creating",
fqn,
logicalId,
instanceId,
resourceType: node.resource.Type,
props: node.props,
attr,
providerVersion: node.provider.version ?? 0,
bindings: excludeDeletedBindings(node.bindings),
downstream: node.downstream,
removalPolicy: node.resource.RemovalPolicy,
providerMode: node.mode,
});
yield* storeAndSignal({
output: attr,
props: {},
bindings: [],
instanceId,
});
}
// While we're waiting on upstream outputs the resource isn't actually
// creating anything yet — surface that as "pending" so the CLI doesn't
// look stuck in "creating" for slow-upstream deploys.
yield* report("pending");
// Create runs against fully resolved upstream outputs and bindings, not the
// raw Output expressions stored in the plan.
yield* waitForDeps(allUpstreamFqns());
yield* report("creating");
const outputs = getOutputs();
const news = (yield* Output.evaluate(node.props, outputs)) as Record<
string,
any
>;
const bindingOutputs = excludeDeletedBindings(
yield* Output.evaluate(node.bindings, outputs),
);
attr = yield* node.provider
.reconcile({
id: logicalId,
fqn,
news,
instanceId,
bindings: bindingOutputs,
session: scopedSession,
olds: undefined,
output: attr,
})
.pipe(
instrumentLifecycle(
"create",
fqn,
node.resource.Type,
logicalId,
instanceId,
),
);
yield* commit({
status: "created",
fqn,
logicalId,
instanceId,
resourceType: node.resource.Type,
props: news,
attr,
// Terminal commits persist the RESOLVED binding payload the
// provider actually reconciled with, not the raw plan-time
// expressions. Raw `node.bindings` may hold unresolved Outputs
// (silently dropped by JSON state stores), so persisting them
// makes the next plan's `diffBindings` compare a lossy stored
// shape against fully-resolved data — a phantom binding update
// on every plan (#874).
bindings: bindingOutputs,
providerVersion: node.provider.version ?? 0,
downstream: node.downstream,
removalPolicy: node.resource.RemovalPolicy,
providerMode: node.mode,
});
tracker[fqn] = {
output: attr,
props: news,
bindings: bindingOutputs,
instanceId,
};
yield* signalReady;
yield* signalReadyStable;
yield* markTerminal("created");
return;
}
// ── update ──
if (node.action === "update") {
// Cycle members publish their previous live attr *before* waiting on
// upstreams so the SCC can converge — peers in the cycle would
// otherwise deadlock waiting on each other. Phase 3 (`converge`)
// re-runs each peer's update against fresh outputs once everyone
// has settled.
//
// Linear (DAG) update nodes skip this entirely and simply wait for
// fresh upstream outputs, mirroring the create flow. This is the
// important property: a downstream of a non-cycle update never
// observes the upstream's stale attr, which prevents wasted/
// destructive intermediate updates (e.g. a Worker deploying with
// stale Build assets).
if (inCycle) {
yield* storeAndSignal({
output: node.state.attr,
props: node.state.props,
bindings: node.state.bindings ?? [],
instanceId,
});
}
// See create-flow note: while we're waiting on upstream outputs
// this resource isn't actually updating yet.
yield* report("pending");
yield* waitForDeps(allUpstreamFqns());
const outputs = getOutputs();
const news = (yield* Output.evaluate(node.props, outputs)) as Record<
string,
any
>;
const adopting =
node.adopting === true ||
(node.state.status === "updating" && node.state.adopting === true);
yield* node.state.status === "replaced"
? commit({
// Keep the replacement wrapper intact while changing the live
// replacement props; GC still has older generations to delete.
...node.state,
attr: node.state.attr,
props: news,
providerMode: node.mode,
})
: commit({
// For ordinary updates we snapshot the previously stable props/attrs
// once, so retries can continue from the same baseline.
status: "updating",
fqn,
logicalId,
instanceId,
resourceType: node.resource.Type,
props: news,
attr: node.state.attr,
providerVersion: node.provider.version ?? 0,
bindings: excludeDeletedBindings(node.bindings),
downstream: node.downstream,
old:
node.state.status === "updating" ? node.state.old : node.state,
adopting: adopting ? true : undefined,
removalPolicy: node.resource.RemovalPolicy,
providerMode: node.mode,
});
yield* report("updating");
const previousProps = adopting
? undefined
: node.state.status === "created" ||
node.state.status === "updated" ||
node.state.status === "replaced"
? node.state.props
: node.state.old.props;
// Providers receive the resolved binding payload for this exact pass, while
// `previousProps` tells them what state the live resource is being updated from.
const bindingOutputs = excludeDeletedBindings(
yield* Output.evaluate(node.bindings, outputs),
);
const attr = yield* node.provider
.reconcile({
id: logicalId,
fqn,
news,
instanceId,
bindings: bindingOutputs,
session: scopedSession,
olds: previousProps,
output: node.state.attr,
})
.pipe(
instrumentLifecycle(
"update",
fqn,
node.resource.Type,
logicalId,
instanceId,
),
);
if (node.state.status === "replaced") {
yield* commit({
// The live replacement changed, but cleanup of older generations still
// has to continue afterwards.
...node.state,
attr,
props: news,
// Resolved payload, not raw `node.bindings` — see create commit.
bindings: bindingOutputs,
providerMode: node.mode,
});
} else {
yield* commit({
status: "updated",
fqn,
logicalId,
instanceId,
resourceType: node.resource.Type,
props: news,
attr,
// Resolved payload, not raw `node.bindings` — see create commit.
bindings: bindingOutputs,
providerVersion: node.provider.version ?? 0,
downstream: node.downstream,
removalPolicy: node.resource.RemovalPolicy,
providerMode: node.mode,
});
}
tracker[fqn] = {
output: attr,
props: news,
bindings: bindingOutputs,
instanceId,
};
// Signal here for the linear (non-cycle) path. For in-cycle updates
// the deferred has already been resolved by the early `storeAndSignal`
// above and `signalReady` is a no-op the second time.
yield* signalReady;
yield* signalReadyStable;
yield* markTerminal("updated");
return;
}
// ── replace ──
if (node.action === "replace") {
if (node.state.status === "replaced" && !node.restart) {
// The replacement already exists; this pass only needs GC to clean up
// older generations, so expose the current replacement and stop here.
tracker[fqn] = {
output: node.state.attr,
props: node.state.props,
bindings: node.state.bindings ?? [],
instanceId,
};
yield* signalReady;
yield* signalReadyStable;
yield* markTerminal("created");
return;
}
let replState: ReplacingResourceState;
if (node.state.status !== "replacing" || node.restart) {
// `restart` deliberately nests the previous top-level replacement state
// into `old`, creating a new outer generation to replace it.
replState = yield* commit({
status: "replacing",
fqn,
logicalId,
instanceId,
resourceType: node.resource.Type,
props: node.props,
bindings: excludeDeletedBindings(node.bindings),
attr: undefined,
providerVersion: node.provider.version ?? 0,
deleteFirst: node.deleteFirst,
old: node.state,
downstream: node.downstream,
removalPolicy: node.resource.RemovalPolicy,
providerMode: node.mode,
});
} else {
// Resume the same replacement generation after an interrupted apply.
replState = node.state;
}
// ── delete-first replacements ──
//
// By default a replacement is create-first: the new generation is
// created here and the old generation(s) are reclaimed afterwards by
// `collectGarbage` (Phase 2). That ordering keeps the old resource
// alive if the create fails, but it is wrong for resources whose
// replacement cannot coexist with the original — a fixed physical
// name, a singleton, etc. Those providers return
// `{ action: "replace", deleteFirst: true }`.
//
// When `deleteFirst` is set we tear the previous generation(s) down
// BEFORE creating the new one, and commit the result as a terminal
// `created` state (rather than `replaced`) so Phase 2 has no old chain
// left to drain. `delete` is required to be idempotent, so a re-run
// after an interrupted apply simply re-converges.
const deleteOldGenerations = (
old: ReplacementOldResourceState,
): Effect.Effect =>
Effect.gen(function* () {
const retain = node.resource.RemovalPolicy === "retain";
if (old.attr !== undefined && !retain) {
// Delete each old generation with the provider variant of the
// mode that created it — after a local ⇄ live switch,
// `node.provider` (the new mode) cannot tear down the other
// runtime's instance. Unstamped rows (legacy or written by a
// mode-agnostic provider) are physically live, unless their
// attrs carry the `dev:` identity marker — see stampedMode.
const oldProvider = yield* findProviderByType(
node.resource.Type,
stampedMode(old),
);
yield* oldProvider
.delete({
id: logicalId,
fqn,
instanceId: old.instanceId,
olds: old.props as never,
output: old.attr,
session: scopedSession,
bindings: [],
})
.pipe(
instrumentLifecycle(
"delete",
fqn,
node.resource.Type,
logicalId,
old.instanceId,
),
);
}
if (old.status === "replacing" || old.status === "replaced") {
yield* deleteOldGenerations(old.old);
}
});
if (node.deleteFirst) {
yield* scopedSession.note(
"Deleting previous resource before creating its replacement (deleteFirst)...",
);
yield* deleteOldGenerations(replState.old);
}
let attr: any = replState.attr;
if (attr !== undefined) {
// If precreate already ran, expose that intermediate output immediately so
// downstream resources can resolve against the same in-flight replacement.
yield* storeAndSignal({
output: attr,
props: {},
bindings: [],
instanceId,
});
}
if (node.provider.precreate && attr === undefined) {
yield* report("pre-creating");
attr = yield* node.provider
.precreate({
id: logicalId,
fqn,
news: node.props,
session: scopedSession,
instanceId,
bindings: excludeDeletedBindings(node.bindings),
})
.pipe(
instrumentLifecycle(
"precreate",
fqn,
node.resource.Type,
logicalId,
instanceId,
),
);
yield* commit({
status: "replacing",
fqn,
logicalId,
instanceId,
resourceType: node.resource.Type,
props: node.props,
attr,
providerVersion: node.provider.version ?? 0,
bindings: excludeDeletedBindings(node.bindings),
downstream: node.downstream,
old: replState.old,
deleteFirst: node.deleteFirst,
removalPolicy: node.resource.RemovalPolicy,
providerMode: node.mode,
});
yield* storeAndSignal({
output: attr,
props: {},
bindings: [],
instanceId,
});
}
// See create-flow note: while we're waiting on upstream outputs
// the replacement isn't actually being created yet.
yield* report("pending");
// Replacement create is evaluated exactly like create, but against the new
// generation's instance id and with the previous generations preserved in `old`.
yield* waitForDeps(allUpstreamFqns());
yield* report("creating replacement");
const outputs = getOutputs();
const news = (yield* Output.evaluate(node.props, outputs)) as Record<
string,
any
>;
const bindingOutputs = excludeDeletedBindings(
yield* Output.evaluate(node.bindings, outputs),
);
attr = yield* node.provider
.reconcile({
id: logicalId,
fqn,
news,
instanceId,
bindings: bindingOutputs,
session: scopedSession,
olds: undefined,
output: attr,
})
.pipe(
instrumentLifecycle(
"create",
fqn,
node.resource.Type,
logicalId,
instanceId,
),
);
if (node.deleteFirst) {
// The old generation(s) were already torn down above, so there is
// nothing left for `collectGarbage` to drain — collapse straight to
// the terminal `created` state.
yield* commit({
status: "created",
fqn,
logicalId,
instanceId,
resourceType: node.resource.Type,
props: news,
attr,
providerVersion: node.provider.version ?? 0,
// Resolved payload, not raw `node.bindings` — see create commit.
bindings: bindingOutputs,
downstream: node.downstream,
removalPolicy: node.resource.RemovalPolicy,
providerMode: node.mode,
});
} else {
yield* commit({
// Creation of the new generation succeeded; from here on the only remaining
// work is draining the old chain via garbage collection.
status: "replaced",
fqn,
logicalId,
instanceId,
resourceType: node.resource.Type,
props: news,
attr,
providerVersion: node.provider.version ?? 0,
// Resolved payload, not raw `node.bindings` — see create commit.
bindings: bindingOutputs,
downstream: node.downstream,
// Preserve the remaining backlog exactly as-is. GC is responsible for
// popping one generation at a time until the chain is exhausted.
old: replState.old,
deleteFirst: node.deleteFirst,
removalPolicy: node.resource.RemovalPolicy,
providerMode: node.mode,
});
}
tracker[fqn] = {
output: attr,
props: news,
bindings: bindingOutputs,
instanceId,
};
yield* signalReady;
yield* signalReadyStable;
// Keep progress anchored to the live replacement while GC drains the
// previous generation(s) in the background.
yield* markTerminal("created");
return;
}
// @ts-expect-error - node is never, this should be unreachable
return yield* Effect.die(`Unknown action: ${node.action}`);
});
}).pipe(
Effect.catchCause((cause) =>
// Record the failure, propagate it to any downstream resources waiting on
// our Deferred (so their waitForDeps short-circuits instead of deadlocking),
// emit a "fail" status to the session, and resolve to void so Effect.all
// does not interrupt sibling fibers. The aggregated cause is raised at
// the end of executePlan.
Effect.gen(function* () {
failures.push({
fqn,
logicalId: node.resource.LogicalId,
type: node.resource.Type,
cause,
});
yield* Deferred.failCause(ready[fqn], cause as Cause.Cause);
yield* Deferred.failCause(
readyStable[fqn],
cause as Cause.Cause,
);
yield* session.emit({
kind: "status-change",
id: node.resource.LogicalId,
type: node.resource.Type,
status: "fail",
providerMode: node.mode,
});
}),
),
Effect.withSpan("apply.resource", {
attributes: {
"alchemy.resource.fqn": fqn,
"alchemy.resource.type": node.resource.Type,
"alchemy.resource.logical_id": node.resource.LogicalId,
"alchemy.resource.action": node.action,
},
}),
) as Effect.Effect;
// ── Task execution ─────────────────────────────────────────────────────────
//
// Tasks slot into the same scheduler as resources. They have no provider
// lifecycle — just a single Effect that runs when inputs change (or when
// `--force` is set). The output value is written to `tracker[fqn].output`
// so downstream Output evaluation works identically to resource attrs.
/**
* Run an Action body, resolving any Outputs it captured via `yield* output`
* during init against the current tracker and exposing them to the body through
* the resolve {@link RuntimeContext}. See {@link makeCaptureContext}.
*/
const runAction = Effect.fn("apply.runAction")(function* (
task: ActionLike,
input: any,
outputs: Record,
) {
const resolved: Record = {};
for (const [key, output] of Object.entries(task.Captures)) {
resolved[key] = yield* Output.evaluate(output, outputs);
}
return yield* task
.Run(input)
.pipe(Effect.provideService(RuntimeContext, makeResolveContext(resolved)));
});
const executeActionNode = (
fqn: string,
node: ActionApply,
tracker: Record,
ready: Record>,
readyStable: Record>,
terminalStatuses: Map<
string,
{
id: string;
type: string;
status: Extract;
providerMode?: ProviderMode;
}
>,
session: PlanStatusSession,
state: {
set: (req: {
stack: string;
stage: string;
fqn: string;
value: V;
}) => Effect.Effect;
},
stackName: string,
stage: string,
getOutputs: () => Record,
waitForDeps: (fqns: string[]) => Effect.Effect,
failures: LifecycleFailure[],
): Effect.Effect =>
Effect.gen(function* () {
const task = node.def;
const logicalId = task.LogicalId;
const namespace = task.Namespace;
const commit = (value: Omit) =>
state.set({
stack: stackName,
stage,
fqn,
value: { ...value, namespace } as S,
});
const report = (status: ApplyStatus) =>
session.emit({
kind: "status-change",
id: logicalId,
type: task.Type,
status,
});
const signalReady = Deferred.succeed(ready[fqn], void 0);
const signalReadyStable = Deferred.succeed(readyStable[fqn], void 0);
if (node.action === "noop") {
tracker[fqn] = {
output: node.state.output,
props: { __input: node.state.input },
bindings: [],
instanceId: fqn,
};
yield* signalReady;
yield* signalReadyStable;
terminalStatuses.set(fqn, {
id: logicalId,
type: task.Type,
status: "skipped",
});
yield* report("skipped");
return;
}
// ── run ──
// Tasks wait on `waitForStableDeps` (post-reconcile attrs) for their
// upstreams, which is often the slowest dep chain in the deploy.
// Surface that as "pending" instead of having the task show no status
// until its run actually starts.
yield* report("pending");
yield* waitForDeps(
[
...new Set([
...Object.keys(Output.resolveUpstream(node.input)),
...Object.keys(Output.upstreamAny(task.Captures)),
]),
].filter((f) => f in readyStable),
);
const outputs = getOutputs();
const resolvedInput = (yield* Output.evaluate(node.input, outputs)) as any;
const inputHashValue = yield* hashInput(resolvedInput);
yield* commit({
kind: "action",
status: "running",
fqn,
logicalId,
actionType: task.Type,
inputHash: inputHashValue,
input: resolvedInput,
downstream: node.downstream,
});
yield* report("running");
const result = yield* runAction(task, resolvedInput, outputs);
yield* commit({
kind: "action",
status: "ran",
fqn,
logicalId,
actionType: task.Type,
inputHash: inputHashValue,
input: resolvedInput,
output: result,
downstream: node.downstream,
});
tracker[fqn] = {
output: result,
props: { __input: resolvedInput },
bindings: [],
instanceId: fqn,
};
yield* signalReady;
yield* signalReadyStable;
terminalStatuses.set(fqn, {
id: logicalId,
type: task.Type,
status: "ran",
});
yield* report("ran");
}).pipe(
Effect.catchCause((cause) =>
Effect.gen(function* () {
failures.push({
fqn,
logicalId: node.def.LogicalId,
type: node.def.Type,
cause,
});
yield* Deferred.failCause(ready[fqn], cause as Cause.Cause);
yield* Deferred.failCause(
readyStable[fqn],
cause as Cause.Cause,
);
yield* session.emit({
kind: "status-change",
id: node.def.LogicalId,
type: node.def.Type,
status: "fail",
});
}),
),
Effect.withSpan("apply.action", {
attributes: {
"alchemy.action.fqn": fqn,
"alchemy.action.type": node.def.Type,
"alchemy.action.logical_id": node.def.LogicalId,
"alchemy.action.verb": node.action,
},
}),
) as Effect.Effect;
// ── Phase 3: imperative convergence loop ───────────────────────────────────
//
// After the initial concurrent pass, some resources may have been created
// with stale upstream values (e.g. a precreate stub instead of the final
// output). Walk the plan and re-evaluate each resource's props/bindings
// against the current tracker outputs. Call provider.reconcile for any
// resource whose resolved inputs differ from what it was last applied with.
// Repeat until no resource needs updating.
const converge = Effect.fn(function* (
plan: Plan,
tracker: Record,
terminalStatuses: Map<
string,
{
id: string;
type: string;
status: Extract;
providerMode?: ProviderMode;
}
>,
session: PlanStatusSession,
state: {
set: (req: {
stack: string;
stage: string;
fqn: string;
value: V;
}) => Effect.Effect;
},
stackName: string,
stage: string,
) {
for (;;) {
let anyUpdated = false;
for (const [fqn, node] of Object.entries(plan.resources)) {
if (node.action === "noop") continue;
if (!tracker[fqn]) continue;
const outputs = Object.fromEntries(
Object.entries(tracker).map(([k, t]) => [k, t.output]),
);
const newProps = (yield* Output.evaluate(node.props, outputs)) as Record<
string,
any
>;
const newBindings = excludeDeletedBindings(
yield* Output.evaluate(node.bindings, outputs),
);
const oldProps = tracker[fqn].props;
const oldBindings = tracker[fqn].bindings;
const propsChanged = havePropsChanged(oldProps, newProps);
const bindingsChanged =
JSON.stringify(oldBindings) !== JSON.stringify(newBindings);
if (!propsChanged && !bindingsChanged) continue;
anyUpdated = true;
const logicalId = node.resource.LogicalId;
const namespace = node.resource.Namespace;
const instanceId = tracker[fqn].instanceId;
const scopedSession = {
...session,
note: (note: string) =>
session.emit({ id: logicalId, kind: "annotate", message: note }),
} satisfies ScopedPlanStatusSession;
const attr = yield* node.provider
.reconcile({
id: logicalId,
fqn,
news: newProps,
instanceId,
bindings: newBindings,
session: scopedSession,
olds: oldProps,
output: tracker[fqn].output,
})
.pipe(
instrumentLifecycle(
"update",
fqn,
node.resource.Type,
logicalId,
instanceId,
),
);
tracker[fqn] = {
output: attr,
props: newProps,
bindings: newBindings,
instanceId,
};
yield* state.set({
stack: stackName,
stage,
fqn,
value: {
status: "updated",
fqn,
logicalId,
instanceId,
resourceType: node.resource.Type,
// This site bypasses the `commit` helper, so strip unresolved
// leaves (Effect-valued env entries survive `Output.evaluate`)
// the same way `commit` does — state only ever holds plain data.
props: stripUnresolved(newProps),
attr,
providerVersion: node.provider.version ?? 0,
// Resolved payload, not raw `node.bindings` — see the create
// commit in applyResource. Stripped like the commit helper so
// Effect leaves (e.g. tagged Worker classes in `env`) never reach
// the state store.
bindings: stripUnresolved(newBindings),
downstream: node.downstream,
namespace,
removalPolicy: node.resource.RemovalPolicy,
providerMode: node.mode,
} as UpdatedResourceState,
});
terminalStatuses.set(fqn, {
id: logicalId,
type: node.resource.Type,
status: "updated",
providerMode: node.mode,
});
}
// Tasks: re-run when their resolved input drifts vs. the value they
// last applied with (e.g. an upstream resource produced new attrs in
// this pass). Skipped (noop) tasks are not re-checked here — their
// recorded inputHash is authoritative until the next plan.
for (const [fqn, node] of Object.entries(plan.actions)) {
if (node.action !== "run") continue;
if (!tracker[fqn]) continue;
const outputs = Object.fromEntries(
Object.entries(tracker).map(([k, t]) => [k, t.output]),
);
const newInput = (yield* Output.evaluate(node.input, outputs)) as any;
const newHash = yield* hashInput(newInput);
const oldInput = tracker[fqn].props?.__input;
const oldHash = yield* hashInput(oldInput);
if (newHash === oldHash) continue;
anyUpdated = true;
yield* state.set({
stack: stackName,
stage,
fqn,
value: {
kind: "action",
status: "running",
fqn,
logicalId: node.def.LogicalId,
namespace: node.def.Namespace,
actionType: node.def.Type,
inputHash: newHash,
input: newInput,
downstream: node.downstream,
} satisfies RunningActionState,
});
const result = yield* runAction(node.def, newInput, outputs);
yield* state.set({
stack: stackName,
stage,
fqn,
value: {
kind: "action",
status: "ran",
fqn,
logicalId: node.def.LogicalId,
namespace: node.def.Namespace,
actionType: node.def.Type,
inputHash: newHash,
input: newInput,
output: result,
downstream: node.downstream,
} satisfies RanActionState,
});
tracker[fqn] = {
output: result,
props: { __input: newInput },
bindings: [],
instanceId: fqn,
};
terminalStatuses.set(fqn, {
id: node.def.LogicalId,
type: node.def.Type,
status: "ran",
});
}
if (!anyUpdated) break;
}
});
// ── Phase 2: delete orphans and old replaced resources ─────────────────────
/** A provider delete (or its attr-recovery read / state commit) that failed. */
export interface DeleteFailure {
fqn: string;
logicalId: string;
resourceType: string;
cause: Cause.Cause;
}
/**
* A delete that was never attempted because a dependent's delete failed (or
* was itself blocked). The resource may legitimately be undeletable while its
* dependents still exist, so skipping is not an error in its own right.
*/
export interface BlockedDelete {
fqn: string;
logicalId: string;
resourceType: string;
/** FQNs of the dependents whose failed/blocked deletes block this one. */
blockedBy: string[];
}
/**
* Aggregate raised at the end of the deletion phase when one or more
* resource deletes failed. Every resource whose delete did not depend on a
* failed one was still attempted — a single failure no longer strands
* unrelated siblings.
*/
export class DestroyError extends Data.TaggedError("DestroyError")<{
failures: ReadonlyArray;
blocked: ReadonlyArray;
}> {
override get message(): string {
return [
`Failed to delete ${this.failures.length} resource(s)` +
(this.blocked.length > 0
? ` (${this.blocked.length} more skipped because a dependent's delete failed)`
: "") +
":",
...this.failures.map(
(f) => ` ✗ ${f.fqn} (${f.resourceType}): ${Cause.pretty(f.cause)}`,
),
...this.blocked.map(
(b) =>
` ⊘ ${b.fqn} (${b.resourceType}): skipped — blocked by failed delete of ${b.blockedBy.join(", ")}`,
),
].join("\n");
}
}
const collectGarbage = Effect.fn(function* (
plan: Plan,
session: PlanStatusSession,
) {
const state = yield* yield* State;
const stack = yield* Stack;
const stackName = stack.name;
const stage = yield* Stage;
// Task deletions are pure state drops — no body is invoked. Run them in
// parallel before resource GC; tasks never have provider-side dependencies
// to wait on.
yield* Effect.all(
Object.entries(plan.actionDeletions ?? {}).map(([fqn, node]) =>
node === undefined
? Effect.void
: Effect.gen(function* () {
yield* session.emit({
kind: "status-change",
id: node.def.LogicalId,
type: node.def.Type,
status: "deleting",
});
yield* state.delete({ stack: stackName, stage, fqn });
yield* session.emit({
kind: "status-change",
id: node.def.LogicalId,
type: node.def.Type,
status: "deleted",
});
}),
),
{ concurrency: "unbounded" },
);
// Failures are collected — not propagated — so one bad delete never
// strands unrelated siblings. `unresolved` tracks every FQN whose delete
// failed or was blocked this run: later passes must not retry them (a
// still-`replaced` row would otherwise spin the drain loop forever) and
// dependencies scheduled in later passes must observe them as blocking.
const failures: DeleteFailure[] = [];
const blockedDeletes: BlockedDelete[] = [];
const unresolved = new Set();
type DeleteOutcome = "deleted" | "failed" | "blocked";
const deleteGraph = Effect.fn(function* (
deletionGraph: Record,
) {
const deletions: {
[fqn in string]: Effect.Effect;
} = {};
const deleteResource = (
node: Delete | ReplacedResourceState,
ancestors: ReadonlySet = new Set(),
): Effect.Effect =>
Effect.gen(function* () {
const isDeleteNode = (
node: Delete | ReplacedResourceState,
): node is Delete => "action" in node;
const {
fqn,
logicalId,
namespace,
resourceType,
instanceId,
downstream,
props,
attr: persistedAttr,
provider,
providerMode,
} = isDeleteNode(node)
? {
// Use the persisted FQN verbatim — never recompute it from
// `toFqn(namespace, logicalId)`. A logical ID may legitimately
// contain the FQN separator (`/`), in which case `parseFqn`
// truncated it and a recomputed key would miss the real state
// row (the row would then resurface as an orphan on every
// subsequent destroy, never getting deleted).
fqn: node.resource.FQN,
logicalId: node.resource.LogicalId,
namespace: node.resource.Namespace,
resourceType: node.resource.Type,
instanceId: node.state.instanceId,
downstream: node.downstream,
props: node.state.props,
attr: node.state.attr,
// Plan resolved this provider for the row's persisted (or
// marker-inferred) `providerMode` (see the deletions builder
// in Plan.ts).
provider: node.provider,
providerMode: node.state.providerMode,
}
: {
fqn: node.fqn,
logicalId: node.logicalId,
namespace: node.namespace,
resourceType: node.old.resourceType,
instanceId: node.old.instanceId,
downstream: node.old.downstream,
props: node.old.props,
attr: node.old.attr,
// A missing provider is fatal — plan already dies on zombie
// rows (see the deletions builder in Plan.ts); this guards
// the replaced-chain generations that bypass plan. The old
// generation is torn down with the provider variant of the
// mode that created it (local ⇄ live replacements);
// unstamped rows are physically live unless their attrs
// carry the `dev:` identity marker (see stampedMode).
provider: yield* tryFindProviderByType(
node.old.resourceType,
stampedMode(node.old),
).pipe(
Effect.flatMap(
Option.match({
onNone: () =>
Effect.die(
missingProviderError(node.old.resourceType, node.fqn),
),
onSome: Effect.succeed,
}),
),
),
providerMode: node.old.providerMode,
};
// Mutable: an attr-less row (interrupted create) may recover its
// attributes from `provider.read` below, right before deletion.
let attr = persistedAttr;
const nextAncestors = new Set(ancestors).add(fqn);
const commit = (value: Omit) =>
state.set({
stack: stackName,
stage,
fqn,
// Same rule as the lifecycle commit above: state only stores
// plain data, never unresolved Output exprs or Effect leaves.
value: {
...value,
props: stripUnresolved(value.props),
bindings: stripUnresolved(value.bindings),
namespace,
} as S,
});
const report = (status: ApplyStatus) =>
session.emit({
kind: "status-change",
id: logicalId,
type: resourceType,
status,
providerMode,
});
const scopedSession = {
...session,
note: (note: string) =>
session.emit({
id: logicalId,
kind: "annotate",
message: note,
}),
} satisfies ScopedPlanStatusSession;
return yield* (deletions[fqn] ??= yield* Effect.cached(
Effect.gen(function* () {
// Dependents (`downstream`) are deleted before this resource. A
// dependent whose delete failed (or was itself blocked) may make
// this resource legitimately undeletable (dependency violation),
// so it is skipped with a "blocked by" note instead of surfacing
// a spurious second error.
const dependents = yield* Effect.all(
downstream.map((dep) =>
dep !== fqn && !ancestors.has(dep)
? dep in deletionGraph
? deleteResource(
deletionGraph[dep] as Delete | ReplacedResourceState,
nextAncestors,
).pipe(Effect.map((outcome) => ({ dep, outcome })))
: // Not in this pass's graph — but it may have failed in
// an earlier drain pass of the same destroy.
Effect.sync(() => ({
dep,
outcome: unresolved.has(dep)
? ("failed" as const)
: ("deleted" as const),
}))
: Effect.succeed({ dep, outcome: "deleted" as const }),
),
{ concurrency: "unbounded" },
);
const blockedBy = dependents
.filter(({ outcome }) => outcome !== "deleted")
.map(({ dep }) => dep);
if (blockedBy.length > 0) {
unresolved.add(fqn);
blockedDeletes.push({
fqn,
logicalId,
resourceType,
blockedBy,
});
yield* scopedSession.note(
`Skipping delete — blocked by failed delete of ${blockedBy.join(", ")}.`,
);
yield* report("skipped");
return "blocked" as const;
}
return yield* deleteResourceBody().pipe(
Effect.catchCause((cause) =>
Effect.gen(function* () {
unresolved.add(fqn);
failures.push({ fqn, logicalId, resourceType, cause });
yield* report("fail");
return "failed" as const;
}),
),
);
}),
));
function deleteResourceBody() {
return Effect.gen(function* () {
if (isDeleteNode(node)) {
yield* report("deleting");
if (node.resource.RemovalPolicy === "retain") {
yield* state.delete({
stack: stackName,
stage,
fqn,
});
yield* report("retained");
// Retention is intentional — it never blocks dependencies.
return "deleted" as const;
}
}
// Honor `retain` for the old generation of a replacement, mirroring
// the orphan-delete path above. Delete-node retain is already
// handled with an early return; this guards the replaced
// old-generation physical delete.
const retainOldGeneration =
!isDeleteNode(node) && node.removalPolicy === "retain";
if (retainOldGeneration) {
yield* scopedSession.note(
"Retaining replaced resource (removal policy: retain)...",
);
}
// A row can reach deletion with `attr === undefined` when a create
// was interrupted after the cloud-side call succeeded but before
// `reconcile` returned Attributes (a `creating` row — or the old
// generation of a replacement chain in the same predicament).
// Skipping the provider's delete outright would silently orphan
// the physical resource, so ask `read` to look it up from the
// persisted props (providers derive the deterministic physical
// name from id/props):
// - plain attrs → exists and is ours; proceed to delete
// - Unowned(attrs) → exists but is NOT ours (e.g. our create
// actually lost a name race, or died before
// stamping ownership) — never delete a
// foreign resource; drop our state and say so
// - undefined → nothing exists; dropping state is safe
if (attr === undefined && !retainOldGeneration) {
if (provider.read) {
const recovered = yield* provider
.read({
id: logicalId,
fqn,
instanceId,
olds: props as never,
output: undefined,
})
.pipe(
instrumentLifecycle(
"read",
fqn,
resourceType,
logicalId,
instanceId,
),
// The persisted props of an interrupted create can carry
// holes where unresolved Outputs were stripped at commit
// time (see stripUnresolved) — e.g. a parent reference
// persisted as `{}`. A provider that dereferences one
// crashes deep inside its SDK client (a SchemaError
// defect), which would make the stage impossible to
// destroy. Recovery is best-effort: degrade the defect
// to "nothing recovered", surface a note, and let the
// row be dropped (#995).
Effect.catchDefect((defect) =>
scopedSession
.note(
"Recovery read crashed while looking up this " +
"resource's interrupted create " +
`(${String(defect)}) — if a physical resource ` +
"was created, it must be cleaned up manually.",
)
.pipe(Effect.as(undefined)),
),
);
if (recovered !== undefined) {
if (Unowned.is(recovered)) {
yield* scopedSession.note(
"Resource exists in the cloud but is not owned by this " +
"stack — leaving it in place (re-deploy with --adopt " +
"to take ownership, then destroy).",
);
} else {
attr = stripUnowned(recovered as Record);
}
}
} else {
yield* scopedSession.note(
"No attributes were recorded for this resource (its create " +
"was interrupted) and the provider does not implement " +
"`read` — if a physical resource was created, it must be " +
"cleaned up manually.",
);
}
}
if (isDeleteNode(node)) {
yield* commit({
status: "deleting",
fqn,
logicalId,
instanceId,
resourceType,
props,
attr,
downstream,
providerVersion: provider.version ?? 0,
bindings: excludeDeletedBindings(node.bindings),
removalPolicy: node.resource.RemovalPolicy,
providerMode,
});
}
if (attr !== undefined && !retainOldGeneration) {
yield* provider
.delete({
id: logicalId,
fqn,
instanceId,
olds: props as never,
output: attr,
session: scopedSession,
bindings: [],
})
.pipe(
instrumentLifecycle(
"delete",
fqn,
resourceType,
logicalId,
instanceId,
),
);
}
if (isDeleteNode(node)) {
yield* state.delete({
stack: stackName,
stage,
fqn,
});
yield* report("deleted");
} else {
if (!retainOldGeneration) {
yield* scopedSession.note("Cleaning up replaced resource...");
}
if (
node.old.status === "replacing" ||
node.old.status === "replaced"
) {
// We only deleted the outermost old generation. A nested replacement
// chain still exists, so stay in `replaced` and pop the chain forward
// one level. The outer loop will pick this resource up again.
yield* commit({
status: "replaced",
fqn,
logicalId: node.logicalId,
instanceId: node.instanceId,
resourceType: node.resourceType,
props: node.props,
attr: node.attr,
providerVersion: node.providerVersion,
downstream: node.downstream,
bindings: excludeDeletedBindings(node.bindings),
old: node.old.old,
deleteFirst: node.deleteFirst,
removalPolicy: node.removalPolicy,
providerMode: node.providerMode,
});
} else {
// The old chain is fully drained, so the current replacement is now
// the stable resource and we can collapse back to a terminal state.
yield* commit({
status: "created",
fqn,
logicalId: node.logicalId,
instanceId: node.instanceId,
resourceType: node.resourceType,
props: node.props,
attr: node.attr,
providerVersion: node.providerVersion,
downstream: node.downstream,
bindings: excludeDeletedBindings(node.bindings),
removalPolicy: node.removalPolicy,
providerMode: node.providerMode,
});
}
yield* scopedSession.note(
retainOldGeneration
? "Replaced resource retained."
: "Replaced resource cleanup complete.",
);
}
return "deleted" as const;
});
}
});
// Attempt every root. Per-node failures were recorded (and their state
// retained) inside each node's delete effect — the effects resolve to
// outcomes and never fail, so one bad resource never interrupts sibling
// deletions mid-flight.
yield* Effect.all(
Object.values(deletionGraph)
.filter((node) => node !== undefined)
.map((node) => deleteResource(node)),
{ concurrency: "unbounded" },
);
});
// The first pass handles both planned deletions and any top-level replaced
// resources already present in state. Later passes only drain replacement
// chains that were re-committed as `replaced` while deleting older generations.
let first = true;
while (true) {
const remainingReplacedResources = (yield* state.getReplacedResources({
stack: stackName,
stage,
}))
// A row whose drain already failed (or was blocked) this run stays
// `replaced` in state — retrying it in a later pass would loop forever.
// It stays behind for the next destroy; the aggregate error below
// reports it.
.filter((replaced) => !unresolved.has(replaced.fqn));
if (!first && remainingReplacedResources.length === 0) {
break;
}
yield* deleteGraph({
// Orphan/resource deletions from the current plan should only run once.
...(first ? plan.deletions : {}),
...Object.fromEntries(
remainingReplacedResources.map((replaced) => [
// Key by the persisted FQN (not a recomputed one) so logical IDs
// containing the FQN separator round-trip correctly.
replaced.fqn,
replaced,
]),
),
});
first = false;
}
if (failures.length > 0) {
// Every independent delete was still attempted; now surface everything
// that went wrong (and everything skipped as a consequence) as one
// typed aggregate. The destroy as a whole still fails.
return yield* Effect.fail(
new DestroyError({ failures, blocked: blockedDeletes }),
);
}
});
const excludeDeletedBindings = (
bindings: ReadonlyArray,
): ResourceBinding[] =>
bindings.flatMap(({ action, sid, data }) =>
action === "delete" ? [] : [{ sid, data }],
);