| 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. */ |
| 18 | export const ExprTypeId = Symbol.for("alchemy/AWS/StepFunctions/JsonataExpr"); |
| 19 | export type ExprTypeId = typeof ExprTypeId; |
| 20 | |
| 21 | /** The value roots a JSONata path can start from. */ |
| 22 | export 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. */ |
| 31 | export 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 | */ |
| 37 | export 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 | |
| 51 | interface 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 | */ |
| 66 | export 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). */ |
| 74 | export type ExprInput<T> = T | Expr<T> | Expr<any>; |
| 75 | |
| 76 | /** Recursively replace `Expr<T>` references with their referent type. */ |
| 77 | export 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. */ |
| 87 | export 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}. */ |
| 93 | export 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 | */ |
| 100 | export 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 | |
| 120 | const literalNode = (value: unknown): ExprNode => ({ _: "literal", value }); |
| 121 | |
| 122 | /** Lift a value or Expr to an AST node. */ |
| 123 | export const toNode = (value: unknown): ExprNode => |
| 124 | isExpr(value) ? nodeOf(value) : literalNode(value); |
| 125 | |
| 126 | /** Lift a constant into a typed expression. */ |
| 127 | export 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 | */ |
| 133 | export const jsonata = <T = unknown>(expression: string): Expr<T> => |
| 134 | makeExpr({ _: "raw", jsonata: expression }); |
| 135 | |
| 136 | const 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). */ |
| 144 | export const eq = <T>(left: ExprInput<T>, right: ExprInput<T>): Expr<boolean> => |
| 145 | binop("=", left, right); |
| 146 | /** `left != right`. */ |
| 147 | export const ne = <T>(left: ExprInput<T>, right: ExprInput<T>): Expr<boolean> => |
| 148 | binop("!=", left, right); |
| 149 | /** `left > right`. */ |
| 150 | export const gt = ( |
| 151 | left: ExprInput<number>, |
| 152 | right: ExprInput<number>, |
| 153 | ): Expr<boolean> => binop(">", left, right); |
| 154 | /** `left >= right`. */ |
| 155 | export const gte = ( |
| 156 | left: ExprInput<number>, |
| 157 | right: ExprInput<number>, |
| 158 | ): Expr<boolean> => binop(">=", left, right); |
| 159 | /** `left < right`. */ |
| 160 | export const lt = ( |
| 161 | left: ExprInput<number>, |
| 162 | right: ExprInput<number>, |
| 163 | ): Expr<boolean> => binop("<", left, right); |
| 164 | /** `left <= right`. */ |
| 165 | export const lte = ( |
| 166 | left: ExprInput<number>, |
| 167 | right: ExprInput<number>, |
| 168 | ): Expr<boolean> => binop("<=", left, right); |
| 169 | /** Boolean conjunction. */ |
| 170 | export 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. */ |
| 186 | export 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(...)`). */ |
| 202 | export const not = (condition: ExprInput<boolean>): Expr<boolean> => |
| 203 | makeExpr({ _: "not", inner: toNode(condition) }); |
| 204 | |
| 205 | /** The execution-input root expression. */ |
| 206 | export const inputExpr = <T>(): Expr<T> => |
| 207 | makeExpr({ _: "root", root: { kind: "input" } }); |
| 208 | |
| 209 | /** A reference to the ASL variable `name`. */ |
| 210 | export 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 | */ |
| 218 | export const taskToken: Expr<string> = makeExpr({ |
| 219 | _: "root", |
| 220 | root: { kind: "token" }, |
| 221 | }); |
| 222 | |
| 223 | // --------------------------------------------------------------------------- |
| 224 | // JSONata printer (used by compile.ts) |
| 225 | // --------------------------------------------------------------------------- |
| 226 | |
| 227 | const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/; |
| 228 | |
| 229 | const 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). */ |
| 241 | export 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. */ |
| 266 | export const renderExprString = (expr: Expr<any>): string => |
| 267 | `{% ${renderNode(nodeOf(expr))} %}`; |