1import * as Clock from "effect/Clock";
2import * as Effect from "effect/Effect";
3import { apply as applyPlan } from "../../Apply.ts";
4import * as Plan from "../../Plan.ts";
5import type {
6 ResourceSelection,
7 SelectionOutput,
8} from "../../ResourceSelection.ts";
9import type { PlannedAction, PlannedResource } from "../../Report.ts";
10import { applySession, Progress, withSpanEvents } from "../Progress.ts";
11import { open, type Session, type StackTarget } from "../Session.ts";
12
13export type { StackTarget } from "../Session.ts";
14
15export interface PlanInput {
16 readonly target: StackTarget;
17 readonly operation: "deploy" | "destroy";
18 readonly force?: boolean;
19 readonly include?: never;
20 readonly exclude?: never;
21 readonly adopt?: boolean;
22 readonly updateStateStore?: boolean;
23 /** Run local (emulated) providers instead of the real cloud. */
24 readonly dev?: boolean;
25}
26
27/** Select nodes and dependencies; declaration still runs, stack outputs are preserved. */
28export interface FilteredPlanInput
29 extends Omit<PlanInput, keyof ResourceSelection>, ResourceSelection {}
30
31/** Infer the deployed stack output from an `alchemy.run.ts` module type. */
32export type StackModuleOutput<Module> = Module extends {
33 readonly default: infer Definition;
34}
35 ? Definition extends Effect.Effect<any, any, any>
36 ? Effect.Success<Definition> extends { readonly output: infer Output }
37 ? Output
38 : unknown
39 : unknown
40 : unknown;
41
42export interface PlanSummary {
43 readonly create: number;
44 readonly update: number;
45 readonly adopted: number;
46 readonly replace: number;
47 readonly delete: number;
48 readonly orphaned: number;
49 readonly noop: number;
50}
51
52export interface PlanSnapshot<Output = unknown> {
53 readonly stack: { readonly name: string; readonly stage: string };
54 readonly summary: PlanSummary;
55 /**
56 * Serializable resource rows (including orphan deletions) with their
57 * bindings and provider modes — what a remote renderer shows without
58 * holding {@link PlanSnapshot.native}.
59 */
60 readonly resources: ReadonlyArray<PlannedResource>;
61 /** Serializable stack-action rows. */
62 readonly actions: ReadonlyArray<PlannedAction>;
63 /** The engine plan, as consumed by in-process renderers and {@link apply}. */
64 readonly native: Plan.Plan<Output>;
65 readonly createdAt: Date;
66 /** The session this plan was computed under; {@link apply} runs in it. */
67 readonly session: Session;
68}
69
70/** Whether a plan proposes any cloud mutations (i.e. approval-worthy work). */
71export const hasChanges = (summary: PlanSummary): boolean =>
72 summary.create +
73 summary.update +
74 summary.adopted +
75 summary.replace +
76 summary.delete +
77 summary.orphaned >
78 0;
79
80export const summarize = (plan: Plan.Plan): PlanSummary => {
81 const summary: { -readonly [K in keyof PlanSummary]: PlanSummary[K] } = {
82 create: 0,
83 update: 0,
84 adopted: 0,
85 replace: 0,
86 delete: 0,
87 orphaned: 0,
88 noop: 0,
89 };
90 for (const node of Object.values(plan.resources)) summary[node.action]++;
91 for (const node of Object.values(plan.deletions)) {
92 if (node !== undefined) summary[node.action]++;
93 }
94 return summary;
95};
96
97type PlanResult<Output> = Effect.Effect<
98 PlanSnapshot<Output>,
99 Effect.Error<ReturnType<typeof planStack>>,
100 Effect.Services<ReturnType<typeof planStack>>
101>;
102
103/**
104 * Import the stack, resolve its services, and compute a deploy or destroy
105 * plan. Planning phases are reported through {@link Progress}; the returned
106 * snapshot is what {@link apply} executes. Filtered deploys preserve stack
107 * outputs and return void; the entire declaration still evaluates.
108 * With an explicit module type, filter-carrying callbacks also specify the input type.
109 */
110export function plan<Module = unknown>(
111 input: FilteredPlanInput &
112 ({ include: ReadonlyArray<string> } | { exclude: ReadonlyArray<string> }),
113): PlanResult<undefined>;
114export function plan<Module = unknown>(
115 input: PlanInput,
116): PlanResult<StackModuleOutput<Module>>;
117export function plan<Module = unknown>(
118 input: FilteredPlanInput,
119): PlanResult<StackModuleOutput<Module> | undefined>;
120export function plan<
121 Module = unknown,
122 Input extends FilteredPlanInput = PlanInput,
123>(input: Input): PlanResult<SelectionOutput<StackModuleOutput<Module>, Input>>;
124export function plan<Module = unknown>(input: FilteredPlanInput) {
125 return planStack<Module>(input);
126}
127
128const planStack = <Module = unknown>(input: FilteredPlanInput) =>
129 Effect.gen(function* () {
130 type Output = StackModuleOutput<Module> | undefined;
131 if (
132 input.operation === "destroy" &&
133 (input.include !== undefined || input.exclude !== undefined)
134 ) {
135 return yield* Effect.die(
136 new Plan.InvalidResourceSelection({
137 message: "Filtered destroy is not supported.",
138 }),
139 );
140 }
141 const report = withSpanEvents(yield* Progress);
142
143 // Everything below emits into the same flat ProgressEvent channel:
144 // `open` reports importing-module / resolving-services at the real work
145 // boundaries, the engine reports loading-state / computing-plan and the
146 // per-node diff events. Re-providing the wrapped reporter is all the
147 // route does — no translation layer.
148 const session = yield* open(input.target, input).pipe(
149 Effect.provideService(Progress, report),
150 );
151 const native = (yield* (
152 input.operation === "destroy"
153 ? Plan.destroy(session.stack)
154 : Plan.make(session.stack, {
155 force: input.force,
156 include: input.include,
157 exclude: input.exclude,
158 })
159 ).pipe(
160 Effect.provideService(Progress, report),
161 Effect.provide(session.context),
162 )) as Plan.Plan<Output>;
163 yield* report({ _tag: "plan.phase", phase: "plan-ready" });
164 return {
165 stack: { name: session.stack.name, stage: session.stack.stage },
166 summary: summarize(native),
167 ...Plan.describePlan(native),
168 native,
169 createdAt: new Date(yield* Clock.currentTimeMillis),
170 session,
171 } satisfies PlanSnapshot<Output>;
172 }).pipe(Effect.withSpan("Alchemist.stack.plan"));
173
174/**
175 * Apply a computed plan. Engine apply events are reported through
176 * {@link Progress} as `ApplyEvent`.
177 */
178export const apply = Effect.fn("Alchemist.stack.apply")(function* <Output>(
179 snapshot: PlanSnapshot<Output>,
180) {
181 const report = withSpanEvents(yield* Progress);
182 return yield* Effect.provide(
183 applyPlan(snapshot.native, { session: applySession(report) }),
184 snapshot.session.context,
185 );
186});