| 1 | import * as Context from "effect/Context"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; |
| 4 | import type { Pipeable } from "effect/Pipeable"; |
| 5 | import { makeCaptureContext } from "./ActionRuntimeContext.ts"; |
| 6 | import { toFqn } from "./FQN.ts"; |
| 7 | import type { Input } from "./Input.ts"; |
| 8 | import { CurrentNamespace, type NamespaceNode } from "./Namespace.ts"; |
| 9 | import * as Output from "./Output.ts"; |
| 10 | import { RuntimeContext } from "./RuntimeContext.ts"; |
| 11 | import { Stack } from "./Stack.ts"; |
| 12 | |
| 13 | /** |
| 14 | * An Action is a node in the dependency graph that runs an Effect with its |
| 15 | * resolved input during {@link plan}/{@link apply}. It is similar to a |
| 16 | * Resource but without a Provider lifecycle: |
| 17 | * |
| 18 | * - It has a LogicalId and typed Input. |
| 19 | * - The body Effect is called when input changes (diff) or `--force` is set. |
| 20 | * - There is no replace/precreate/read/delete: removing an Action from the |
| 21 | * stack simply drops its persisted state without invoking the body. |
| 22 | * - Dependencies pulled in by the init Effect surface as `Req` on the |
| 23 | * call site, exactly like a Resource's provider services. |
| 24 | * |
| 25 | * Actions are recorded on {@link Stack.actions} and produce a single output |
| 26 | * value; `yield*` on the constructor returns `Output<Out>` for use as |
| 27 | * input to downstream Resources / Actions. |
| 28 | */ |
| 29 | export interface ActionLike< |
| 30 | Type extends string = string, |
| 31 | In extends object | undefined = any, |
| 32 | Out = any, |
| 33 | > { |
| 34 | readonly Kind: "action"; |
| 35 | readonly Namespace: NamespaceNode | undefined; |
| 36 | readonly FQN: string; |
| 37 | readonly Type: Type; |
| 38 | readonly LogicalId: string; |
| 39 | readonly Input: In; |
| 40 | /** |
| 41 | * Resource Outputs referenced via `yield* output` inside the init Effect, |
| 42 | * keyed by the Output's sanitized key. They become dependency edges (the |
| 43 | * Action waits for these upstreams) and are resolved against the tracker at |
| 44 | * apply time, then exposed to the body through the resolve |
| 45 | * {@link RuntimeContext}. Empty when the Action captures nothing. |
| 46 | */ |
| 47 | readonly Captures: Record<string, Output.Output>; |
| 48 | /** Resolved runner — populated by the init effect (if any). */ |
| 49 | readonly Run: (input: In) => Effect.Effect<Out, any, any>; |
| 50 | /** @internal phantom */ |
| 51 | Output: Out; |
| 52 | } |
| 53 | |
| 54 | export const isAction = (value: any): value is ActionLike => |
| 55 | typeof value === "object" && value !== null && value?.Kind === "action"; |
| 56 | |
| 57 | /** Body function — runs each time the resolved input changes. */ |
| 58 | export type ActionRunner<In extends object | undefined, Out, Req = any> = ( |
| 59 | input: In, |
| 60 | ) => Effect.Effect<Out, any, Req>; |
| 61 | |
| 62 | /** |
| 63 | * Init Effect — declares dependencies via `yield*` and returns the runner. |
| 64 | * Lets multiple Action definitions share resolved services. |
| 65 | */ |
| 66 | export type ActionInit<In extends object | undefined, Out, Req> = Effect.Effect< |
| 67 | ActionRunner<In, Out, any>, |
| 68 | any, |
| 69 | Req |
| 70 | >; |
| 71 | |
| 72 | // ── Public API ───────────────────────────────────────────────────────────── |
| 73 | // |
| 74 | // Direct runner: |
| 75 | // |
| 76 | // const Sync = Action("Sync", Effect.fn(function* (input: { table: string }) { |
| 77 | // return { rows: 42 }; |
| 78 | // })); |
| 79 | // |
| 80 | // Init constructor — pulls in dependencies, returns the runner. The init |
| 81 | // Effect's `Req` channel bubbles up to the call site: |
| 82 | // |
| 83 | // const Sync = Action("Sync", Effect.gen(function* () { |
| 84 | // const db = yield* Database; |
| 85 | // return Effect.fn(function* (input: { table: string }) { |
| 86 | // return { rows: yield* db.count(input.table) }; |
| 87 | // }); |
| 88 | // })); |
| 89 | // |
| 90 | // Either way, call it inside a stack to register an instance — `yield*` |
| 91 | // returns `Output<Out>` ready to feed into downstream nodes: |
| 92 | // |
| 93 | // const rows = yield* Sync({ table: bucket.name }); |
| 94 | // // rows: Output<{ rows: number }, never> |
| 95 | // |
| 96 | // Tagged form for split contract/implementation — declare the type with an |
| 97 | // interface, build the value with the no-arg overload, then supply the runner |
| 98 | // via `.make` (add the returned Layer to the stack's `providers`, or provide it |
| 99 | // locally with `Effect.provide`). The init passed to `.make` runs under the |
| 100 | // same capture context as the inline form, so `yield* output` accessors work: |
| 101 | // |
| 102 | // interface Sync extends Action<"Sync", { table: string }, { rows: number }> {} |
| 103 | // const Sync = Action<Sync, { table: string }, { rows: number }>()("Sync"); |
| 104 | // const SyncLive = Sync.make(Effect.gen(function* () { /* ... */ })); |
| 105 | |
| 106 | export function Action< |
| 107 | Type extends string, |
| 108 | In extends object | undefined, |
| 109 | Out, |
| 110 | Req = never, |
| 111 | >( |
| 112 | type: Type, |
| 113 | initOrRun: ActionRunner<In, Out, Req> | ActionInit<In, Out, Req>, |
| 114 | ): ActionClass<never, Type, In, Out, Req>; |
| 115 | |
| 116 | export function Action<Self, In extends object | undefined, Out>(): < |
| 117 | Type extends string, |
| 118 | >( |
| 119 | type: Type, |
| 120 | ) => ActionClass<Self, Type, In, Out, Self>; |
| 121 | |
| 122 | export function Action(...args: any[]): any { |
| 123 | if (args.length === 0) { |
| 124 | // Tagged form: Action<Self, In, Out>()(type) |
| 125 | return (type: string) => makeActionClass(type, undefined); |
| 126 | } |
| 127 | // Inline form: Action(type, runnerOrInit) |
| 128 | const [type, initOrRun] = args as [ |
| 129 | string, |
| 130 | ActionRunner<any, any, any> | ActionInit<any, any, any>, |
| 131 | ]; |
| 132 | return makeActionClass(type, initOrRun); |
| 133 | } |
| 134 | |
| 135 | export interface ActionClass< |
| 136 | Self, |
| 137 | Type extends string, |
| 138 | In extends object | undefined, |
| 139 | Out, |
| 140 | Req, |
| 141 | > { |
| 142 | readonly Type: Type; |
| 143 | /** |
| 144 | * Default form — uses `Type` as the LogicalId. One instance per Action |
| 145 | * definition (the common case for deploy-time work). Returns the Action's |
| 146 | * output as `Output<Out>`. |
| 147 | */ |
| 148 | (input: { [k in keyof In]: Input<In[k]> }): Effect.Effect< |
| 149 | Output.ToOutput<Out, never>, |
| 150 | never, |
| 151 | Req | Stack |
| 152 | >; |
| 153 | /** |
| 154 | * Explicit-id form — register multiple instances of the same Action |
| 155 | * definition under distinct logical ids. |
| 156 | */ |
| 157 | ( |
| 158 | id: string, |
| 159 | input: { [k in keyof In]: Input<In[k]> }, |
| 160 | ): Effect.Effect<Output.ToOutput<Out, never>, never, Req | Stack>; |
| 161 | /** |
| 162 | * Tagged-only: bind an init Effect to this Action's Self tag. Add the |
| 163 | * returned Layer to the stack's `providers`. |
| 164 | */ |
| 165 | make: [Self] extends [never] |
| 166 | ? never |
| 167 | : <R = never>( |
| 168 | init: ActionRunner<In, Out, R> | ActionInit<In, Out, R>, |
| 169 | ) => Layer.Layer<Self, never, R>; |
| 170 | /** Tagged-only: the Context tag holding the resolved runner. */ |
| 171 | readonly Self: [Self] extends [never] |
| 172 | ? never |
| 173 | : Context.Service<Self, ActionRunner<In, Out, any>>; |
| 174 | } |
| 175 | |
| 176 | const isRunnerEffect = ( |
| 177 | v: ActionRunner<any, any, any> | ActionInit<any, any, any>, |
| 178 | ): v is ActionInit<any, any, any> => Effect.isEffect(v as any); |
| 179 | |
| 180 | const makeActionClass = ( |
| 181 | type: string, |
| 182 | baked: ActionRunner<any, any, any> | ActionInit<any, any, any> | undefined, |
| 183 | ): any => { |
| 184 | // Pre-resolve baked init/runner into a single Effect<Runner, _, Req>. Use |
| 185 | // Effect.cached so the init's body runs at most once per process — every |
| 186 | // action instance after the first reuses the resolved runner without paying |
| 187 | // the init cost (or re-yielding its dependencies). |
| 188 | let resolveRunner: |
| 189 | | Effect.Effect<ActionRunner<any, any, any>, any, any> |
| 190 | | undefined; |
| 191 | if (baked !== undefined) { |
| 192 | resolveRunner = isRunnerEffect(baked) |
| 193 | ? Effect.runSync(Effect.cached(baked)) |
| 194 | : Effect.succeed(baked); |
| 195 | } |
| 196 | |
| 197 | // Tagged form needs a Context tag so the user can supply the runner |
| 198 | // through a Layer. Inline form bakes the runner in and skips the tag. |
| 199 | const SelfTag = baked |
| 200 | ? undefined |
| 201 | : Context.Service<any, ActionRunner<any, any, any>>( |
| 202 | `alchemy/Action<${type}>`, |
| 203 | ); |
| 204 | |
| 205 | // Outputs referenced via `yield* output` inside the init Effect land here, |
| 206 | // recorded by the capture RuntimeContext. Shared per definition — the init |
| 207 | // runs at most once (Effect.cached), so captures are inherently |
| 208 | // per-definition, matching how the init's `Req` bubbles per definition. |
| 209 | const captures: Record<string, Output.Output> = {}; |
| 210 | const captureContext = makeCaptureContext(captures); |
| 211 | |
| 212 | const constructor = (...args: [any] | [string, any]) => { |
| 213 | const [id, input] = |
| 214 | args.length === 1 ? [type, args[0]] : (args as [string, any]); |
| 215 | return Effect.gen(function* () { |
| 216 | const run = resolveRunner |
| 217 | ? yield* resolveRunner.pipe( |
| 218 | Effect.provideService(RuntimeContext, captureContext), |
| 219 | ) |
| 220 | : ((yield* SelfTag!) as ActionRunner<any, any, any>); |
| 221 | return yield* registerAction(type, id, input, run, captures); |
| 222 | }); |
| 223 | }; |
| 224 | |
| 225 | const extra: Record<string, any> = { Type: type }; |
| 226 | if (SelfTag) { |
| 227 | extra.Self = SelfTag; |
| 228 | // `.make(initOrRun)` — accepts either a direct runner or an init Effect. |
| 229 | // For init form we use `Layer.effect` so the init's Req surfaces on the |
| 230 | // Layer, and run it under the capture RuntimeContext so `yield* output` |
| 231 | // accessors are recorded just like the inline form; for runners we use |
| 232 | // `Layer.succeed` (nothing to capture). |
| 233 | extra.make = <R>( |
| 234 | initOrRun: ActionRunner<any, any, R> | ActionInit<any, any, R>, |
| 235 | ) => |
| 236 | isRunnerEffect(initOrRun) |
| 237 | ? Layer.effect( |
| 238 | SelfTag, |
| 239 | (initOrRun as ActionInit<any, any, R>).pipe( |
| 240 | Effect.provideService(RuntimeContext, captureContext), |
| 241 | ), |
| 242 | ) |
| 243 | : Layer.succeed(SelfTag, initOrRun as ActionRunner<any, any, any>); |
| 244 | } |
| 245 | return Object.assign(constructor, extra); |
| 246 | }; |
| 247 | |
| 248 | const registerAction = < |
| 249 | Type extends string, |
| 250 | In extends object | undefined, |
| 251 | Out, |
| 252 | >( |
| 253 | type: Type, |
| 254 | id: string, |
| 255 | input: any, |
| 256 | run: ActionRunner<In, Out, any>, |
| 257 | captures: Record<string, Output.Output>, |
| 258 | ): Effect.Effect<Output.ToOutput<Out, never>, never, Stack> => |
| 259 | Effect.gen(function* () { |
| 260 | const stack = yield* Stack; |
| 261 | const namespace = yield* CurrentNamespace; |
| 262 | const fqn = toFqn(namespace, id); |
| 263 | |
| 264 | const actions = (stack.actions ??= {}); |
| 265 | const existing = actions[fqn]; |
| 266 | if (existing) |
| 267 | return Output.of(existing as any) as unknown as Output.ToOutput< |
| 268 | Out, |
| 269 | never |
| 270 | >; |
| 271 | |
| 272 | // FQN collision check: actions share the same FQN namespace as resources |
| 273 | // so the dependency graph stays unified. Rejecting overlaps here makes |
| 274 | // the constraint obvious at registration time. |
| 275 | if (stack.resources[fqn]) { |
| 276 | return yield* Effect.die( |
| 277 | new Error( |
| 278 | `Action '${fqn}' collides with a Resource of the same logical id`, |
| 279 | ), |
| 280 | ); |
| 281 | } |
| 282 | |
| 283 | const target: ActionLike<Type, In, Out> = { |
| 284 | Kind: "action" as const, |
| 285 | Type: type, |
| 286 | Namespace: namespace, |
| 287 | FQN: fqn, |
| 288 | LogicalId: id, |
| 289 | Input: input, |
| 290 | Captures: captures, |
| 291 | Run: run, |
| 292 | Output: undefined as any, |
| 293 | }; |
| 294 | (target as any).toString = () => `Action<${type}>(${id})`; |
| 295 | |
| 296 | actions[fqn] = target as any; |
| 297 | // `yield* Sync({...})` returns `Output<Out>`. The engine writes the |
| 298 | // materialized value into `tracker[fqn]` during apply; `Output.of(action)` |
| 299 | // resolves a ResourceExpr by looking up `outputs[fqn]` — which is |
| 300 | // precisely that materialized value. Property access into the returned |
| 301 | // Output chains through the standard PropExpr proxy. |
| 302 | return Output.of(target as any) as unknown as Output.ToOutput<Out, never>; |
| 303 | }); |
| 304 | |
| 305 | /** |
| 306 | * Pipeable Action instance used internally by Plan/Apply. Users get an |
| 307 | * `Output<Out>` from `yield*` and don't normally see this. |
| 308 | */ |
| 309 | export type Action< |
| 310 | Type extends string = string, |
| 311 | In extends object | undefined = any, |
| 312 | Out = any, |
| 313 | > = Pipeable & ActionLike<Type, In, Out>; |