1/**
2 * Phantom-typed JSONata value references for the Step Functions program DSL.
3 *
4 * An {@link Expr} is a *typed reference to a value that will exist at
5 * execution time* — the execution input, the result of a previous state
6 * (an ASL variable), the current Map item, etc. Property access is a Proxy
7 * that builds up a JSONata path (`$states.context.Execution.Input.customerId`
8 * style); comparator helpers (`eq`, `gt`, `and`, …) build boolean
9 * expressions for Choice states.
10 *
11 * The same expression AST has two interpreters:
12 * - `compile.ts` renders it to a `{% ... %}` JSONata string in the ASL
13 * definition,
14 * - `simulate.ts` evaluates it in-process against a variable environment.
15 */
16
17/** Brand key carried by every {@link Expr} proxy. */
18export const ExprTypeId = Symbol.for("alchemy/AWS/StepFunctions/JsonataExpr");
19export type ExprTypeId = typeof ExprTypeId;
20
21/** The value roots a JSONata path can start from. */
22export type ExprRoot =
23 /** The state machine execution input (`$states.context.Execution.Input`). */
24 | { readonly kind: "input" }
25 /** An ASL variable assigned by an earlier state (`$name`). */
26 | { readonly kind: "variable"; readonly name: string }
27 /** The callback task token (`$states.context.Task.Token`). */
28 | { readonly kind: "token" };
29
30/** Binary comparison / boolean operators supported by the expression AST. */
31export type ExprOperator = "=" | "!=" | ">" | ">=" | "<" | "<=" | "and" | "or";
32
33/**
34 * The untyped expression AST behind an {@link Expr} proxy. Plain data — both
35 * the ASL printer and the local simulator interpret it structurally.
36 */
37export type ExprNode =
38 | { readonly _: "root"; readonly root: ExprRoot }
39 | { readonly _: "prop"; readonly parent: ExprNode; readonly name: string }
40 | { readonly _: "literal"; readonly value: unknown }
41 | {
42 readonly _: "binop";
43 readonly op: ExprOperator;
44 readonly left: ExprNode;
45 readonly right: ExprNode;
46 }
47 | { readonly _: "not"; readonly inner: ExprNode }
48 /** Raw JSONata escape hatch — compiles verbatim, unsupported by simulate. */
49 | { readonly _: "raw"; readonly jsonata: string };
50
51interface ExprBrand<T> {
52 readonly [ExprTypeId]: {
53 readonly node: ExprNode;
54 readonly _T: (_: never) => T;
55 };
56}
57
58/**
59 * A typed JSONata value reference. Property access is typed: given
60 * `input: Expr<{ order: { total: number } }>`, `input.order.total` is an
61 * `Expr<number>` that renders as `….order.total`.
62 *
63 * Arrays are opaque references (pass them to `Sfn.forEach`); primitives are
64 * leaf references usable in comparators and payloads.
65 */
66export type Expr<T = any> = ExprBrand<T> &
67 ([T] extends [readonly any[]]
68 ? {}
69 : [T] extends [object]
70 ? { readonly [K in keyof T & string]-?: Expr<T[K]> }
71 : {});
72
73/** A value of `T`, a typed reference to one, or `Expr<any>` (untyped hole). */
74export type ExprInput<T> = T | Expr<T> | Expr<any>;
75
76/** Recursively replace `Expr<T>` references with their referent type. */
77export type UnwrapExpr<T> =
78 T extends Expr<infer A>
79 ? A
80 : T extends readonly (infer U)[]
81 ? UnwrapExpr<U>[]
82 : T extends object
83 ? { [K in keyof T]: UnwrapExpr<T[K]> }
84 : T;
85
86/** Runtime guard for {@link Expr} proxies. */
87export const isExpr = (value: unknown): value is Expr<any> =>
88 (typeof value === "object" || typeof value === "function") &&
89 value !== null &&
90 (value as ExprBrand<any>)[ExprTypeId] !== undefined;
91
92/** Extract the AST node from an {@link Expr}. */
93export const nodeOf = (expr: Expr<any>): ExprNode => expr[ExprTypeId].node;
94
95/**
96 * Wrap an AST node in the typed Proxy. String property access appends a
97 * `prop` node; everything else (symbols, `then`, iteration protocols) is
98 * absent so the proxy never masquerades as a Promise, Effect, or Output.
99 */
100export const makeExpr = <T>(node: ExprNode): Expr<T> => {
101 const brand = { node, _T: undefined as never };
102 return new Proxy(
103 {},
104 {
105 get: (_, prop) => {
106 if (prop === ExprTypeId) return brand;
107 if (typeof prop !== "string") return undefined;
108 // `toJSON`/`then` lookups happen when host code probes values
109 // (JSON.stringify, await). Treat them as path segments would be
110 // wrong; renderers detect Exprs *before* serialization, so any
111 // such probe reaching here is a misuse — return a child expr
112 // anyway (harmless, typed access never sees these names).
113 return makeExpr({ _: "prop", parent: node, name: prop });
114 },
115 has: (_, prop) => prop === ExprTypeId,
116 },
117 ) as Expr<T>;
118};
119
120const literalNode = (value: unknown): ExprNode => ({ _: "literal", value });
121
122/** Lift a value or Expr to an AST node. */
123export const toNode = (value: unknown): ExprNode =>
124 isExpr(value) ? nodeOf(value) : literalNode(value);
125
126/** Lift a constant into a typed expression. */
127export const literal = <T>(value: T): Expr<T> => makeExpr(literalNode(value));
128
129/**
130 * Raw JSONata escape hatch. Compiles verbatim into the definition
131 * (`{% <jsonata> %}`); `simulate` rejects it with a `SimulateError`.
132 */
133export const jsonata = <T = unknown>(expression: string): Expr<T> =>
134 makeExpr({ _: "raw", jsonata: expression });
135
136const binop = (
137 op: ExprOperator,
138 left: unknown,
139 right: unknown,
140): Expr<boolean> =>
141 makeExpr({ _: "binop", op, left: toNode(left), right: toNode(right) });
142
143/** `left = right` (JSONata equality). */
144export const eq = <T>(left: ExprInput<T>, right: ExprInput<T>): Expr<boolean> =>
145 binop("=", left, right);
146/** `left != right`. */
147export const ne = <T>(left: ExprInput<T>, right: ExprInput<T>): Expr<boolean> =>
148 binop("!=", left, right);
149/** `left > right`. */
150export const gt = (
151 left: ExprInput<number>,
152 right: ExprInput<number>,
153): Expr<boolean> => binop(">", left, right);
154/** `left >= right`. */
155export const gte = (
156 left: ExprInput<number>,
157 right: ExprInput<number>,
158): Expr<boolean> => binop(">=", left, right);
159/** `left < right`. */
160export const lt = (
161 left: ExprInput<number>,
162 right: ExprInput<number>,
163): Expr<boolean> => binop("<", left, right);
164/** `left <= right`. */
165export const lte = (
166 left: ExprInput<number>,
167 right: ExprInput<number>,
168): Expr<boolean> => binop("<=", left, right);
169/** Boolean conjunction. */
170export const and = (
171 ...conditions: [
172 ExprInput<boolean>,
173 ExprInput<boolean>,
174 ...ExprInput<boolean>[],
175 ]
176): Expr<boolean> =>
177 conditions
178 .slice(1)
179 .reduce<Expr<boolean>>(
180 (acc, c) => binop("and", acc, c),
181 isExpr(conditions[0])
182 ? (conditions[0] as Expr<boolean>)
183 : literal(conditions[0] as boolean),
184 );
185/** Boolean disjunction. */
186export const or = (
187 ...conditions: [
188 ExprInput<boolean>,
189 ExprInput<boolean>,
190 ...ExprInput<boolean>[],
191 ]
192): Expr<boolean> =>
193 conditions
194 .slice(1)
195 .reduce<Expr<boolean>>(
196 (acc, c) => binop("or", acc, c),
197 isExpr(conditions[0])
198 ? (conditions[0] as Expr<boolean>)
199 : literal(conditions[0] as boolean),
200 );
201/** Boolean negation (`$not(...)`). */
202export const not = (condition: ExprInput<boolean>): Expr<boolean> =>
203 makeExpr({ _: "not", inner: toNode(condition) });
204
205/** The execution-input root expression. */
206export const inputExpr = <T>(): Expr<T> =>
207 makeExpr({ _: "root", root: { kind: "input" } });
208
209/** A reference to the ASL variable `name`. */
210export const variableExpr = <T>(name: string): Expr<T> =>
211 makeExpr({ _: "root", root: { kind: "variable", name } });
212
213/**
214 * The callback task token (`$states.context.Task.Token`) — embed inside
215 * `Sfn.waitForTaskToken` arguments so the callback side can complete the
216 * task via `SendTaskSuccess`/`SendTaskFailure`.
217 */
218export const taskToken: Expr<string> = makeExpr({
219 _: "root",
220 root: { kind: "token" },
221});
222
223// ---------------------------------------------------------------------------
224// JSONata printer (used by compile.ts)
225// ---------------------------------------------------------------------------
226
227const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
228
229const renderRoot = (root: ExprRoot): string => {
230 switch (root.kind) {
231 case "input":
232 return "$states.context.Execution.Input";
233 case "variable":
234 return `$${root.name}`;
235 case "token":
236 return "$states.context.Task.Token";
237 }
238};
239
240/** Render an AST node to JSONata source (without the `{% %}` wrapper). */
241export const renderNode = (node: ExprNode): string => {
242 switch (node._) {
243 case "root":
244 return renderRoot(node.root);
245 case "prop": {
246 const parent =
247 node.parent._ === "root" || node.parent._ === "prop"
248 ? renderNode(node.parent)
249 : `(${renderNode(node.parent)})`;
250 return IDENT.test(node.name)
251 ? `${parent}.${node.name}`
252 : `${parent}.\`${node.name}\``;
253 }
254 case "literal":
255 return JSON.stringify(node.value === undefined ? null : node.value);
256 case "binop":
257 return `(${renderNode(node.left)} ${node.op} ${renderNode(node.right)})`;
258 case "not":
259 return `$not(${renderNode(node.inner)})`;
260 case "raw":
261 return node.jsonata;
262 }
263};
264
265/** Render an {@link Expr} to a `{% ... %}`-wrapped ASL JSONata string. */
266export const renderExprString = (expr: Expr<any>): string =>
267 `{% ${renderNode(nodeOf(expr))} %}`;