/** * `flow` — the one species of backend behavior. * * A Flow is a `do` body plus runtime options. Invoke contracts (`in` / `out` / * `errors` / `breaking`) are authored on the exposure (`http.*`, `call`, * `mcp.tool`) and stamped onto the FlowDef for Manifest / validation. * Triggers are attached by {@link on}; {@link call} is the call-only sugar. */ import type { Effects, FlowPlane, Slo } from "../manifest/types.ts"; import type { SchemaInput } from "../validation/standard-schema.ts"; import type { FxRetryOptions } from "./concurrency.ts"; import type { FlowFailure } from "./errors.ts"; import type { Fx } from "./fx.ts"; import type { HookFn, HookStage } from "./hooks.ts"; import type { PluginDef } from "./plugin.ts"; import type { Trigger } from "./triggers.ts"; export type { SchemaInput, StandardSchemaV1 } from "../validation/standard-schema.ts"; /** Result of a Standard Schema validate call (re-export shape). */ export type StandardSchemaResult = | { readonly value: Output; readonly issues?: undefined } | { readonly issues: ReadonlyArray<{ readonly message: string }> }; /** Map of declared boundary error names to their schemas. */ export type FlowErrorMap = Readonly>; /** * Body of a Flow. Receives validated input and the `fx` door. * * @typeParam I - Input type * @typeParam O - Output type */ export type FlowHandler = ( input: I, fx: Fx, ) => O | FlowFailure | Promise; /** Runtime / behavior options for {@link flow} (no invoke contract). */ export interface FlowOptions { /** Declared effects (capability token). Inferred later by AoT. */ readonly effects?: Effects; /** Journal every effect call. */ readonly durable?: boolean; /** * Retry the whole `do` body on thrown errors (same journal session when * durable). Prefer {@link Fx.retry} inside {@link Fx.step} for fine control. */ readonly retry?: FxRetryOptions; /** * Cache policy. Omitted caches a pure store-read flow. `true` or a duration * (`"30s"`) opts one flow in when the app set `cache: { auto: false }`. * `false` always disables. The key includes tenant, locale, scopes, and roles. */ readonly cache?: boolean | string; /** * HTTP `Idempotency-Key` handling. * Omitted is auto: the header is honored on an eligible mutating flow. * `"required"` rejects a missing header. `false` ignores the header. * A TTL string (`"24h"`) overrides the 24 hour default. */ readonly idempotency?: | false | "required" | { readonly required?: boolean; readonly ttl?: string }; /** Declared service-level objective. */ readonly slo?: Slo; /** * Plane — `user` (application) or `operator` (Console). * Cross-plane invocation is a compile error. */ readonly plane?: FlowPlane; /** * Compensation phase after terminal failure on a durable flow. * Runs under the same journal session after auto per-step `{ undo }` * handlers and before `commit("failed")`. Prefer `fx.step(name, fn, { undo })` * for step-local reverse work; use this hook for cross-cutting cleanup. * Manual bodies must use distinct `fx.step("undo:…")` names — never reuse * forward step names. Never called on retryable attempts or sleep park. */ readonly compensate?: ( ctx: { readonly input: I; readonly error: unknown; readonly completedSteps: readonly string[]; }, fx: import("./fx.ts").Fx, ) => unknown | Promise; /** * When `false`, skip tenant-role scope union even if `fx.tenant.id` is set. * Default `true` when `gate.auth.tenant` is on. */ readonly tenantScoped?: boolean; /** * Mark this HTTP flow as returning `text/event-stream` via `fx.json.stream` * (not signal live). Stamps `$routes.stream` for the typed client. */ readonly stream?: true; /** The behavior. */ readonly do: FlowHandler; } /** * Infer validated input from a flow options bag (handler signature). * * @typeParam Opts - {@link FlowOptions} / call-site object */ export type InferFlowIn = Opts extends { readonly do: FlowHandler } ? I : unknown; /** * Infer success output from a flow options bag (handler signature). * * @typeParam Opts - {@link FlowOptions} / call-site object */ export type InferFlowOut = Opts extends { readonly do: FlowHandler } ? O : unknown; /** * Phantom brand for the bound trigger type parameter. Optional so * `FlowDef<…, undefined>` remains assignable into `on()` before rebinding. */ declare const triggerPhantom: unique symbol; /** Covariant input phantom so `fx.call` can read `I` without hitting `do`. */ declare const inputPhantom: unique symbol; /** Covariant output phantom so `fx.call` can read `O` without hitting `do`. */ declare const outputPhantom: unique symbol; /** * A Flow definition — one species, trigger-agnostic until {@link on} binds. * * `in` / `out` / `errors` / `breaking` are **projections** stamped from the * exposure (`http.*` / `call` / `mcp.tool` / signal schema), not authored here. * * @typeParam I - Input type * @typeParam O - Output type * @typeParam E - Error map (schemas) * @typeParam D - Accumulated decoration types from `.plug()` * @typeParam T - Bound trigger (set by {@link on}; `undefined` when untriggered) */ export interface FlowDef< I = unknown, O = unknown, E extends FlowErrorMap = FlowErrorMap, D extends Record = {}, T extends Trigger | undefined = undefined, > { /** Brand for type guards. */ readonly [flowBrand]: true; /** * Type-level bound trigger (phantom). Not read at runtime — use * `$trigger` / `triggers[0]` for the value. */ readonly [triggerPhantom]?: T; /** Type-level input. Not present at runtime. */ readonly [inputPhantom]?: I; /** Type-level output. Not present at runtime. */ readonly [outputPhantom]?: O; /** Stable name (auto-assigned when omitted). */ readonly name: string; /** Unit scope — derived from `name`'s first dot segment (e.g. `"auth.refresh"` → `"auth"`). */ readonly unit: string | undefined; /** Input schema (stamped from exposure). */ in: SchemaInput | undefined; /** Output schema (stamped from exposure). */ out: SchemaInput | undefined; /** Declared errors (stamped from exposure). */ errors: E | undefined; /** Declared effects. */ readonly effects: Effects | undefined; /** Durability flag. */ readonly durable: boolean; /** Whole-body retry policy (runtime). */ readonly retry: FxRetryOptions | undefined; /** * Live signal name when this HTTP flow streams {@link SignalDecl} SSE. */ readonly live: string | undefined; /** * True when `.live(signal)` is bound to a custom `do` (not synthesized). * Boot uniqueness uses `custom:{flowName}` instead of path-param match. */ readonly liveCustomMatch: boolean; /** Cache option. */ readonly cache: boolean | string | undefined; /** Author idempotency option. */ readonly idempotency: | false | "required" | { readonly required?: boolean; readonly ttl?: string } | undefined; /** * Extracted idempotency stamp. Boot copies this from the Manifest. * Runtime resolves it when extract has not run. */ resolvedIdempotency?: { readonly mode: "off" | "auto" | "required"; readonly ttl: string }; /** True when extract saw `fx.raw`. A throw then completes the idempotency row. */ usesRaw?: boolean; /** SLO. */ readonly slo: Slo | undefined; /** Plane (user vs operator). */ readonly plane: FlowPlane | undefined; /** Intentional contract-break acknowledgement (stamped from exposure). */ breaking: boolean; /** * When `false`, this flow is tenant-unaware (no tenant-role scope union). * Default `true` when `gate.auth.tenant` is on. */ readonly tenantScoped: boolean | undefined; /** * True when this HTTP flow returns SSE via `fx.json.stream` (not live signal). */ readonly stream: true | undefined; /** * Optional compensation phase after terminal durable failure. * See {@link FlowOptions.compensate}. */ readonly compensate: | (( ctx: { readonly input: I; readonly error: unknown; readonly completedSteps: readonly string[]; }, fx: import("./fx.ts").Fx, ) => unknown | Promise) | undefined; /** Handler body. */ readonly do: FlowHandler; /** Triggers bound via {@link on} (zero or more). */ readonly triggers: readonly Trigger[]; /** * First bound trigger at runtime (`undefined` when untriggered). * The precise trigger literal lives in the {@link triggerPhantom} type param. */ readonly $trigger: Trigger | undefined; /** Flow-scoped hooks, registration order. */ readonly hooks: Readonly>>; /** * Plugins queued via {@link FlowDef.plug} until an app registry adopts them. * @internal */ readonly pendingPlugins: readonly PluginDef[]; /** Accumulated decoration types from `.plug()` (type-level only). */ readonly decorations?: D; /** * Register a flow-scoped hook (registration order, no priority numbers). * * @param stage - Pipeline stage * @param fn - Hook function */ hook(stage: HookStage, fn: HookFn): FlowDef; /** * Attach a plugin to this flow only. Scope is the attachment point. * * @param pluginDef - Plugin from {@link plugin} */ plug

( pluginDef: P, ): FlowDef ? PD : Record), T>; } /** * Input type of a Flow handle. `unknown` when `F` is a string name. * * @typeParam F - Flow handle or other call target */ export type FlowInput = F extends { readonly [inputPhantom]?: infer I } ? [I] extends [undefined] ? unknown : Exclude : unknown; /** * Output type of a Flow handle. `unknown` when `F` is a string name. * * @typeParam F - Flow handle or other call target */ export type FlowOutput = F extends { readonly [outputPhantom]?: infer O } ? [O] extends [undefined] ? unknown : Exclude : unknown; /** Unique brand symbol for {@link FlowDef}. */ export const flowBrand: unique symbol = Symbol("oke.flow"); /** * Define a Flow — behavior only. Author invoke contracts on `http.*` / * {@link call} / `mcp.tool`, not here. * * A nameless `flow({ do })` is stamped `unit.export` by the file-tree * generator, {@link unit}, or `.adopt()`. Explicit `flow("notes.get", {…})` * still wins. Signal / Clock consumers with no explicit or tree-derived name * fail **OKE1072** at `oke()` / extract — they do not inherit the trigger name. * * @param options - Runtime options and handler (name stamped later) */ export function flow>( options: Opts, ): FlowDef, InferFlowOut>; /** * @param name - Stable name (used by `fx.call` and the Manifest) * @param options - Runtime options and handler */ export function flow>( name: string, options: Opts & { readonly name?: never }, ): FlowDef, InferFlowOut>; export function flow( nameOrOptions: string | FlowOptions, maybeOptions?: FlowOptions & { readonly name?: never }, ): FlowDef { const named = typeof nameOrOptions === "string"; const name = named ? nameOrOptions : ""; const options = (named ? maybeOptions : nameOrOptions) as FlowOptions | undefined; if (!options || typeof options.do !== "function") { throw new TypeError("flow() expected an options bag with a do handler"); } // Reject legacy contract keys so misuse fails loud at definition time. const legacy = options as FlowOptions & { in?: unknown; out?: unknown; errors?: unknown; breaking?: unknown; }; if ( legacy.in !== undefined || legacy.out !== undefined || legacy.errors !== undefined || legacy.breaking !== undefined ) { throw new TypeError( "flow() no longer accepts in/out/errors/breaking — author the contract on http.*/call/mcp.tool", ); } const triggers: Trigger[] = []; const hooks: Partial> = {}; const pendingPlugins: PluginDef[] = []; const dot = name.indexOf("."); const unit = dot > 0 ? name.slice(0, dot) : undefined; const def: FlowDef = { [flowBrand]: true, name, unit, in: undefined, out: undefined, errors: undefined, effects: options.effects, durable: options.durable ?? false, retry: options.retry, live: undefined, liveCustomMatch: false, cache: options.cache, idempotency: options.idempotency, slo: options.slo, plane: options.plane, breaking: false, tenantScoped: options.tenantScoped, stream: options.stream, compensate: options.compensate as FlowDef["compensate"], do: options.do as FlowHandler, triggers, $trigger: undefined, hooks, pendingPlugins, hook(stage, fn) { const list = hooks[stage] ?? (hooks[stage] = []); list.push(fn); return def; }, plug(pluginDef) { pendingPlugins.push(pluginDef); return def as never; }, }; return def; } /** * Erased flow type for heterogenous registries (`on` bindings, app index). * Individual flows keep their `I`/`O` at the declaration site; the registry * cannot preserve every specialization without an erased carrier type. */ export type AnyFlowDef = FlowDef; /** * Type guard for {@link FlowDef}. * * @param value - Unknown value */ export function isFlow(value: unknown): value is AnyFlowDef { return ( typeof value === "object" && value !== null && flowBrand in value && (value as AnyFlowDef)[flowBrand] === true ); } /** * No-op. Nameless flows start as `""` and are stamped by unit / adopt / * the file-tree generator — kept so existing test `beforeEach` blocks * don't need touching. * * @internal */ export function resetFlowSeq(): void {}