| 1 | import * as Lambda from "@distilled.cloud/aws/lambda"; |
| 2 | import type { ConfigError } from "effect/Config"; |
| 3 | import * as Context from "effect/Context"; |
| 4 | import type * as Duration from "effect/Duration"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Effectable from "effect/Effectable"; |
| 7 | import type * as Layer from "effect/Layer"; |
| 8 | import * as Option from "effect/Option"; |
| 9 | import type { Scope } from "effect/Scope"; |
| 10 | import type { PackageInstall } from "../../Bundle/InstalledPackages.ts"; |
| 11 | import type { InputProps } from "../../Input.ts"; |
| 12 | import * as Output from "../../Output.ts"; |
| 13 | import type { PlatformServices } from "../../Platform.ts"; |
| 14 | import { toSeconds, toWireDays } from "../../Util/Duration.ts"; |
| 15 | import type { DistributiveOmit } from "../../Util/types.ts"; |
| 16 | import { effectClass, taggedFunction } from "../../Util/effect.ts"; |
| 17 | import type { DurableExecutionContext, DurableStep } from "./Durable.ts"; |
| 18 | import { |
| 19 | DURABLE_SDK_MODULE, |
| 20 | encodeDurableEnvelope, |
| 21 | makeDurableListener, |
| 22 | } from "./DurableBridge.ts"; |
| 23 | import { |
| 24 | Function, |
| 25 | type FunctionProps, |
| 26 | type FunctionServices, |
| 27 | type HandlerContext, |
| 28 | } from "./Function.ts"; |
| 29 | |
| 30 | type TypeId = "AWS.Lambda.DurableFunction"; |
| 31 | const TypeId = "AWS.Lambda.DurableFunction" as const; |
| 32 | |
| 33 | /** |
| 34 | * The services available inside a durable function's run body. |
| 35 | * |
| 36 | * The bridge provides all of them per durable invocation: `DurableStep` |
| 37 | * powers `Durable.step`/`Durable.sleep`/`Durable.waitForCallback`, |
| 38 | * `DurableExecutionContext` carries the execution ARN, `HandlerContext` is |
| 39 | * the raw `lambda.Context`, and a fresh `Scope` scopes per-invocation |
| 40 | * resources. |
| 41 | * |
| 42 | * Deliberately narrow: cloud clients (`Credentials`/`Region`-requiring |
| 43 | * effects) are NOT provided to the body directly — resolve typed binding |
| 44 | * clients in the init phase and call them inside `Durable.step`, which is |
| 45 | * exactly the determinism law the replay model requires. |
| 46 | */ |
| 47 | export type DurableRunServices = |
| 48 | | DurableStep |
| 49 | | DurableExecutionContext |
| 50 | | HandlerContext |
| 51 | | Scope; |
| 52 | |
| 53 | /** |
| 54 | * A durable function implementation: a function from a typed `Input` payload |
| 55 | * to an Effect producing the execution's `Result`. Code outside |
| 56 | * `Durable.step` re-runs on every replay and must be deterministic. |
| 57 | */ |
| 58 | export type DurableFunctionImpl<Input = unknown, Result = unknown> = ( |
| 59 | input: Input, |
| 60 | ) => Effect.Effect<Result, never, DurableRunServices>; |
| 61 | |
| 62 | /** |
| 63 | * Services satisfied by the DurableFunction's own machinery (or the engine) |
| 64 | * and therefore excluded from the caller-facing requirements of the returned |
| 65 | * Effect/Layer. |
| 66 | */ |
| 67 | export type DurableFunctionInitServices = |
| 68 | | FunctionServices |
| 69 | | PlatformServices |
| 70 | | Function |
| 71 | | DurableRunServices; |
| 72 | |
| 73 | /** |
| 74 | * Properties of an {@link DurableFunction | AWS.Lambda.DurableFunction}. |
| 75 | * |
| 76 | * A DurableFunction accepts every {@link FunctionProps | Function prop} |
| 77 | * except `functionUrl` (every invocation of a durable function arrives as the |
| 78 | * durable-execution envelope — there is no HTTP surface), plus the |
| 79 | * `DurableConfig` tuning knobs below. |
| 80 | */ |
| 81 | /** The DurableConfig tuning knobs a DurableFunction adds to Function props. */ |
| 82 | export interface DurableFunctionTuning { |
| 83 | /** |
| 84 | * Maximum total duration of a durable execution, from start to terminal |
| 85 | * state (minimum 60 seconds, maximum 1 year). Rounded up to whole seconds. |
| 86 | * @default 24 hours (AWS default) |
| 87 | */ |
| 88 | executionTimeout?: Duration.Input; |
| 89 | /** |
| 90 | * How long completed execution history is retained (e.g. `"7 days"`; |
| 91 | * 1–90 days). Rounded to whole days on the wire. |
| 92 | * @default "14 days" (AWS default) |
| 93 | */ |
| 94 | retentionPeriod?: Duration.Input; |
| 95 | } |
| 96 | |
| 97 | /** |
| 98 | * Props of a {@link DurableFunction}: any Function props that carry a |
| 99 | * `main` — the orchestrator body is an Effect that has to be bundled into |
| 100 | * an entrypoint — minus `functionUrl` (every invocation arrives as the |
| 101 | * durable envelope, so there is no HTTP surface), plus the DurableConfig |
| 102 | * knobs. |
| 103 | * |
| 104 | * Selected by SHAPE (`Extract<…, { main: string }>`), not by packaging, so |
| 105 | * any future main-bearing variant is durable-capable automatically; a |
| 106 | * prebuilt `image` function is excluded because it has no `main` for the |
| 107 | * `impl` Effect to live in. |
| 108 | * |
| 109 | * The Omit DISTRIBUTES over the union: a bare `Omit` computes the COMMON |
| 110 | * keys and merges the members, which would make `main` optional and let |
| 111 | * `image` through alongside it. |
| 112 | */ |
| 113 | export type DurableFunctionProps = DistributiveOmit< |
| 114 | Extract<FunctionProps, { main: string }>, |
| 115 | "functionUrl" | "durableConfig" |
| 116 | > & |
| 117 | DurableFunctionTuning; |
| 118 | |
| 119 | /** |
| 120 | * Options for starting a durable execution. |
| 121 | */ |
| 122 | export interface DurableStartOptions<Input = unknown> { |
| 123 | /** |
| 124 | * Idempotent execution name (`DurableExecutionName`): starting again with |
| 125 | * the same name and payload reattaches to the existing execution; the same |
| 126 | * name with a different payload fails with |
| 127 | * `DurableExecutionAlreadyStartedException`. |
| 128 | */ |
| 129 | name?: string; |
| 130 | /** The typed input payload delivered to the durable function body. */ |
| 131 | params?: Input; |
| 132 | /** |
| 133 | * Function version or alias to pin the execution to. Durable executions |
| 134 | * replay against the version they started on. `$LATEST` is suitable for |
| 135 | * disposable development; production starts should target an immutable |
| 136 | * numbered {@link Version} or a stable {@link Alias}. |
| 137 | */ |
| 138 | qualifier?: string; |
| 139 | } |
| 140 | |
| 141 | /** |
| 142 | * A started durable execution reference. |
| 143 | */ |
| 144 | export interface DurableExecutionRef { |
| 145 | /** ARN of the durable execution (when returned by the Invoke response). */ |
| 146 | executionArn: string | undefined; |
| 147 | statusCode: number | undefined; |
| 148 | } |
| 149 | |
| 150 | /** |
| 151 | * The typed durable-execution handle: start, inspect, stop, and complete |
| 152 | * callbacks of durable executions of this function. Returned by |
| 153 | * `yield* MyDurableFunction` (as part of {@link DurableFunction}) and, inside |
| 154 | * the function's own init phase, by {@link DurableFunctionScope}. |
| 155 | */ |
| 156 | export interface DurableFunctionHandle<Input = unknown, Result = unknown> { |
| 157 | Type: TypeId; |
| 158 | name: string; |
| 159 | /** @internal phantom */ |
| 160 | Result?: Result; |
| 161 | /** |
| 162 | * Start a durable execution (async `Invoke` with the alchemy payload |
| 163 | * envelope). Returns immediately; the execution progresses through |
| 164 | * checkpointed re-invocations. |
| 165 | */ |
| 166 | start( |
| 167 | options?: DurableStartOptions<Input>, |
| 168 | ): Effect.Effect<DurableExecutionRef, Lambda.InvokeError>; |
| 169 | /** Fetch the execution's status/result. */ |
| 170 | get( |
| 171 | executionArn: string, |
| 172 | ): Effect.Effect< |
| 173 | Lambda.GetDurableExecutionResponse, |
| 174 | Lambda.GetDurableExecutionError |
| 175 | >; |
| 176 | /** List executions of this function, optionally filtered by name/status. */ |
| 177 | list(options?: { |
| 178 | name?: string; |
| 179 | statuses?: Lambda.ExecutionStatus[]; |
| 180 | }): Effect.Effect< |
| 181 | Lambda.ListDurableExecutionsByFunctionResponse, |
| 182 | Lambda.ListDurableExecutionsByFunctionError |
| 183 | >; |
| 184 | /** Stop a running execution. */ |
| 185 | stop( |
| 186 | executionArn: string, |
| 187 | error?: Lambda.ErrorObject, |
| 188 | ): Effect.Effect< |
| 189 | Lambda.StopDurableExecutionResponse, |
| 190 | Lambda.StopDurableExecutionError |
| 191 | >; |
| 192 | /** Complete a `Durable.waitForCallback` from the outside. */ |
| 193 | sendCallbackSuccess( |
| 194 | callbackId: string, |
| 195 | result?: unknown, |
| 196 | ): Effect.Effect<void, Lambda.SendDurableExecutionCallbackSuccessError>; |
| 197 | sendCallbackFailure( |
| 198 | callbackId: string, |
| 199 | error?: Lambda.ErrorObject, |
| 200 | ): Effect.Effect<void, Lambda.SendDurableExecutionCallbackFailureError>; |
| 201 | sendCallbackHeartbeat( |
| 202 | callbackId: string, |
| 203 | ): Effect.Effect<void, Lambda.SendDurableExecutionCallbackHeartbeatError>; |
| 204 | } |
| 205 | |
| 206 | /** |
| 207 | * The value produced by `yield* MyDurableFunction`: the typed |
| 208 | * {@link DurableFunctionHandle} plus references to the underlying |
| 209 | * {@link Function} resource and its key attributes. |
| 210 | */ |
| 211 | export interface DurableFunction< |
| 212 | Input = unknown, |
| 213 | Result = unknown, |
| 214 | > extends DurableFunctionHandle<Input, Result> { |
| 215 | /** The underlying Lambda {@link Function} resource owned by this wrapper. */ |
| 216 | function: Function; |
| 217 | /** Physical name of the underlying Lambda function. */ |
| 218 | functionName: Function["functionName"]; |
| 219 | /** ARN of the underlying Lambda function. */ |
| 220 | functionArn: Function["functionArn"]; |
| 221 | } |
| 222 | |
| 223 | /** |
| 224 | * Inside a DurableFunction's init phase, resolves the function's own |
| 225 | * {@link DurableFunctionHandle} (e.g. for chained self-starts). Also what |
| 226 | * `yield* AWS.Lambda.DurableFunction` (the bare namespace value) resolves. |
| 227 | */ |
| 228 | export class DurableFunctionScope extends Context.Service< |
| 229 | DurableFunctionScope, |
| 230 | DurableFunctionHandle |
| 231 | >()("AWS.Lambda.DurableFunctionScope") {} |
| 232 | |
| 233 | export interface DurableFunctionClass { |
| 234 | <_Self>(): { |
| 235 | <Input = unknown, Result = unknown, PropsReq = never, InitReq = never>( |
| 236 | id: string, |
| 237 | props: |
| 238 | | InputProps<DurableFunctionProps> |
| 239 | | Effect.Effect< |
| 240 | InputProps<DurableFunctionProps>, |
| 241 | ConfigError, |
| 242 | PropsReq |
| 243 | >, |
| 244 | impl: Effect.Effect< |
| 245 | DurableFunctionImpl<Input, Result>, |
| 246 | ConfigError, |
| 247 | InitReq |
| 248 | >, |
| 249 | ): Effect.Effect< |
| 250 | DurableFunction<Input, Result>, |
| 251 | never, |
| 252 | | Function["Providers"] |
| 253 | | Exclude<PropsReq | InitReq, DurableFunctionInitServices> |
| 254 | > & { |
| 255 | new (_: never): DurableFunctionImpl<Input, Result>; |
| 256 | }; |
| 257 | <const Id extends string>( |
| 258 | id: Id, |
| 259 | ): Effect.Effect<DurableFunction, never, Function["Providers"]> & { |
| 260 | make< |
| 261 | Input = unknown, |
| 262 | Result = unknown, |
| 263 | PropsReq = never, |
| 264 | InitReq = never, |
| 265 | >( |
| 266 | props: |
| 267 | | InputProps<DurableFunctionProps> |
| 268 | | Effect.Effect< |
| 269 | InputProps<DurableFunctionProps>, |
| 270 | ConfigError, |
| 271 | PropsReq |
| 272 | >, |
| 273 | impl: Effect.Effect< |
| 274 | DurableFunctionImpl<Input, Result>, |
| 275 | ConfigError, |
| 276 | InitReq |
| 277 | >, |
| 278 | ): Layer.Layer< |
| 279 | _Self, |
| 280 | never, |
| 281 | | Function["Providers"] |
| 282 | | Exclude<PropsReq | InitReq, DurableFunctionInitServices> |
| 283 | >; |
| 284 | new (_: never): {}; |
| 285 | }; |
| 286 | }; |
| 287 | <Input = unknown, Result = unknown, PropsReq = never, InitReq = never>( |
| 288 | id: string, |
| 289 | props: |
| 290 | | InputProps<DurableFunctionProps> |
| 291 | | Effect.Effect<InputProps<DurableFunctionProps>, ConfigError, PropsReq>, |
| 292 | impl: Effect.Effect< |
| 293 | DurableFunctionImpl<Input, Result>, |
| 294 | ConfigError, |
| 295 | InitReq |
| 296 | >, |
| 297 | ): Effect.Effect< |
| 298 | DurableFunction<Input, Result>, |
| 299 | never, |
| 300 | | Function["Providers"] |
| 301 | | Exclude<PropsReq | InitReq, DurableFunctionInitServices> |
| 302 | >; |
| 303 | } |
| 304 | |
| 305 | /** |
| 306 | * Where the composed init stashes the {@link DurableFunction} value on the |
| 307 | * owned Function instance so `yield*` of any authoring form can produce it. |
| 308 | * A symbol key passes through the Resource proxy untouched (string props |
| 309 | * fabricate `Output.PropExpr` accessors). |
| 310 | */ |
| 311 | const DurableHandleKey = Symbol.for("alchemy/AWS.Lambda.DurableFunction"); |
| 312 | |
| 313 | /** |
| 314 | * Vendor the Durable Execution SDK into the artifact. `build.install` roots |
| 315 | * are excluded from the bundle and npm-installed into the zip targeting the |
| 316 | * function's architecture, so this single entry both externalizes the SDK |
| 317 | * (the bridge dynamic-imports it at runtime) and ships it. Respects an |
| 318 | * explicit user entry (e.g. a pinned version). |
| 319 | */ |
| 320 | const withDurableSdkInstall = ( |
| 321 | install: PackageInstall | undefined, |
| 322 | ): PackageInstall => { |
| 323 | if (install === undefined) { |
| 324 | return [DURABLE_SDK_MODULE]; |
| 325 | } |
| 326 | if (Array.isArray(install)) { |
| 327 | return install.includes(DURABLE_SDK_MODULE) |
| 328 | ? install |
| 329 | : [...install, DURABLE_SDK_MODULE]; |
| 330 | } |
| 331 | const record = install as Readonly<Record<string, string>>; |
| 332 | return DURABLE_SDK_MODULE in record |
| 333 | ? record |
| 334 | : { ...record, [DURABLE_SDK_MODULE]: "*" }; |
| 335 | }; |
| 336 | |
| 337 | /** |
| 338 | * Lower DurableFunction props onto the base Function's props: split off the |
| 339 | * DurableConfig knobs into the internal wire-level `durableConfig` channel, |
| 340 | * disable the Function URL (durable invocations are the only surface), and |
| 341 | * vendor the Durable Execution SDK. |
| 342 | */ |
| 343 | const mapDurableProps = (props: DurableFunctionProps): FunctionProps => { |
| 344 | const { executionTimeout, retentionPeriod, build, ...rest } = |
| 345 | props ?? ({} as DurableFunctionProps); |
| 346 | const executionTimeoutSeconds = toSeconds(executionTimeout); |
| 347 | const retentionPeriodDays = toWireDays(retentionPeriod); |
| 348 | return { |
| 349 | ...rest, |
| 350 | // Every invocation of a DurableConfig'd function arrives as the durable |
| 351 | // envelope — a Function URL could never be served. |
| 352 | functionUrl: false, |
| 353 | build: { |
| 354 | ...build, |
| 355 | install: withDurableSdkInstall(build?.install), |
| 356 | }, |
| 357 | durableConfig: { |
| 358 | ...(executionTimeoutSeconds !== undefined |
| 359 | ? { ExecutionTimeout: executionTimeoutSeconds } |
| 360 | : {}), |
| 361 | ...(retentionPeriodDays !== undefined |
| 362 | ? { RetentionPeriodInDays: retentionPeriodDays } |
| 363 | : {}), |
| 364 | }, |
| 365 | }; |
| 366 | }; |
| 367 | |
| 368 | const mapDurablePropsInput = (props: unknown) => |
| 369 | Effect.isEffect(props) |
| 370 | ? Effect.map(props as Effect.Effect<DurableFunctionProps>, mapDurableProps) |
| 371 | : mapDurableProps(props as DurableFunctionProps); |
| 372 | |
| 373 | const resolveDurableHandle = (id: string) => (instance: unknown) => { |
| 374 | const handle = (instance as Record<symbol, unknown> | undefined)?.[ |
| 375 | DurableHandleKey |
| 376 | ]; |
| 377 | return handle !== undefined |
| 378 | ? Effect.succeed(handle as DurableFunction<any, any>) |
| 379 | : Effect.die( |
| 380 | new Error( |
| 381 | `AWS.Lambda.DurableFunction<${id}> has no durable handle — provide ` + |
| 382 | `its implementation (\`${id}.make(props, impl)\` or an inline ` + |
| 383 | `form) before yielding it.`, |
| 384 | ), |
| 385 | ); |
| 386 | }; |
| 387 | |
| 388 | /** |
| 389 | * Compose the user's orchestrator init effect into the owned Function's init |
| 390 | * effect: resolve the durable management-plane clients, self-bind the |
| 391 | * checkpoint-protocol IAM onto the function's own execution role, register |
| 392 | * the durable listener on the owned entrypoint, and stash the typed handle |
| 393 | * for `yield* MyDurableFunction`. |
| 394 | */ |
| 395 | const composeDurableImpl = ( |
| 396 | name: string, |
| 397 | impl: Effect.Effect<DurableFunctionImpl<any, any>, any, any>, |
| 398 | ): Effect.Effect<void, any, any> => |
| 399 | Effect.gen(function* () { |
| 400 | // Self: the Function resource this wrapper owns (the Platform machinery |
| 401 | // provides `Function.Self` during its own init). |
| 402 | const host = yield* Function; |
| 403 | |
| 404 | // Resolve the distilled operations once at init — they close over the |
| 405 | // ambient Credentials/Region/HttpClient so the handle's runtime |
| 406 | // callables need no cloud services of their own. |
| 407 | const invoke = yield* Lambda.invoke; |
| 408 | const getDurableExecution = yield* Lambda.getDurableExecution; |
| 409 | const listDurableExecutionsByFunction = |
| 410 | yield* Lambda.listDurableExecutionsByFunction; |
| 411 | const stopDurableExecution = yield* Lambda.stopDurableExecution; |
| 412 | const sendCallbackSuccess = |
| 413 | yield* Lambda.sendDurableExecutionCallbackSuccess; |
| 414 | const sendCallbackFailure = |
| 415 | yield* Lambda.sendDurableExecutionCallbackFailure; |
| 416 | const sendCallbackHeartbeat = |
| 417 | yield* Lambda.sendDurableExecutionCallbackHeartbeat; |
| 418 | |
| 419 | // Capture the function-name Output WITHOUT resolving it. This is a |
| 420 | // self-reference — `host` is the very Function this wrapper's init is |
| 421 | // building — and `functionName`'s Output source only registers during |
| 422 | // that function's own reconcile. Yielding it here (init/plan time) would |
| 423 | // block forever (the reconcile that produces it waits on this init to |
| 424 | // finish). Resolve it lazily inside the runtime callables instead, the |
| 425 | // same posture as `InvokeFunctionHttp`. |
| 426 | const FunctionName = host.functionName; |
| 427 | |
| 428 | if (!globalThis.__ALCHEMY_RUNTIME__) { |
| 429 | // Self-binding: the statements land on this function's own execution |
| 430 | // role through the standard bindings channel (precreate makes the stub, |
| 431 | // reconcile applies the collected bindings). |
| 432 | yield* host.bind`Allow(${host}, AWS.Lambda.DurableFunction(${name}))`({ |
| 433 | policyStatements: [ |
| 434 | // The checkpoint/replay protocol the Durable Execution SDK |
| 435 | // drives from inside the handler. |
| 436 | { |
| 437 | Effect: "Allow", |
| 438 | Action: [ |
| 439 | "lambda:CheckpointDurableExecution", |
| 440 | "lambda:GetDurableExecutionState", |
| 441 | ], |
| 442 | Resource: [ |
| 443 | host.functionArn, |
| 444 | Output.interpolate`${host.functionArn}:*`, |
| 445 | ], |
| 446 | }, |
| 447 | // Self-start (handle.start) and chained self-invokes. |
| 448 | { |
| 449 | Effect: "Allow", |
| 450 | Action: ["lambda:InvokeFunction"], |
| 451 | Resource: [ |
| 452 | host.functionArn, |
| 453 | Output.interpolate`${host.functionArn}:*`, |
| 454 | ], |
| 455 | }, |
| 456 | // Management-plane handle methods. Durable execution ARNs are |
| 457 | // a distinct resource shape from the function ARN, so these |
| 458 | // stay account-wide for now. |
| 459 | { |
| 460 | Effect: "Allow", |
| 461 | Action: [ |
| 462 | "lambda:GetDurableExecution", |
| 463 | "lambda:GetDurableExecutionHistory", |
| 464 | "lambda:ListDurableExecutionsByFunction", |
| 465 | "lambda:StopDurableExecution", |
| 466 | "lambda:SendDurableExecutionCallbackSuccess", |
| 467 | "lambda:SendDurableExecutionCallbackFailure", |
| 468 | "lambda:SendDurableExecutionCallbackHeartbeat", |
| 469 | ], |
| 470 | Resource: ["*"], |
| 471 | }, |
| 472 | ], |
| 473 | }); |
| 474 | } |
| 475 | |
| 476 | const handle: DurableFunctionHandle<any, any> = { |
| 477 | Type: TypeId, |
| 478 | name, |
| 479 | start: (options) => |
| 480 | Effect.gen(function* () { |
| 481 | // `FunctionName` is the host's own unresolved Output (captured raw at |
| 482 | // init to avoid the plan-time self-reference deadlock). Resolve both |
| 483 | // stages — Output → Accessor → string — lazily at runtime. |
| 484 | const functionName = yield* yield* FunctionName; |
| 485 | const response = yield* invoke({ |
| 486 | FunctionName: functionName, |
| 487 | InvocationType: "Event", |
| 488 | DurableExecutionName: options?.name, |
| 489 | Qualifier: options?.qualifier, |
| 490 | Payload: encodeDurableEnvelope(name, options?.params), |
| 491 | }); |
| 492 | return { |
| 493 | executionArn: response.DurableExecutionArn, |
| 494 | statusCode: response.StatusCode, |
| 495 | }; |
| 496 | }), |
| 497 | get: (executionArn) => |
| 498 | getDurableExecution({ DurableExecutionArn: executionArn }), |
| 499 | list: (options) => |
| 500 | Effect.gen(function* () { |
| 501 | const functionName = yield* yield* FunctionName; |
| 502 | return yield* listDurableExecutionsByFunction({ |
| 503 | FunctionName: functionName, |
| 504 | DurableExecutionName: options?.name, |
| 505 | Statuses: options?.statuses, |
| 506 | }); |
| 507 | }), |
| 508 | stop: (executionArn, error) => |
| 509 | stopDurableExecution({ |
| 510 | DurableExecutionArn: executionArn, |
| 511 | Error: error, |
| 512 | }), |
| 513 | sendCallbackSuccess: (callbackId, result) => |
| 514 | sendCallbackSuccess({ |
| 515 | CallbackId: callbackId, |
| 516 | Result: result === undefined ? undefined : JSON.stringify(result), |
| 517 | }).pipe(Effect.asVoid), |
| 518 | sendCallbackFailure: (callbackId, error) => |
| 519 | sendCallbackFailure({ |
| 520 | CallbackId: callbackId, |
| 521 | Error: error, |
| 522 | }).pipe(Effect.asVoid), |
| 523 | sendCallbackHeartbeat: (callbackId) => |
| 524 | sendCallbackHeartbeat({ CallbackId: callbackId }).pipe(Effect.asVoid), |
| 525 | }; |
| 526 | |
| 527 | // Resolve the body function. Bindings resolved in the impl's init close |
| 528 | // over their services; the returned closure's only leftover requirements |
| 529 | // are DurableRunServices, provided per invocation by the bridge. |
| 530 | const fn = yield* ( |
| 531 | impl as Effect.Effect<DurableFunctionImpl<any, any>> |
| 532 | ).pipe(Effect.provideService(DurableFunctionScope, handle)); |
| 533 | |
| 534 | yield* host.listen( |
| 535 | makeDurableListener({ |
| 536 | name, |
| 537 | run: (input) => fn(input) as Effect.Effect<unknown>, |
| 538 | }), |
| 539 | ); |
| 540 | |
| 541 | // Expose the full DurableFunction value (handle + resource refs) to |
| 542 | // `yield* MyDurableFunction` for every authoring form. |
| 543 | (host as unknown as Record<symbol, unknown>)[DurableHandleKey] = { |
| 544 | ...handle, |
| 545 | function: host, |
| 546 | functionName: host.functionName, |
| 547 | functionArn: host.functionArn, |
| 548 | } satisfies DurableFunction<any, any>; |
| 549 | }); |
| 550 | |
| 551 | /** |
| 552 | * An AWS Lambda Durable Function — a code-first, replay-based orchestrator |
| 553 | * that IS a durable Lambda Function. `AWS.Lambda.DurableFunction` is a |
| 554 | * wrapper of {@link Function}: it owns the underlying Lambda function, |
| 555 | * configures its `DurableConfig` at `CreateFunction` (durability is a |
| 556 | * create-time property — a DurableFunction is always durable), registers the |
| 557 | * durable-execution listener on the owned entrypoint, self-binds the |
| 558 | * checkpoint-protocol IAM (`lambda:CheckpointDurableExecution`, |
| 559 | * `lambda:GetDurableExecutionState`) onto the execution role, and vendors the |
| 560 | * open-source `@aws/durable-execution-sdk-js` into the artifact (install it |
| 561 | * in your project: `npm i @aws/durable-execution-sdk-js`). |
| 562 | * |
| 563 | * Executions progress by checkpoint + replay: a `Durable.sleep` or |
| 564 | * `Durable.waitForCallback` suspends the execution with zero compute billed |
| 565 | * until Lambda re-invokes the same function version to resume, and completed |
| 566 | * `Durable.step`s replay from the checkpoint log without re-executing. |
| 567 | * |
| 568 | * Every invocation of a durable function arrives as the durable-execution |
| 569 | * envelope, so a DurableFunction has no HTTP surface (`functionUrl` is disabled) — |
| 570 | * it does one thing: run durable orchestrations. Reusing a logical id |
| 571 | * between a plain `Function` and a `DurableFunction` replaces the physical |
| 572 | * function (DurableConfig cannot be flipped in place). |
| 573 | * |
| 574 | * ### Defining a Durable Function |
| 575 | * **Example:** Class form with steps and a durable sleep |
| 576 | * ```typescript |
| 577 | * export class OrderFlow extends AWS.Lambda.DurableFunction<OrderFlow>()( |
| 578 | * "OrderFlow", |
| 579 | * { |
| 580 | * main: import.meta.url, |
| 581 | * executionTimeout: "1 hour", |
| 582 | * retentionPeriod: "7 days", |
| 583 | * }, |
| 584 | * Effect.gen(function* () { |
| 585 | * // init: resolve typed binding clients (IAM lands on this function's role) |
| 586 | * const putItem = yield* AWS.DynamoDB.PutItem(table); |
| 587 | * |
| 588 | * return Effect.fn(function* (input: { orderId: string }) { |
| 589 | * const reserved = yield* AWS.Lambda.Durable.step( |
| 590 | * "reserve", |
| 591 | * putItem({ Item: { pk: { S: input.orderId } } }).pipe(Effect.orDie), |
| 592 | * { retry: { limit: 3, delay: "5 seconds" } }, |
| 593 | * ); |
| 594 | * yield* AWS.Lambda.Durable.sleep("cooldown", "10 minutes"); |
| 595 | * return { orderId: input.orderId, reserved }; |
| 596 | * }); |
| 597 | * }), |
| 598 | * ) {} |
| 599 | * ``` |
| 600 | * |
| 601 | * **Example:** Tag + default export (entrypoint form) |
| 602 | * ```typescript |
| 603 | * // order-flow.ts — `main` points at this module |
| 604 | * export class OrderFlow extends AWS.Lambda.DurableFunction<OrderFlow>()( |
| 605 | * "OrderFlow", |
| 606 | * ) {} |
| 607 | * |
| 608 | * export default OrderFlow.make( |
| 609 | * { main: import.meta.url, executionTimeout: "1 hour" }, |
| 610 | * Effect.gen(function* () { |
| 611 | * return Effect.fn(function* (input: { orderId: string }) { |
| 612 | * return yield* AWS.Lambda.Durable.step("work", doWork(input)); |
| 613 | * }); |
| 614 | * }), |
| 615 | * ); |
| 616 | * ``` |
| 617 | * |
| 618 | * **Example:** Inline effect form |
| 619 | * ```typescript |
| 620 | * const flow = yield* AWS.Lambda.DurableFunction( |
| 621 | * "OrderFlow", |
| 622 | * { main: "./src/order-flow.ts" }, |
| 623 | * Effect.gen(function* () { |
| 624 | * return Effect.fn(function* (input: { orderId: string }) { |
| 625 | * return yield* AWS.Lambda.Durable.step("work", doWork(input)); |
| 626 | * }); |
| 627 | * }), |
| 628 | * ); |
| 629 | * ``` |
| 630 | * |
| 631 | * ### Starting and Monitoring Executions |
| 632 | * **Example:** Starting an execution |
| 633 | * ```typescript |
| 634 | * const orders = yield* OrderFlow; |
| 635 | * const ref = yield* orders.start({ |
| 636 | * name: "order-123", // idempotent start |
| 637 | * params: { orderId: "123" }, |
| 638 | * qualifier: "live", |
| 639 | * }); |
| 640 | * ``` |
| 641 | * |
| 642 | * **Example:** Publish and promote for production |
| 643 | * ```typescript |
| 644 | * const orders = yield* OrderFlow; |
| 645 | * const version = yield* AWS.Lambda.Version("OrderFlowVersion", { |
| 646 | * function: orders.function, |
| 647 | * }); |
| 648 | * yield* AWS.Lambda.Alias("OrderFlowLive", { |
| 649 | * version, |
| 650 | * aliasName: "live", |
| 651 | * }); |
| 652 | * |
| 653 | * const ref = yield* orders.start({ |
| 654 | * name: "order-123", |
| 655 | * params: { orderId: "123" }, |
| 656 | * qualifier: "live", |
| 657 | * }); |
| 658 | * ``` |
| 659 | * |
| 660 | * **Example:** Checking status |
| 661 | * ```typescript |
| 662 | * const execution = yield* orders.get(ref.executionArn!); |
| 663 | * // execution.Status: "RUNNING" | "SUCCEEDED" | "FAILED" | ... |
| 664 | * ``` |
| 665 | * |
| 666 | * ### External Callbacks |
| 667 | * **Example:** Waiting for an approval |
| 668 | * ```typescript |
| 669 | * const approval = yield* AWS.Lambda.Durable.waitForCallback<{ ok: boolean }>( |
| 670 | * "approve", |
| 671 | * (callbackId) => storeCallbackId(callbackId), |
| 672 | * { timeout: "1 day" }, |
| 673 | * ); |
| 674 | * ``` |
| 675 | * |
| 676 | * @resource |
| 677 | */ |
| 678 | export const DurableFunction: DurableFunctionClass = taggedFunction( |
| 679 | DurableFunctionScope, |
| 680 | (( |
| 681 | ...args: |
| 682 | | [] |
| 683 | | [id: string] |
| 684 | | [id: string, props: unknown, impl: Effect.Effect<any, any, any>] |
| 685 | ) => { |
| 686 | if (args.length === 0) { |
| 687 | // `DurableFunction<Self>()` — the binder for the class/tag forms. |
| 688 | return DurableFunction; |
| 689 | } |
| 690 | const [id, props, impl] = args; |
| 691 | if (impl === undefined) { |
| 692 | // Tag form: `class OrderFlow extends DurableFunction<OrderFlow>()("OrderFlow") {}` |
| 693 | // + `export default OrderFlow.make(props, impl)`. |
| 694 | const fnTag = (Function as any)()(id); |
| 695 | return Object.assign( |
| 696 | function (props: unknown, impl: Effect.Effect<any, any, any>) { |
| 697 | return Effect.flatMap( |
| 698 | fnTag(mapDurablePropsInput(props), composeDurableImpl(id, impl)), |
| 699 | resolveDurableHandle(id), |
| 700 | ); |
| 701 | }, |
| 702 | fnTag, |
| 703 | { |
| 704 | make: (props: unknown, impl: Effect.Effect<any, any, any>) => |
| 705 | fnTag.make( |
| 706 | mapDurablePropsInput(props), |
| 707 | composeDurableImpl(id, impl), |
| 708 | ), |
| 709 | }, |
| 710 | Effectable.Prototype({ |
| 711 | label: `${TypeId}<${id}>`, |
| 712 | evaluate: () => |
| 713 | Effect.flatMap( |
| 714 | Effect.serviceOption(fnTag.Self), |
| 715 | Option.match({ |
| 716 | onNone: () => resolveDurableHandle(id)(undefined), |
| 717 | onSome: resolveDurableHandle(id), |
| 718 | }), |
| 719 | ), |
| 720 | }), |
| 721 | ); |
| 722 | } |
| 723 | // Inline forms (eager effect / inline class): delegate to the Function |
| 724 | // platform with lowered props and the composed durable init. |
| 725 | return effectClass( |
| 726 | Effect.flatMap( |
| 727 | (Function as any)( |
| 728 | id, |
| 729 | mapDurablePropsInput(props), |
| 730 | composeDurableImpl(id, impl), |
| 731 | ) as Effect.Effect<unknown>, |
| 732 | resolveDurableHandle(id), |
| 733 | ), |
| 734 | ); |
| 735 | }) as any, |
| 736 | ); |