import * as Lambda from "@distilled.cloud/aws/lambda"; import type { ConfigError } from "effect/Config"; import * as Context from "effect/Context"; import type * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Effectable from "effect/Effectable"; import type * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import type { Scope } from "effect/Scope"; import type { PackageInstall } from "../../Bundle/InstalledPackages.ts"; import type { InputProps } from "../../Input.ts"; import * as Output from "../../Output.ts"; import type { PlatformServices } from "../../Platform.ts"; import { toSeconds, toWireDays } from "../../Util/Duration.ts"; import { effectClass, taggedFunction } from "../../Util/effect.ts"; import type { DurableExecutionContext, DurableStep } from "./Durable.ts"; import { DURABLE_SDK_MODULE, encodeDurableEnvelope, makeDurableListener, } from "./DurableBridge.ts"; import { Function, type FunctionProps, type FunctionServices, type HandlerContext, } from "./Function.ts"; type TypeId = "AWS.Lambda.DurableFunction"; const TypeId = "AWS.Lambda.DurableFunction" as const; /** * The services available inside a durable function's run body. * * The bridge provides all of them per durable invocation: `DurableStep` * powers `Durable.step`/`Durable.sleep`/`Durable.waitForCallback`, * `DurableExecutionContext` carries the execution ARN, `HandlerContext` is * the raw `lambda.Context`, and a fresh `Scope` scopes per-invocation * resources. * * Deliberately narrow: cloud clients (`Credentials`/`Region`-requiring * effects) are NOT provided to the body directly — resolve typed binding * clients in the init phase and call them inside `Durable.step`, which is * exactly the determinism law the replay model requires. */ export type DurableRunServices = | DurableStep | DurableExecutionContext | HandlerContext | Scope; /** * A durable function implementation: a function from a typed `Input` payload * to an Effect producing the execution's `Result`. Code outside * `Durable.step` re-runs on every replay and must be deterministic. */ export type DurableFunctionImpl = ( input: Input, ) => Effect.Effect; /** * Services satisfied by the DurableFunction's own machinery (or the engine) * and therefore excluded from the caller-facing requirements of the returned * Effect/Layer. */ export type DurableFunctionInitServices = | FunctionServices | PlatformServices | Function | DurableRunServices; /** * Properties of an {@link DurableFunction | AWS.Lambda.DurableFunction}. * * A DurableFunction accepts every {@link FunctionProps | Function prop} * except `functionUrl` (every invocation of a durable function arrives as the * durable-execution envelope — there is no HTTP surface), plus the * `DurableConfig` tuning knobs below. */ export interface DurableFunctionProps extends Omit< FunctionProps, "functionUrl" | "durableConfig" > { /** * Maximum total duration of a durable execution, from start to terminal * state (minimum 60 seconds, maximum 1 year). Rounded up to whole seconds. * @default 24 hours (AWS default) */ executionTimeout?: Duration.Input; /** * How long completed execution history is retained (e.g. `"7 days"`; * 1–90 days). Rounded to whole days on the wire. * @default "14 days" (AWS default) */ retentionPeriod?: Duration.Input; } /** * Options for starting a durable execution. */ export interface DurableStartOptions { /** * Idempotent execution name (`DurableExecutionName`): starting again with * the same name and payload reattaches to the existing execution; the same * name with a different payload fails with * `DurableExecutionAlreadyStartedException`. */ name?: string; /** The typed input payload delivered to the durable function body. */ params?: Input; /** * Function version or alias to pin the execution to. Durable executions * replay against the version they started on. `$LATEST` is suitable for * disposable development; production starts should target an immutable * numbered {@link Version} or a stable {@link Alias}. */ qualifier?: string; } /** * A started durable execution reference. */ export interface DurableExecutionRef { /** ARN of the durable execution (when returned by the Invoke response). */ executionArn: string | undefined; statusCode: number | undefined; } /** * The typed durable-execution handle: start, inspect, stop, and complete * callbacks of durable executions of this function. Returned by * `yield* MyDurableFunction` (as part of {@link DurableFunction}) and, inside * the function's own init phase, by {@link DurableFunctionScope}. */ export interface DurableFunctionHandle { Type: TypeId; name: string; /** @internal phantom */ Result?: Result; /** * Start a durable execution (async `Invoke` with the alchemy payload * envelope). Returns immediately; the execution progresses through * checkpointed re-invocations. */ start( options?: DurableStartOptions, ): Effect.Effect; /** Fetch the execution's status/result. */ get( executionArn: string, ): Effect.Effect< Lambda.GetDurableExecutionResponse, Lambda.GetDurableExecutionError >; /** List executions of this function, optionally filtered by name/status. */ list(options?: { name?: string; statuses?: Lambda.ExecutionStatus[]; }): Effect.Effect< Lambda.ListDurableExecutionsByFunctionResponse, Lambda.ListDurableExecutionsByFunctionError >; /** Stop a running execution. */ stop( executionArn: string, error?: Lambda.ErrorObject, ): Effect.Effect< Lambda.StopDurableExecutionResponse, Lambda.StopDurableExecutionError >; /** Complete a `Durable.waitForCallback` from the outside. */ sendCallbackSuccess( callbackId: string, result?: unknown, ): Effect.Effect; sendCallbackFailure( callbackId: string, error?: Lambda.ErrorObject, ): Effect.Effect; sendCallbackHeartbeat( callbackId: string, ): Effect.Effect; } /** * The value produced by `yield* MyDurableFunction`: the typed * {@link DurableFunctionHandle} plus references to the underlying * {@link Function} resource and its key attributes. */ export interface DurableFunction< Input = unknown, Result = unknown, > extends DurableFunctionHandle { /** The underlying Lambda {@link Function} resource owned by this wrapper. */ function: Function; /** Physical name of the underlying Lambda function. */ functionName: Function["functionName"]; /** ARN of the underlying Lambda function. */ functionArn: Function["functionArn"]; } /** * Inside a DurableFunction's init phase, resolves the function's own * {@link DurableFunctionHandle} (e.g. for chained self-starts). Also what * `yield* AWS.Lambda.DurableFunction` (the bare namespace value) resolves. */ export class DurableFunctionScope extends Context.Service< DurableFunctionScope, DurableFunctionHandle >()("AWS.Lambda.DurableFunctionScope") {} export interface DurableFunctionClass { <_Self>(): { ( id: string, props: | InputProps | Effect.Effect< InputProps, ConfigError, PropsReq >, impl: Effect.Effect< DurableFunctionImpl, ConfigError, InitReq >, ): Effect.Effect< DurableFunction, never, | Function["Providers"] | Exclude > & { new (_: never): DurableFunctionImpl; }; ( id: Id, ): Effect.Effect & { make< Input = unknown, Result = unknown, PropsReq = never, InitReq = never, >( props: | InputProps | Effect.Effect< InputProps, ConfigError, PropsReq >, impl: Effect.Effect< DurableFunctionImpl, ConfigError, InitReq >, ): Layer.Layer< _Self, never, | Function["Providers"] | Exclude >; new (_: never): {}; }; }; ( id: string, props: | InputProps | Effect.Effect, ConfigError, PropsReq>, impl: Effect.Effect< DurableFunctionImpl, ConfigError, InitReq >, ): Effect.Effect< DurableFunction, never, | Function["Providers"] | Exclude >; } /** * Where the composed init stashes the {@link DurableFunction} value on the * owned Function instance so `yield*` of any authoring form can produce it. * A symbol key passes through the Resource proxy untouched (string props * fabricate `Output.PropExpr` accessors). */ const DurableHandleKey = Symbol.for("alchemy/AWS.Lambda.DurableFunction"); /** * Vendor the Durable Execution SDK into the artifact. `build.install` roots * are excluded from the bundle and npm-installed into the zip targeting the * function's architecture, so this single entry both externalizes the SDK * (the bridge dynamic-imports it at runtime) and ships it. Respects an * explicit user entry (e.g. a pinned version). */ const withDurableSdkInstall = ( install: PackageInstall | undefined, ): PackageInstall => { if (install === undefined) { return [DURABLE_SDK_MODULE]; } if (Array.isArray(install)) { return install.includes(DURABLE_SDK_MODULE) ? install : [...install, DURABLE_SDK_MODULE]; } const record = install as Readonly>; return DURABLE_SDK_MODULE in record ? record : { ...record, [DURABLE_SDK_MODULE]: "*" }; }; /** * Lower DurableFunction props onto the base Function's props: split off the * DurableConfig knobs into the internal wire-level `durableConfig` channel, * disable the Function URL (durable invocations are the only surface), and * vendor the Durable Execution SDK. */ const mapDurableProps = (props: DurableFunctionProps): FunctionProps => { const { executionTimeout, retentionPeriod, build, ...rest } = props ?? ({} as DurableFunctionProps); const executionTimeoutSeconds = toSeconds(executionTimeout); const retentionPeriodDays = toWireDays(retentionPeriod); return { ...rest, // Every invocation of a DurableConfig'd function arrives as the durable // envelope — a Function URL could never be served. functionUrl: false, build: { ...build, install: withDurableSdkInstall(build?.install), }, durableConfig: { ...(executionTimeoutSeconds !== undefined ? { ExecutionTimeout: executionTimeoutSeconds } : {}), ...(retentionPeriodDays !== undefined ? { RetentionPeriodInDays: retentionPeriodDays } : {}), }, }; }; const mapDurablePropsInput = (props: unknown) => Effect.isEffect(props) ? Effect.map(props as Effect.Effect, mapDurableProps) : mapDurableProps(props as DurableFunctionProps); const resolveDurableHandle = (id: string) => (instance: unknown) => { const handle = (instance as Record | undefined)?.[ DurableHandleKey ]; return handle !== undefined ? Effect.succeed(handle as DurableFunction) : Effect.die( new Error( `AWS.Lambda.DurableFunction<${id}> has no durable handle — provide ` + `its implementation (\`${id}.make(props, impl)\` or an inline ` + `form) before yielding it.`, ), ); }; /** * Compose the user's orchestrator init effect into the owned Function's init * effect: resolve the durable management-plane clients, self-bind the * checkpoint-protocol IAM onto the function's own execution role, register * the durable listener on the owned entrypoint, and stash the typed handle * for `yield* MyDurableFunction`. */ const composeDurableImpl = ( name: string, impl: Effect.Effect, any, any>, ): Effect.Effect => Effect.gen(function* () { // Self: the Function resource this wrapper owns (the Platform machinery // provides `Function.Self` during its own init). const host = yield* Function; // Resolve the distilled operations once at init — they close over the // ambient Credentials/Region/HttpClient so the handle's runtime // callables need no cloud services of their own. const invoke = yield* Lambda.invoke; const getDurableExecution = yield* Lambda.getDurableExecution; const listDurableExecutionsByFunction = yield* Lambda.listDurableExecutionsByFunction; const stopDurableExecution = yield* Lambda.stopDurableExecution; const sendCallbackSuccess = yield* Lambda.sendDurableExecutionCallbackSuccess; const sendCallbackFailure = yield* Lambda.sendDurableExecutionCallbackFailure; const sendCallbackHeartbeat = yield* Lambda.sendDurableExecutionCallbackHeartbeat; // Capture the function-name Output WITHOUT resolving it. This is a // self-reference — `host` is the very Function this wrapper's init is // building — and `functionName`'s Output source only registers during // that function's own reconcile. Yielding it here (init/plan time) would // block forever (the reconcile that produces it waits on this init to // finish). Resolve it lazily inside the runtime callables instead, the // same posture as `InvokeFunctionHttp`. const FunctionName = host.functionName; if (!globalThis.__ALCHEMY_RUNTIME__) { // Self-binding: the statements land on this function's own execution // role through the standard bindings channel (precreate makes the stub, // reconcile applies the collected bindings). yield* host.bind`Allow(${host}, AWS.Lambda.DurableFunction(${name}))`({ policyStatements: [ // The checkpoint/replay protocol the Durable Execution SDK // drives from inside the handler. { Effect: "Allow", Action: [ "lambda:CheckpointDurableExecution", "lambda:GetDurableExecutionState", ], Resource: [ host.functionArn, Output.interpolate`${host.functionArn}:*`, ], }, // Self-start (handle.start) and chained self-invokes. { Effect: "Allow", Action: ["lambda:InvokeFunction"], Resource: [ host.functionArn, Output.interpolate`${host.functionArn}:*`, ], }, // Management-plane handle methods. Durable execution ARNs are // a distinct resource shape from the function ARN, so these // stay account-wide for now. { Effect: "Allow", Action: [ "lambda:GetDurableExecution", "lambda:GetDurableExecutionHistory", "lambda:ListDurableExecutionsByFunction", "lambda:StopDurableExecution", "lambda:SendDurableExecutionCallbackSuccess", "lambda:SendDurableExecutionCallbackFailure", "lambda:SendDurableExecutionCallbackHeartbeat", ], Resource: ["*"], }, ], }); } const handle: DurableFunctionHandle = { Type: TypeId, name, start: (options) => Effect.gen(function* () { // `FunctionName` is the host's own unresolved Output (captured raw at // init to avoid the plan-time self-reference deadlock). Resolve both // stages — Output → Accessor → string — lazily at runtime. const functionName = yield* yield* FunctionName; const response = yield* invoke({ FunctionName: functionName, InvocationType: "Event", DurableExecutionName: options?.name, Qualifier: options?.qualifier, Payload: encodeDurableEnvelope(name, options?.params), }); return { executionArn: response.DurableExecutionArn, statusCode: response.StatusCode, }; }), get: (executionArn) => getDurableExecution({ DurableExecutionArn: executionArn }), list: (options) => Effect.gen(function* () { const functionName = yield* yield* FunctionName; return yield* listDurableExecutionsByFunction({ FunctionName: functionName, DurableExecutionName: options?.name, Statuses: options?.statuses, }); }), stop: (executionArn, error) => stopDurableExecution({ DurableExecutionArn: executionArn, Error: error, }), sendCallbackSuccess: (callbackId, result) => sendCallbackSuccess({ CallbackId: callbackId, Result: result === undefined ? undefined : JSON.stringify(result), }).pipe(Effect.asVoid), sendCallbackFailure: (callbackId, error) => sendCallbackFailure({ CallbackId: callbackId, Error: error, }).pipe(Effect.asVoid), sendCallbackHeartbeat: (callbackId) => sendCallbackHeartbeat({ CallbackId: callbackId }).pipe(Effect.asVoid), }; // Resolve the body function. Bindings resolved in the impl's init close // over their services; the returned closure's only leftover requirements // are DurableRunServices, provided per invocation by the bridge. const fn = yield* ( impl as Effect.Effect> ).pipe(Effect.provideService(DurableFunctionScope, handle)); yield* host.listen( makeDurableListener({ name, run: (input) => fn(input) as Effect.Effect, }), ); // Expose the full DurableFunction value (handle + resource refs) to // `yield* MyDurableFunction` for every authoring form. (host as unknown as Record)[DurableHandleKey] = { ...handle, function: host, functionName: host.functionName, functionArn: host.functionArn, } satisfies DurableFunction; }); /** * An AWS Lambda Durable Function — a code-first, replay-based orchestrator * that IS a durable Lambda Function. `AWS.Lambda.DurableFunction` is a * wrapper of {@link Function}: it owns the underlying Lambda function, * configures its `DurableConfig` at `CreateFunction` (durability is a * create-time property — a DurableFunction is always durable), registers the * durable-execution listener on the owned entrypoint, self-binds the * checkpoint-protocol IAM (`lambda:CheckpointDurableExecution`, * `lambda:GetDurableExecutionState`) onto the execution role, and vendors the * open-source `@aws/durable-execution-sdk-js` into the artifact (install it * in your project: `npm i @aws/durable-execution-sdk-js`). * * Executions progress by checkpoint + replay: a `Durable.sleep` or * `Durable.waitForCallback` suspends the execution with zero compute billed * until Lambda re-invokes the same function version to resume, and completed * `Durable.step`s replay from the checkpoint log without re-executing. * * Every invocation of a durable function arrives as the durable-execution * envelope, so a DurableFunction has no HTTP surface (`functionUrl` is disabled) — * it does one thing: run durable orchestrations. Reusing a logical id * between a plain `Function` and a `DurableFunction` replaces the physical * function (DurableConfig cannot be flipped in place). * * ### Defining a Durable Function * **Example:** Class form with steps and a durable sleep * ```typescript * export class OrderFlow extends AWS.Lambda.DurableFunction()( * "OrderFlow", * { * main: import.meta.url, * executionTimeout: "1 hour", * retentionPeriod: "7 days", * }, * Effect.gen(function* () { * // init: resolve typed binding clients (IAM lands on this function's role) * const putItem = yield* AWS.DynamoDB.PutItem(table); * * return Effect.fn(function* (input: { orderId: string }) { * const reserved = yield* AWS.Lambda.Durable.step( * "reserve", * putItem({ Item: { pk: { S: input.orderId } } }).pipe(Effect.orDie), * { retry: { limit: 3, delay: "5 seconds" } }, * ); * yield* AWS.Lambda.Durable.sleep("cooldown", "10 minutes"); * return { orderId: input.orderId, reserved }; * }); * }), * ) {} * ``` * * **Example:** Tag + default export (entrypoint form) * ```typescript * // order-flow.ts — `main` points at this module * export class OrderFlow extends AWS.Lambda.DurableFunction()( * "OrderFlow", * ) {} * * export default OrderFlow.make( * { main: import.meta.url, executionTimeout: "1 hour" }, * Effect.gen(function* () { * return Effect.fn(function* (input: { orderId: string }) { * return yield* AWS.Lambda.Durable.step("work", doWork(input)); * }); * }), * ); * ``` * * **Example:** Inline effect form * ```typescript * const flow = yield* AWS.Lambda.DurableFunction( * "OrderFlow", * { main: "./src/order-flow.ts" }, * Effect.gen(function* () { * return Effect.fn(function* (input: { orderId: string }) { * return yield* AWS.Lambda.Durable.step("work", doWork(input)); * }); * }), * ); * ``` * * ### Starting and Monitoring Executions * **Example:** Starting an execution * ```typescript * const orders = yield* OrderFlow; * const ref = yield* orders.start({ * name: "order-123", // idempotent start * params: { orderId: "123" }, * qualifier: "live", * }); * ``` * * **Example:** Publish and promote for production * ```typescript * const orders = yield* OrderFlow; * const version = yield* AWS.Lambda.Version("OrderFlowVersion", { * function: orders.function, * }); * yield* AWS.Lambda.Alias("OrderFlowLive", { * version, * aliasName: "live", * }); * * const ref = yield* orders.start({ * name: "order-123", * params: { orderId: "123" }, * qualifier: "live", * }); * ``` * * **Example:** Checking status * ```typescript * const execution = yield* orders.get(ref.executionArn!); * // execution.Status: "RUNNING" | "SUCCEEDED" | "FAILED" | ... * ``` * * ### External Callbacks * **Example:** Waiting for an approval * ```typescript * const approval = yield* AWS.Lambda.Durable.waitForCallback<{ ok: boolean }>( * "approve", * (callbackId) => storeCallbackId(callbackId), * { timeout: "1 day" }, * ); * ``` * * @resource */ export const DurableFunction: DurableFunctionClass = taggedFunction( DurableFunctionScope, (( ...args: | [] | [id: string] | [id: string, props: unknown, impl: Effect.Effect] ) => { if (args.length === 0) { // `DurableFunction()` — the binder for the class/tag forms. return DurableFunction; } const [id, props, impl] = args; if (impl === undefined) { // Tag form: `class OrderFlow extends DurableFunction()("OrderFlow") {}` // + `export default OrderFlow.make(props, impl)`. const fnTag = (Function as any)()(id); return Object.assign( function (props: unknown, impl: Effect.Effect) { return Effect.flatMap( fnTag(mapDurablePropsInput(props), composeDurableImpl(id, impl)), resolveDurableHandle(id), ); }, fnTag, { make: (props: unknown, impl: Effect.Effect) => fnTag.make( mapDurablePropsInput(props), composeDurableImpl(id, impl), ), }, Effectable.Prototype({ label: `${TypeId}<${id}>`, evaluate: () => Effect.flatMap( Effect.serviceOption(fnTag.Self), Option.match({ onNone: () => resolveDurableHandle(id)(undefined), onSome: resolveDurableHandle(id), }), ), }), ); } // Inline forms (eager effect / inline class): delegate to the Function // platform with lowered props and the composed durable init. return effectClass( Effect.flatMap( (Function as any)( id, mapDurablePropsInput(props), composeDurableImpl(id, impl), ) as Effect.Effect, resolveDurableHandle(id), ), ); }) as any, );