/** * Typed framework errors and flow-boundary failure values. * * Every framework error has a stable numeric code, a one-line cause, * a suggested fix, and a docs URL (unified-theory §21). Codes are * permanent from the moment they enter this registry. */ import { docsUrl as absoluteDocsUrl } from "../docs-origin.ts"; import { getActiveDefaultLocale, getActiveLocale } from "../i18n/locale-context.ts"; import { lazyRequire } from "./lazy-require.ts"; import type { BuiltinErrorMap } from "./builtin-errors.ts"; /** Catalogs — kept off the edge `fail` / `OKE_ERRORS` static graph. */ function loadMessages(): typeof import("../i18n/messages.ts") { const stem = ["mes", "sages"].join(""); try { return lazyRequire(`${import.meta.dir}/../i18n`, stem); } catch { return lazyRequire(import.meta.dir, stem); } } /** Failure-message helper — same lazy boundary as {@link loadMessages}. */ function loadFailureMessage(): typeof import("../i18n/failure-message.ts") { const stem = ["failure", "message"].join("-"); try { return lazyRequire(`${import.meta.dir}/../i18n`, stem); } catch { return lazyRequire(import.meta.dir, stem); } } /** Numeric OKE error code (stable after the domain-range renumber). */ export type OkeErrorCode = number; /** * Error domain — each maps to a fixed numeric range in {@link OKE_ERROR_RANGES}. * Kernel covers Flow/Trigger/Gate-posture (boot structural). Gate sessions/OAuth * live in `gate`; MCP + tenancy share `mcp_tenancy`. */ export type OkeErrorDomain = | "kernel" | "store" | "signal" | "clock" | "gate" | "vault" | "channel" | "ai" | "mcp_tenancy" | "compiler"; /** Inclusive `[lo, hi]` band for each {@link OkeErrorDomain}. */ export const OKE_ERROR_RANGES = { kernel: [1000, 1099], store: [1100, 1199], signal: [1200, 1299], clock: [1300, 1399], gate: [1400, 1499], vault: [1500, 1599], channel: [1600, 1699], ai: [1700, 1799], mcp_tenancy: [1800, 1899], compiler: [1900, 1999], } as const satisfies Record; /** One registry entry — cause/fix may include `{name}` placeholders. */ export interface OkeErrorDefinition { /** Permanent numeric code (must fall in {@link OKE_ERROR_RANGES} for `domain`). */ readonly code: OkeErrorCode; /** Domain that owns this code's numeric range. */ readonly domain: OkeErrorDomain; /** One-line cause template. */ readonly cause: string; /** Suggested fix template. */ readonly fix: string; } /** Context values interpolated into cause/fix templates. */ export type OkeErrorParams = Readonly>; /** * Framework error: thrown for invariant violations (capability, wiring). * Distinct from flow-boundary failures returned by {@link fail}. */ export class OkeError extends Error { /** Permanent numeric code. */ readonly code: OkeErrorCode; /** Interpolated one-line cause. */ readonly causeText: string; /** Interpolated suggested fix. */ readonly fix: string; /** Docs URL for this code. */ readonly docsUrl: string; /** Interpolation params used to build the message. */ readonly params: OkeErrorParams; /** * @param definition - Registry entry * @param params - Values for `{placeholders}` in cause/fix * @param locale - Optional locale (defaults to active request locale) */ constructor(definition: OkeErrorDefinition, params: OkeErrorParams = {}, locale?: string) { const causeText = localizeOkePart(definition.code, "cause", definition.cause, params, locale); const fix = localizeOkePart(definition.code, "fix", definition.fix, params, locale); const docsUrl = absoluteDocsUrl(`/e/${definition.code}`); const message = formatOkeMessage(definition.code, causeText, fix, docsUrl); super(message); this.name = "OkeError"; this.code = definition.code; this.causeText = causeText; this.fix = fix; this.docsUrl = docsUrl; this.params = params; } } /** * Flow-boundary failure value produced by {@link fail}. * Errors are values (`{ data, error }`), not exceptions, at the flow boundary. */ export interface FlowFailure { readonly data: null; readonly error: FlowErrorValue; } /** Payload carried by a flow-boundary failure. */ export interface FlowErrorValue { /** * Declared error code from the flow's `errors` map * (e.g. `"FlightFull"`, `"ValidationError"`) — not an OKE#### number. * Clients narrow with `error?.code === "FlightFull"`. */ readonly code: string; /** Typed error data from the flow. */ readonly data: E; /** Optional localized or custom message. */ readonly message?: string; } /** Optional extras for {@link fail}. */ export interface FailOptions { /** Override message (often from `fx.t(...)`). */ readonly message?: string; } /** * Callable `fail` plus built-in helpers (`fail.notFound`, `fail.forbidden`, …). */ export interface FailFn { /** * Flow-boundary failure value (does not throw). * * @param code - Declared or built-in error code * @param data - Error payload * @param opts - Optional message override */ (code: string, data: E, opts?: FailOptions): FlowFailure; /** Missing row / object — HTTP 404. */ notFound( data?: BuiltinErrorMap["NotFound"], opts?: FailOptions, ): FlowFailure; /** Not authenticated — HTTP 401. */ unauthorized( data?: BuiltinErrorMap["Unauthorized"], opts?: FailOptions, ): FlowFailure; /** Authenticated but denied — HTTP 403. */ forbidden( data?: BuiltinErrorMap["Forbidden"], opts?: FailOptions, ): FlowFailure; /** Unique / exclusion conflict — HTTP 409. */ conflict( data?: BuiltinErrorMap["Conflict"], opts?: FailOptions, ): FlowFailure; /** Foreign-key violation — HTTP 409. */ foreignKey( data?: BuiltinErrorMap["ForeignKey"], opts?: FailOptions, ): FlowFailure; /** Rate budget exhausted — HTTP 429. */ rateLimited( data?: BuiltinErrorMap["RateLimited"], opts?: FailOptions, ): FlowFailure; /** Maintenance / connection — HTTP 503. */ serviceUnavailable( data?: BuiltinErrorMap["ServiceUnavailable"], opts?: FailOptions, ): FlowFailure; /** SQL / store constraint that is not unique or FK. */ database( data: BuiltinErrorMap["DatabaseError"], opts?: FailOptions, ): FlowFailure; /** Unhandled throw — HTTP 500. Catalog message only. */ internal( data?: BuiltinErrorMap["InternalError"], opts?: FailOptions, ): FlowFailure; } /** * Create a flow-boundary failure value (does not throw). * * When `opts.message` is omitted, attaches a localized message from the * built-in / app catalogs (`errors.{code}.{reason}` → `errors.{code}`) using * the active request locale. Custom codes with no catalog entry stay * message-less. * * @param code - Declared error code from the flow's `errors` map * @param data - Error payload * @param opts - Optional message override */ function failImpl(code: string, data: E, opts?: FailOptions): FlowFailure { const message = opts?.message !== undefined ? opts.message : loadFailureMessage().resolveFailureMessage(code, data); const error: FlowErrorValue = message !== undefined ? { code, data, message } : { code, data }; return { data: null, error }; } /** * Flow-boundary failure (callable). Built-in helpers live on the lazy * `fail-helpers` chunk so they stay off the kernel edge graph — {@link createFx} * and the public `okengine` export load that chunk. */ export const fail: FailFn = failImpl as FailFn; /** * Permanent error registry. Codes are stable within their domain range after * the domain-range renumber; each entry declares `domain` for range guards. * * Cause/fix templates load from {@link OKE_ERROR_TEXT} on first read so the * edge profile does not carry the English strings. * * Ranges: * - `1000–1099` — Kernel (Flow / Trigger / Gate-posture) * - `1100–1199` — Store * - `1200–1299` — Signal * - `1300–1399` — Clock * - `1400–1499` — Gate (sessions / OAuth / API keys) * - `1500–1599` — Vault * - `1600–1699` — Channel * - `1700–1799` — AI * - `1800–1899` — MCP + Tenancy * - `1900–1999` — Compiler / Manifest */ const OKE_ERROR_ROWS = { /** Flow reads a store resource not listed in `effects.reads`. */ UNDECLARED_READ: { code: 1001, domain: "kernel" }, /** Flow writes a store resource not listed in `effects.writes`. */ UNDECLARED_WRITE: { code: 1002, domain: "kernel" }, /** Flow emits a signal not listed in `effects.emits`. */ UNDECLARED_EMIT: { code: 1003, domain: "kernel" }, /** Flow sends a channel template not listed in `effects.sends`. */ UNDECLARED_SEND: { code: 1004, domain: "kernel" }, /** Flow asks a prompt not listed in `effects.asks`. */ UNDECLARED_ASK: { code: 1005, domain: "kernel" }, /** Flow reads a secret not listed in `effects.secrets`. */ UNDECLARED_SECRET: { code: 1006, domain: "kernel" }, /** Flow calls another flow not listed in `effects.calls`. */ UNDECLARED_CALL: { code: 1007, domain: "kernel" }, /** Flow fetches a host not listed in `effects.fetches`. */ UNDECLARED_FETCH: { code: 1008, domain: "kernel" }, /** Flow embeds via a model not listed in `effects.embeds`. */ UNDECLARED_EMBED: { code: 1009, domain: "kernel" }, /** Flow decides a decision not listed in `effects.decides`. */ UNDECLARED_DECIDE: { code: 1010, domain: "kernel" }, /** * Flow has no declared `effects` and no Manifest-derived effects were * available to stamp at boot (dev+compose / prod — never a silent open token). */ NO_EFFECTS_DECLARED: { code: 1020, domain: "kernel" }, /** * A `src/flows/` folder exists on disk but no adopted flow carries * that unit — the generated `.adopt()` barrel (`src/flows/index.ts`) * is stale or was hand-edited. dev+compose / prod — never a silently-incomplete * route table in a deploy-shaped environment. */ ADOPT_BARREL_STALE: { code: 1030, domain: "kernel" }, /** `http.get()` was never stamped from the file tree — refuse a silent `/`. */ HTTP_PATH_UNRESOLVED: { code: 1040, domain: "kernel" }, /** Two HTTP bindings share method + path. */ HTTP_ROUTE_DUPLICATE: { code: 1041, domain: "kernel" }, /** Adopted HTTP flow still has no name (`flow({ do })` outside a unit). */ HTTP_FLOW_UNNAMED: { code: 1045, domain: "kernel" }, /** Two live HTTP exposures of the same signal share gates + match shape. */ LIVE_EXPOSURE_DUPLICATE: { code: 1050, domain: "kernel" }, /** Two MCP tool bindings share the same exposed tool name. */ MCP_TOOL_DUPLICATE: { code: 1060, domain: "kernel" }, /** A journal write arrived from a holder whose lease token no longer matches. */ JOURNAL_STALE_LEASE: { code: 1074, domain: "kernel" }, /** Replay called a different step or effect than the next journal entry. */ JOURNAL_REPLAY_DIVERGENCE: { code: 1075, domain: "kernel" }, /** Resume of a run stamped with a different code version. */ JOURNAL_CODE_VERSION: { code: 1076, domain: "kernel" }, /** A journaled value could not be stored as JSON. */ JOURNAL_VALUE_NOT_JSON: { code: 1077, domain: "kernel" }, /** * Emit target has no subscriber (unified-theory §21). * Thrown at emit when `optional` is false and nobody is subscribed. */ ORPHAN_EMIT: { code: 1240, domain: "signal" }, /** Emit payload failed the signal's declared Standard Schema. */ SIGNAL_SCHEMA: { code: 1250, domain: "signal" }, /** Domain table/column missing under docker/prod (migrations not applied). */ DOMAIN_SCHEMA_MISSING: { code: 1110, domain: "store" }, } as const satisfies Record; type OkeErrorRowTable = typeof OKE_ERROR_ROWS; /** Registry entries with literal codes and lazy cause/fix. */ type OkeErrorTable = { readonly [K in keyof OkeErrorRowTable]: OkeErrorDefinition & { readonly code: OkeErrorRowTable[K]["code"]; readonly domain: OkeErrorRowTable[K]["domain"]; }; }; /** * Load English cause/fix templates. Computed stem so Bun.build cannot inline them. */ function loadErrorText(): typeof import("./errors-text.ts") { return lazyRequire(import.meta.dir, ["errors", "text"].join("-")); } /** * Read one cause or fix template. * * @param key - {@link OKE_ERRORS} property name * @param part - `cause` or `fix` */ function errorPart(key: string, part: "cause" | "fix"): string { const row = loadErrorText().OKE_ERROR_TEXT[key]; if (!row) throw new Error(`Missing OKE error text for ${key}.${part}`); return row[part]; } /** * Attach lazy cause/fix getters. Strings stay in the text chunk until read. * * @param rows - Code and domain table */ function attachErrorText(rows: OkeErrorRowTable): OkeErrorTable { const out: Record = {}; for (const key of Object.keys(rows) as (keyof OkeErrorRowTable)[]) { const row = rows[key]; out[key] = { code: row.code, domain: row.domain, get cause() { return errorPart(key, "cause"); }, get fix() { return errorPart(key, "fix"); }, }; } return out as OkeErrorTable; } /** * Permanent error registry. Cause/fix load from the lazy text chunk. * * `@__PURE__` lets bundles that never read the registry drop the getter * table. A plain call looks like a side effect and stays in every importer. */ export const OKE_ERRORS: OkeErrorTable = /* @__PURE__ */ attachErrorText(OKE_ERROR_ROWS); /** Tenant error keys — definitions live in the lazy `errors-tenant` chunk. */ export type TenantOkeErrorKey = "TENANT_REQUIRED" | "TENANT_NOT_MEMBER" | "TENANT_UNKNOWN_SCOPE"; type TenantErrorChunk = { readonly TENANT_REQUIRED: OkeErrorDefinition; readonly TENANT_NOT_MEMBER: OkeErrorDefinition; readonly TENANT_UNKNOWN_SCOPE: OkeErrorDefinition; }; /** * Load OKE1810–1830. Computed stem so Bun.build cannot inline the chunk. */ function loadTenantErrors(): TenantErrorChunk { return lazyRequire(import.meta.dir, ["errors", "tenant"].join("-")); } /** * Build and throw a registered framework error. * * @param key - Key into {@link OKE_ERRORS} or a lazy tenant code * @param params - Interpolation params * @returns Never */ export function throwOke( key: keyof typeof OKE_ERRORS | TenantOkeErrorKey, params: OkeErrorParams = {}, ): never { if (typeof key === "string" && key.startsWith("TENANT_")) { throw new OkeError(loadTenantErrors()[key as TenantOkeErrorKey], params); } throw new OkeError(OKE_ERRORS[key as keyof typeof OKE_ERRORS], params); } /** * Look up a registry entry by numeric code. * * @param code - Permanent numeric code */ export function lookupOkeError(code: OkeErrorCode): OkeErrorDefinition | undefined { if (code === 1210) { return lazyRequire( import.meta.dir, ["errors", "live", "resume"].join("-"), ).LIVE_RESUME_GAP; } if (code === 1510) { return lazyRequire( import.meta.dir, ["errors", "vault"].join("-"), ).VAULT_SECRET_MISSING; } if (code === 1605) { return lazyRequire( import.meta.dir, ["errors", "channel"].join("-"), ).CHANNEL_SCHEMA; } if (code === 1810) return loadTenantErrors().TENANT_REQUIRED; if (code === 1820) return loadTenantErrors().TENANT_NOT_MEMBER; if (code === 1830) return loadTenantErrors().TENANT_UNKNOWN_SCOPE; for (const def of Object.values(OKE_ERRORS)) { if (def.code === code) return def; } return undefined; } /** * Format the canonical multi-line OKE error message (§21). * * @param code - Numeric code * @param cause - Interpolated cause * @param fix - Interpolated fix * @param docsUrl - Docs URL */ export function formatOkeMessage( code: OkeErrorCode, cause: string, fix: string, docsUrl: string, ): string { return `OKE${code} ${cause}\n → ${fix}\n ${docsUrl}`; } function interpolate(template: string, params: OkeErrorParams): string { return template.replace(/\{([a-zA-Z0-9_]+)\}/g, (_, key: string) => { return params[key] ?? `{${key}}`; }); } /** * Localize one OKE cause/fix line from catalogs, falling back to the registry * English template. * * @param code - Numeric OKE code * @param part - `cause` or `fix` * @param fallback - Registry English template * @param params - Interpolation params * @param locale - Optional locale override */ function localizeOkePart( code: OkeErrorCode, part: "cause" | "fix", fallback: string, params: OkeErrorParams, locale?: string, ): string { const key = `oke.${code}.${part}`; const { getMessageCatalogs, translate } = loadMessages(); const catalogs = getMessageCatalogs(); const active = locale ?? getActiveLocale("en"); const defaultLocale = getActiveDefaultLocale("en"); if (catalogs[active]?.[key] === undefined && catalogs[defaultLocale]?.[key] === undefined) { return interpolate(fallback, params); } const formatted = translate({ locale: active, defaultLocale, catalogs, key, values: params, }); // Catalog returned uninterpolated ICU (missing args) → registry template. if ( Object.keys(params).length === 0 || formatted.includes("{flow}") || formatted.includes("{resource}") ) { return interpolate(fallback, params); } return formatted; }