| 1 | /** |
| 2 | * The `Sfn.*` combinators — Effect-mirroring constructors over the ASL AST. |
| 3 | * |
| 4 | * Names and semantics track their `Effect` counterparts as closely as the |
| 5 | * target language allows: `Sfn.gen`, `Sfn.retry`, `Sfn.catchTag`, |
| 6 | * `Sfn.catchAll`, `Sfn.all`, `Sfn.forEach`, `Sfn.sleep`, `Sfn.when`, |
| 7 | * `Sfn.match`, `Sfn.succeed`, `Sfn.fail`. Divergences are deliberate: |
| 8 | * `retry` takes an option bag mirroring the `Schedule.exponential` |
| 9 | * vocabulary (a real `Schedule` is opaque and cannot be introspected into |
| 10 | * ASL's `BackoffRate`), and branching goes through `when`/`match` because a |
| 11 | * compiled program cannot branch on runtime JS. |
| 12 | */ |
| 13 | import * as Duration from "effect/Duration"; |
| 14 | import { aslSeconds } from "./compile.ts"; |
| 15 | import type { Expr, UnwrapExpr } from "./Jsonata.ts"; |
| 16 | import type { |
| 17 | ErrorOutput, |
| 18 | ForEachOptions, |
| 19 | IntegrationOptions, |
| 20 | InvokableFunction, |
| 21 | RetryOptions, |
| 22 | } from "./Node.ts"; |
| 23 | import { |
| 24 | isSfnEffect as isSfn, |
| 25 | make, |
| 26 | type Error as ErrorOf, |
| 27 | type SfnEffect, |
| 28 | type Success, |
| 29 | } from "./Program.ts"; |
| 30 | |
| 31 | /** |
| 32 | * Invoke a Lambda function as a Task state |
| 33 | * (`arn:aws:states:::lambda:invoke`). The payload may embed typed `Expr` |
| 34 | * references and resource `Output`s; the result reference is the function's |
| 35 | * response payload. |
| 36 | * |
| 37 | * Compiling the program emits the `lambda:InvokeFunction` policy statement |
| 38 | * for the function's exact ARN (and its qualified variants) into the |
| 39 | * collected `policyStatements`. |
| 40 | */ |
| 41 | export const invoke = <A = unknown, E = never>( |
| 42 | fn: InvokableFunction, |
| 43 | payload?: unknown, |
| 44 | ): SfnEffect<A, E> => make({ kind: "invoke", fn, payload }); |
| 45 | |
| 46 | /** |
| 47 | * Call an optimized service integration as a Task state, e.g. |
| 48 | * `arn:aws:states:::sqs:sendMessage`. Provide the exact-ARN policy |
| 49 | * statements the execution role needs. |
| 50 | */ |
| 51 | export const integrate = <A = unknown, E = never>( |
| 52 | options: IntegrationOptions, |
| 53 | ): SfnEffect<A, E> => |
| 54 | make({ kind: "integrate", options, waitForTaskToken: false }); |
| 55 | |
| 56 | /** |
| 57 | * Call a service integration with the `.waitForTaskToken` callback pattern: |
| 58 | * the state parks until `SendTaskSuccess`/`SendTaskFailure` is called with |
| 59 | * the token. Embed {@link taskToken} (re-exported as `Sfn.taskToken`) in the |
| 60 | * arguments to hand the token to the callback side; the result is the JSON |
| 61 | * given to `SendTaskSuccess`. |
| 62 | */ |
| 63 | export const waitForTaskToken = <A = unknown, E = never>( |
| 64 | options: IntegrationOptions, |
| 65 | ): SfnEffect<A, E> => |
| 66 | make({ kind: "integrate", options, waitForTaskToken: true }); |
| 67 | |
| 68 | /** |
| 69 | * Run programs in parallel (an ASL `Parallel` state) — mirrors |
| 70 | * `Effect.all`. The result is the tuple of branch results. |
| 71 | */ |
| 72 | export const all = <const T extends readonly SfnEffect<any, any>[]>( |
| 73 | branches: T, |
| 74 | ): SfnEffect<{ [K in keyof T]: Success<T[K]> }, ErrorOf<T[number]>> => |
| 75 | make({ kind: "all", branches }); |
| 76 | |
| 77 | /** |
| 78 | * Apply `body` to every element of `items` (an ASL inline `Map` state) — |
| 79 | * mirrors `Effect.forEach`, including the `concurrency` option |
| 80 | * (`MaxConcurrency`). The result is the array of iteration results. |
| 81 | */ |
| 82 | export const forEach = <Item, A, E>( |
| 83 | items: Expr<readonly Item[]> | Expr<Item[]>, |
| 84 | body: (item: Expr<Item>) => SfnEffect<A, E>, |
| 85 | options?: ForEachOptions, |
| 86 | ): SfnEffect<A[], E> => |
| 87 | make({ |
| 88 | kind: "forEach", |
| 89 | items: items as Expr<any>, |
| 90 | body: body as (item: Expr<any>) => SfnEffect<any, any>, |
| 91 | options: options ?? {}, |
| 92 | }); |
| 93 | |
| 94 | /** |
| 95 | * Pause the workflow (an ASL `Wait` state) — mirrors `Effect.sleep`. |
| 96 | * Durations round up to whole seconds (ASL's granularity). |
| 97 | */ |
| 98 | export const sleep = (duration: Duration.Input): SfnEffect<void> => |
| 99 | make({ |
| 100 | kind: "sleep", |
| 101 | seconds: Math.max(1, Math.ceil(Duration.toSeconds(duration))), |
| 102 | }); |
| 103 | |
| 104 | /** |
| 105 | * Branch on a typed condition (an ASL `Choice` state) — mirrors |
| 106 | * `Effect.if`. Build conditions with the typed comparators (`Sfn.eq`, |
| 107 | * `Sfn.gt`, `Sfn.and`, …). When `onFalse` is omitted the false branch |
| 108 | * produces `null`. |
| 109 | */ |
| 110 | export const when = <A, E, B = null, E2 = never>( |
| 111 | condition: Expr<boolean>, |
| 112 | onTrue: SfnEffect<A, E>, |
| 113 | onFalse?: SfnEffect<B, E2>, |
| 114 | ): SfnEffect<A | B, E | E2> => |
| 115 | make({ kind: "when", condition, onTrue, onFalse }); |
| 116 | |
| 117 | /** |
| 118 | * Branch on a value's literal cases (an ASL `Choice` state with one rule |
| 119 | * per case) — mirrors `Effect.match`-style dispatch. With no `otherwise` |
| 120 | * and no matching case the execution fails with `States.NoChoiceMatched`. |
| 121 | */ |
| 122 | export const match = < |
| 123 | T extends string | number | boolean, |
| 124 | const Cases extends Record<string, SfnEffect<any, any>>, |
| 125 | B = never, |
| 126 | E2 = never, |
| 127 | >( |
| 128 | value: Expr<T>, |
| 129 | cases: Cases, |
| 130 | otherwise?: SfnEffect<B, E2>, |
| 131 | ): SfnEffect< |
| 132 | Success<Cases[keyof Cases]> | B, |
| 133 | ErrorOf<Cases[keyof Cases]> | E2 |
| 134 | > => make({ kind: "match", value, cases, otherwise }); |
| 135 | |
| 136 | /** |
| 137 | * Produce a value (an ASL `Pass` state) — mirrors `Effect.succeed`. The |
| 138 | * value may embed typed `Expr` references from earlier steps. |
| 139 | */ |
| 140 | export const succeed = <const T>(value: T): SfnEffect<UnwrapExpr<T>> => |
| 141 | make({ kind: "succeed", value }); |
| 142 | |
| 143 | /** |
| 144 | * Fail the workflow with a tagged error (an ASL `Fail` state) — mirrors |
| 145 | * `Effect.fail`. The error's `_tag` becomes the ASL `Error` name, so |
| 146 | * `Sfn.catchTag(program, tag, …)` and `Sfn.retry({ while: [tag] })` line up |
| 147 | * with it end-to-end. |
| 148 | */ |
| 149 | export const fail = <E extends { readonly _tag: string }>( |
| 150 | error: E, |
| 151 | cause?: string, |
| 152 | ): SfnEffect<never, E> => |
| 153 | make({ |
| 154 | kind: "fail", |
| 155 | error: error._tag, |
| 156 | cause: cause ?? (error as { message?: string }).message ?? error._tag, |
| 157 | failure: error, |
| 158 | }); |
| 159 | |
| 160 | /** |
| 161 | * Retry a program on failure (ASL `Retry`) — mirrors `Effect.retry`, with |
| 162 | * an option bag in the `Schedule.exponential` vocabulary (`initial`, |
| 163 | * `backoff`, `maxAttempts`, `maxDelay`, `jitter`; `while` takes error |
| 164 | * tags). Multi-state programs are wrapped in a single-branch `Parallel` |
| 165 | * state so the whole program re-runs, matching `Effect.retry` semantics. |
| 166 | */ |
| 167 | export const retry: { |
| 168 | (options: RetryOptions): <A, E>(self: SfnEffect<A, E>) => SfnEffect<A, E>; |
| 169 | <A, E>(self: SfnEffect<A, E>, options: RetryOptions): SfnEffect<A, E>; |
| 170 | } = (( |
| 171 | selfOrOptions: SfnEffect<any, any> | RetryOptions, |
| 172 | options?: RetryOptions, |
| 173 | ) => |
| 174 | isSfn(selfOrOptions) |
| 175 | ? make({ kind: "retry", inner: selfOrOptions, options: options ?? {} }) |
| 176 | : (self: SfnEffect<any, any>) => |
| 177 | make({ kind: "retry", inner: self, options: selfOrOptions })) as any; |
| 178 | |
| 179 | /** |
| 180 | * Catch failures by tag (ASL `Catch` with `ErrorEquals`) — mirrors |
| 181 | * `Effect.catchTag`, narrowing `E` exactly the same way. The handler |
| 182 | * receives a typed reference to the ASL error output (`{ Error, Cause }`). |
| 183 | */ |
| 184 | export const catchTag: { |
| 185 | <Tag extends string, A2, E2>( |
| 186 | tag: Tag | readonly Tag[], |
| 187 | handler: (error: Expr<ErrorOutput>) => SfnEffect<A2, E2>, |
| 188 | ): <A, E>( |
| 189 | self: SfnEffect<A, E>, |
| 190 | ) => SfnEffect<A | A2, Exclude<E, { _tag: Tag }> | E2>; |
| 191 | <A, E, Tag extends string, A2, E2>( |
| 192 | self: SfnEffect<A, E>, |
| 193 | tag: Tag | readonly Tag[], |
| 194 | handler: (error: Expr<ErrorOutput>) => SfnEffect<A2, E2>, |
| 195 | ): SfnEffect<A | A2, Exclude<E, { _tag: Tag }> | E2>; |
| 196 | } = ((...args: any[]) => |
| 197 | isSfn(args[0]) |
| 198 | ? make({ |
| 199 | kind: "catch", |
| 200 | inner: args[0], |
| 201 | tags: Array.isArray(args[1]) ? args[1] : [args[1]], |
| 202 | handler: args[2], |
| 203 | }) |
| 204 | : (self: SfnEffect<any, any>) => |
| 205 | make({ |
| 206 | kind: "catch", |
| 207 | inner: self, |
| 208 | tags: Array.isArray(args[0]) ? args[0] : [args[0]], |
| 209 | handler: args[1], |
| 210 | })) as any; |
| 211 | |
| 212 | /** |
| 213 | * Catch every failure (ASL `Catch` with `States.ALL`) — mirrors |
| 214 | * `Effect.catchAll`. |
| 215 | */ |
| 216 | export const catchAll: { |
| 217 | <A2, E2>( |
| 218 | handler: (error: Expr<ErrorOutput>) => SfnEffect<A2, E2>, |
| 219 | ): <A, E>(self: SfnEffect<A, E>) => SfnEffect<A | A2, E2>; |
| 220 | <A, E, A2, E2>( |
| 221 | self: SfnEffect<A, E>, |
| 222 | handler: (error: Expr<ErrorOutput>) => SfnEffect<A2, E2>, |
| 223 | ): SfnEffect<A | A2, E2>; |
| 224 | } = ((...args: any[]) => |
| 225 | isSfn(args[0]) |
| 226 | ? make({ |
| 227 | kind: "catch", |
| 228 | inner: args[0], |
| 229 | tags: ["States.ALL"], |
| 230 | handler: args[1], |
| 231 | }) |
| 232 | : (self: SfnEffect<any, any>) => |
| 233 | make({ |
| 234 | kind: "catch", |
| 235 | inner: self, |
| 236 | tags: ["States.ALL"], |
| 237 | handler: args[0], |
| 238 | })) as any; |
| 239 | |
| 240 | /** The `States.*` built-in error names, for `retry`/`catchTag`. */ |
| 241 | export const Errors = { |
| 242 | ALL: "States.ALL", |
| 243 | Timeout: "States.Timeout", |
| 244 | TaskFailed: "States.TaskFailed", |
| 245 | Permissions: "States.Permissions", |
| 246 | BranchFailed: "States.BranchFailed", |
| 247 | NoChoiceMatched: "States.NoChoiceMatched", |
| 248 | IntrinsicFailure: "States.IntrinsicFailure", |
| 249 | ExceedToleratedFailureThreshold: "States.ExceedToleratedFailureThreshold", |
| 250 | ItemReaderFailed: "States.ItemReaderFailed", |
| 251 | ResultWriterFailed: "States.ResultWriterFailed", |
| 252 | HeartbeatTimeout: "States.HeartbeatTimeout", |
| 253 | QueryEvaluationError: "States.QueryEvaluationError", |
| 254 | Runtime: "States.Runtime", |
| 255 | } as const; |