| 1 | import * as Clock from "effect/Clock"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import { apply as applyPlan } from "../../Apply.ts"; |
| 4 | import * as Plan from "../../Plan.ts"; |
| 5 | import type { |
| 6 | ResourceSelection, |
| 7 | SelectionOutput, |
| 8 | } from "../../ResourceSelection.ts"; |
| 9 | import type { PlannedAction, PlannedResource } from "../../Report.ts"; |
| 10 | import { applySession, Progress, withSpanEvents } from "../Progress.ts"; |
| 11 | import { open, type Session, type StackTarget } from "../Session.ts"; |
| 12 | |
| 13 | export type { StackTarget } from "../Session.ts"; |
| 14 | |
| 15 | export 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. */ |
| 28 | export interface FilteredPlanInput |
| 29 | extends Omit<PlanInput, keyof ResourceSelection>, ResourceSelection {} |
| 30 | |
| 31 | /** Infer the deployed stack output from an `alchemy.run.ts` module type. */ |
| 32 | export 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 | |
| 42 | export 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 | |
| 52 | export 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). */ |
| 71 | export 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 | |
| 80 | export 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 | |
| 97 | type 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 | */ |
| 110 | export function plan<Module = unknown>( |
| 111 | input: FilteredPlanInput & |
| 112 | ({ include: ReadonlyArray<string> } | { exclude: ReadonlyArray<string> }), |
| 113 | ): PlanResult<undefined>; |
| 114 | export function plan<Module = unknown>( |
| 115 | input: PlanInput, |
| 116 | ): PlanResult<StackModuleOutput<Module>>; |
| 117 | export function plan<Module = unknown>( |
| 118 | input: FilteredPlanInput, |
| 119 | ): PlanResult<StackModuleOutput<Module> | undefined>; |
| 120 | export function plan< |
| 121 | Module = unknown, |
| 122 | Input extends FilteredPlanInput = PlanInput, |
| 123 | >(input: Input): PlanResult<SelectionOutput<StackModuleOutput<Module>, Input>>; |
| 124 | export function plan<Module = unknown>(input: FilteredPlanInput) { |
| 125 | return planStack<Module>(input); |
| 126 | } |
| 127 | |
| 128 | const 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 | */ |
| 178 | export 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 | }); |