1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import type { Pipeable } from "effect/Pipeable";
5import { makeCaptureContext } from "./ActionRuntimeContext.ts";
6import { toFqn } from "./FQN.ts";
7import type { Input } from "./Input.ts";
8import { CurrentNamespace, type NamespaceNode } from "./Namespace.ts";
9import * as Output from "./Output.ts";
10import { RuntimeContext } from "./RuntimeContext.ts";
11import { 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 */
29export 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
54export 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. */
58export 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 */
66export 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
106export 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
116export function Action<Self, In extends object | undefined, Out>(): <
117 Type extends string,
118>(
119 type: Type,
120) => ActionClass<Self, Type, In, Out, Self>;
121
122export 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
135export 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
176const isRunnerEffect = (
177 v: ActionRunner<any, any, any> | ActionInit<any, any, any>,
178): v is ActionInit<any, any, any> => Effect.isEffect(v as any);
179
180const 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
248const 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 */
309export type Action<
310 Type extends string = string,
311 In extends object | undefined = any,
312 Out = any,
313> = Pipeable & ActionLike<Type, In, Out>;