okengine 0.19.9 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/AGENTS.md +1 -1
  2. package/package.json +2 -1
  3. package/site/content/docs/client/auth.mdx +1 -2
  4. package/site/content/docs/client/calling.mdx +125 -37
  5. package/site/content/docs/client/index.mdx +7 -7
  6. package/site/content/docs/client/react.mdx +5 -0
  7. package/site/content/docs/elements/channel/email.mdx +25 -36
  8. package/site/content/docs/elements/channel/index.mdx +88 -46
  9. package/site/content/docs/elements/channel/push.mdx +7 -9
  10. package/site/content/docs/elements/channel/sms.mdx +6 -4
  11. package/site/content/docs/elements/channel/whatsapp.mdx +11 -9
  12. package/site/content/docs/elements/clock/index.mdx +14 -25
  13. package/site/content/docs/elements/flow/http.mdx +42 -23
  14. package/site/content/docs/elements/flow/index.mdx +41 -30
  15. package/site/content/docs/elements/flow/routing.mdx +166 -122
  16. package/site/content/docs/elements/gate/rls.mdx +2 -2
  17. package/site/content/docs/elements/gate/tenancy.mdx +1 -1
  18. package/site/content/docs/elements/store/files.mdx +11 -5
  19. package/site/content/docs/elements/store/index.mdx +13 -6
  20. package/site/content/docs/elements/store/kv.mdx +8 -2
  21. package/site/content/docs/elements/store/search.mdx +5 -5
  22. package/site/content/docs/elements/store/sql.mdx +100 -39
  23. package/site/content/docs/elements/vault/index.mdx +14 -13
  24. package/site/content/docs/elements/vault/secrets.mdx +6 -3
  25. package/site/content/docs/index.mdx +1 -1
  26. package/site/content/docs/plugins/magic-link.mdx +4 -3
  27. package/site/content/docs/plugins/otp.mdx +4 -3
  28. package/site/content/docs/plugins/two-factor.mdx +4 -0
  29. package/site/content/docs/providers/index.mdx +1 -1
  30. package/site/content/docs/recipes/index.mdx +1 -1
  31. package/site/content/docs/recipes/rustfs.mdx +1 -1
  32. package/site/content/docs/reference/cli.mdx +7 -4
  33. package/site/content/docs/reference/configuration.mdx +8 -8
  34. package/site/content/docs/reference/errors.mdx +229 -55
  35. package/site/content/docs/reference/fx.mdx +22 -9
  36. package/site/content/docs/reference/i18n.mdx +4 -4
  37. package/site/content/docs/reference/plugins.mdx +4 -4
  38. package/site/content/docs/understand/the-architecture.mdx +2 -2
  39. package/site/content/docs/understand/try-it.mdx +759 -24
  40. package/src/cli/ai-setup/ai-setup.test.ts +40 -0
  41. package/src/cli/ai-setup/apply.ts +28 -46
  42. package/src/cli/build.test.ts +3 -3
  43. package/src/cli/build.ts +5 -5
  44. package/src/cli/db-auto-push.test.ts +11 -0
  45. package/src/cli/db-auto-push.ts +6 -2
  46. package/src/cli/db.test.ts +1 -1
  47. package/src/cli/db.ts +6 -6
  48. package/src/cli/dev-app-runner.ts +2 -1
  49. package/src/cli/dev-db-push.test.ts +6 -2
  50. package/src/cli/dev-schema-sync.ts +1 -1
  51. package/src/cli/dev.test.ts +10 -7
  52. package/src/cli/dev.ts +13 -11
  53. package/src/cli/ensure-drizzle-config.ts +4 -3
  54. package/src/cli/start.ts +2 -1
  55. package/src/client/create.ts +3 -3
  56. package/src/client/explain.test.ts +252 -0
  57. package/src/client/explain.ts +272 -0
  58. package/src/client/live.test.ts +44 -0
  59. package/src/client/notes-contract.test.ts +10 -0
  60. package/src/client/sse.ts +6 -2
  61. package/src/client/transport.test.ts +67 -0
  62. package/src/client/transport.ts +24 -40
  63. package/src/client/types.ts +17 -6
  64. package/src/client-react/browser.test.ts +23 -0
  65. package/src/client-react/live-resource.ts +6 -2
  66. package/src/client-react/use-live-query.ts +1 -1
  67. package/src/compiler/flow-path.test.ts +1 -0
  68. package/src/compiler/flow-path.ts +1 -1
  69. package/src/compiler/generate-adopt.test.ts +55 -1
  70. package/src/compiler/generate-adopt.ts +111 -21
  71. package/src/compiler/response.ts +17 -27
  72. package/src/config/index.ts +6 -4
  73. package/src/console/server/invoke-user-flow.test.ts +8 -2
  74. package/src/console/server/invoke-user-flow.ts +12 -18
  75. package/src/console/server/security.gate.test.ts +1 -1
  76. package/src/console/ui-next/dist/assets/{access-page-BoC83Ubl.js → access-page-tIbsiphz.js} +1 -1
  77. package/src/console/ui-next/dist/assets/{agent-disclosure-CKjAEOqA.js → agent-disclosure-CSKumwS2.js} +1 -1
  78. package/src/console/ui-next/dist/assets/{cache-glyph-Ceaq9pYh.js → cache-glyph-BCC-DxKT.js} +1 -1
  79. package/src/console/ui-next/dist/assets/{call-pii-button-C4lmY7ck.js → call-pii-button-jOmYISdc.js} +1 -1
  80. package/src/console/ui-next/dist/assets/{collapsible-82y257sL.js → collapsible-DC2xNaAb.js} +1 -1
  81. package/src/console/ui-next/dist/assets/{duration-tone-W63jaMZ8.js → duration-tone-CwoV56jn.js} +1 -1
  82. package/src/console/ui-next/dist/assets/{flows-page-KFTFj2rK.js → flows-page-gT1lsWQK.js} +1 -1
  83. package/src/console/ui-next/dist/assets/{highlighted-json-yE9zNTqC.js → highlighted-json-C2GZEJNI.js} +1 -1
  84. package/src/console/ui-next/dist/assets/{http-method-CwFeFroN.js → http-method-BdYjcIrD.js} +1 -1
  85. package/src/console/ui-next/dist/assets/{index-r7xXt_VV.js → index-Cul17AcV.js} +3 -3
  86. package/src/console/ui-next/dist/assets/{observability-page-BDiliMiC.js → observability-page-oY9vdYBk.js} +1 -1
  87. package/src/console/ui-next/dist/assets/{replica-lag-DYDzWUFT.js → replica-lag-C4QdAF7J.js} +1 -1
  88. package/src/console/ui-next/dist/assets/{request-meta-CzrOfgiz.js → request-meta-C43DHyld.js} +1 -1
  89. package/src/console/ui-next/dist/assets/{store-page-CZC2cwaw.js → store-page-CL0D9dOq.js} +1 -1
  90. package/src/console/ui-next/dist/assets/{trace-detail-sheet-DgeeejW7.js → trace-detail-sheet-Dcpo4Us_.js} +1 -1
  91. package/src/console/ui-next/dist/assets/{tree-expand-toggle-CzGIyOPY.js → tree-expand-toggle-CKJTuv43.js} +1 -1
  92. package/src/console/ui-next/dist/assets/{units-page-DBiDCLIB.js → units-page-1PlT19ft.js} +1 -1
  93. package/src/console/ui-next/dist/assets/{vault-page-CcHsthPe.js → vault-page-BwZ9YjTW.js} +1 -1
  94. package/src/console/ui-next/dist/index.html +1 -1
  95. package/src/docker/docker.test.ts +3 -3
  96. package/src/docker/images-config.test.ts +4 -4
  97. package/src/docker/stack-id.test.ts +1 -1
  98. package/src/drivers/clock-postgres.test.ts +10 -2
  99. package/src/drivers/clock-postgres.ts +18 -2
  100. package/src/drivers/vault-driver-removal.test.ts +2 -2
  101. package/src/elements/channel/declare.ts +66 -3
  102. package/src/elements/channel/runtime.ts +9 -11
  103. package/src/elements/channel.test.ts +42 -0
  104. package/src/elements/channel.ts +4 -2
  105. package/src/elements/clock/reconcile.ts +45 -24
  106. package/src/elements/clock.test.ts +33 -0
  107. package/src/elements/store/emit-drizzle.ts +285 -65
  108. package/src/elements/store/files-errors.test.ts +149 -0
  109. package/src/elements/store/files-errors.ts +189 -0
  110. package/src/elements/store/kv-errors.test.ts +98 -0
  111. package/src/elements/store/kv-errors.ts +139 -0
  112. package/src/elements/store/load-plugin-tables.ts +1 -1
  113. package/src/elements/store/prepare-row.test.ts +57 -4
  114. package/src/elements/store/resource.ts +11 -7
  115. package/src/elements/store/runtime.ts +18 -13
  116. package/src/elements/store/schema-decl.test.ts +178 -0
  117. package/src/elements/store/sql-errors.test.ts +197 -0
  118. package/src/elements/store/sql-errors.ts +294 -0
  119. package/src/elements/store/sql-session.test.ts +52 -0
  120. package/src/elements/store/sql-session.ts +50 -2
  121. package/src/elements/store/store-errors.ts +47 -0
  122. package/src/elements/store/table.ts +8 -6
  123. package/src/http.ts +9 -1
  124. package/src/i18n/catalogs/ar.ts +18 -0
  125. package/src/i18n/catalogs/en.ts +18 -0
  126. package/src/index.ts +9 -1
  127. package/src/kernel/adopt-barrel-fresh.test.ts +1 -1
  128. package/src/kernel/app.ts +40 -34
  129. package/src/kernel/auto-registry.test.ts +26 -1
  130. package/src/kernel/boot.ts +2 -2
  131. package/src/kernel/boundary-contract.ts +6 -1
  132. package/src/kernel/builtin-errors.test.ts +117 -0
  133. package/src/kernel/builtin-errors.ts +129 -0
  134. package/src/kernel/call.test.ts +182 -0
  135. package/src/kernel/errors-vault.ts +16 -0
  136. package/src/kernel/errors.registry.test.ts +7 -0
  137. package/src/kernel/errors.ts +97 -24
  138. package/src/kernel/fail-helpers.ts +34 -0
  139. package/src/kernel/flow-units.ts +3 -3
  140. package/src/kernel/fx.test.ts +8 -0
  141. package/src/kernel/fx.ts +24 -8
  142. package/src/kernel/index.ts +12 -1
  143. package/src/kernel/mutation-id.ts +8 -0
  144. package/src/kernel/plugin.ts +4 -3
  145. package/src/kernel/project-out.test.ts +176 -0
  146. package/src/kernel/project-out.ts +91 -0
  147. package/src/kernel/realtime-bind.ts +2 -3
  148. package/src/kernel/router/linear.ts +12 -6
  149. package/src/kernel/router.test.ts +13 -0
  150. package/src/plugins/magic-link.ts +25 -24
  151. package/src/plugins/otp.ts +35 -24
  152. package/src/plugins/two-factor.ts +15 -0
  153. package/src/runs/duckdb.test.ts +2 -2
  154. package/src/runtime/dev-request-log.ts +29 -11
  155. package/src/term.test.ts +76 -0
  156. package/src/term.ts +166 -3
package/src/kernel/app.ts CHANGED
@@ -39,15 +39,16 @@ import { requirePackageModule } from "../shared/lazy-src.ts";
39
39
  import { lazyRequire } from "./lazy-require.ts";
40
40
  import type { DurableResult, RunDurableOptions } from "../elements/clock/durable.ts";
41
41
  import { applyClockTimezoneDefaults } from "../elements/clock/declare.ts";
42
- import type { TemplateCatalog } from "../elements/channel/runtime.ts";
42
+ import { catalogFromTemplates, mergeTemplateCatalogs } from "../elements/channel/declare.ts";
43
43
  import { parseAcceptLanguage } from "../elements/channel/locale.ts";
44
44
  import { runWithLocale } from "../i18n/locale-context.ts";
45
45
  import { isFlow, type AnyFlowDef } from "./flow.ts";
46
46
  import { failureFromUnknown, runCompensationPhase } from "./compensate.ts";
47
47
  import { withAbortSignal } from "./abort-scope.ts";
48
48
  import { withCdcMutationId } from "../elements/store/sql-session.ts";
49
- import { MUTATION_ID_HEADER } from "./realtime-bind.ts";
49
+ import { MUTATION_ID_HEADER } from "./mutation-id.ts";
50
50
  import { fxRetry } from "./concurrency.ts";
51
+ import { projectFlowOut } from "./project-out.ts";
51
52
  import type {
52
53
  CreateFxOptions,
53
54
  Fx,
@@ -60,7 +61,7 @@ import type {
60
61
  } from "./fx.ts";
61
62
  import {
62
63
  currentDevSurface,
63
- failureDetailFromResponse,
64
+ failureEnvelopeFromResponse,
64
65
  logDevRequest,
65
66
  shouldLogDevRequests,
66
67
  } from "../runtime/dev-request-log.ts";
@@ -350,6 +351,11 @@ export interface ExecuteResult {
350
351
  readonly failure: PipelineResult["failure"];
351
352
  readonly response: Response | undefined;
352
353
  readonly ctx: InvocationContext;
354
+ /**
355
+ * Derived projection of {@link InvocationContext.error} — never a separate
356
+ * store. Always `=== ctx.error` by reference.
357
+ */
358
+ readonly error: unknown;
353
359
  readonly fx: Fx;
354
360
  /** Wide-event cache dimension from this invocation's telemetry. */
355
361
  readonly cache: "hit" | "miss" | "none";
@@ -381,13 +387,13 @@ export interface UnitHooks<D extends Record<string, unknown> = {}> {
381
387
  }
382
388
 
383
389
  /**
384
- * Module-augmentation slot filled by `src/flows/generated.ts`.
390
+ * Module-augmentation slot filled by `src/flows/index.ts`.
385
391
  * Kernel tests do not import an app generated file, so this stays `{}`.
386
392
  */
387
393
  export interface RegisteredFlowUnits {}
388
394
 
389
395
  /**
390
- * `$routes` derived from {@link RegisteredFlowUnits} after `import generated`.
396
+ * `$routes` derived from {@link RegisteredFlowUnits} after `import "@/flows"`.
391
397
  *
392
398
  * @typeParam U - Augmented unit map
393
399
  */
@@ -853,7 +859,7 @@ function registerOnceSignalBinding(
853
859
  }
854
860
 
855
861
  /**
856
- * Fold `generated.ts` units into `$routes` and `flowsByName`.
862
+ * Fold `src/flows/index.ts` units into `$routes` and `flowsByName`.
857
863
  *
858
864
  * @param units - Drained {@link registerFlowUnits} bag
859
865
  * @param routes - Runtime `$routes`
@@ -967,12 +973,21 @@ export function oke(options: OkeOptions): OkeApp {
967
973
  // explicit `options.channel` nor a registered template exists — a defined
968
974
  // object here would flip `resolveElementNeeds`'s `channel` need to `true`
969
975
  // for every app, whether or not it uses channel at all.
976
+ const channelTemplates = mergeUnique(
977
+ options.channel?.templates,
978
+ registrySnapshot.channelTemplates,
979
+ );
980
+ const channelCatalog = mergeTemplateCatalogs(
981
+ catalogFromTemplates(channelTemplates),
982
+ options.channel?.catalog,
983
+ );
970
984
  const effectiveChannel: BootOptions["channel"] =
971
985
  options.channel === undefined && registrySnapshot.channelTemplates.length === 0
972
986
  ? undefined
973
987
  : {
974
988
  ...(options.channel ?? {}),
975
- templates: mergeUnique(options.channel?.templates, registrySnapshot.channelTemplates),
989
+ templates: channelTemplates,
990
+ ...(channelCatalog ? { catalog: channelCatalog } : {}),
976
991
  };
977
992
  // Same undefined-when-empty rule for AI — a bare `{}` would force the AI
978
993
  // runtime open even when the app never declared models / prompts.
@@ -1444,7 +1459,13 @@ export function oke(options: OkeOptions): OkeApp {
1444
1459
  const baseSignals = overrides?.signals ?? effectiveSignals;
1445
1460
  const baseClocks = overrides?.clocks ?? effectiveClocks;
1446
1461
  const baseChannel = overrides?.channel ?? effectiveChannel;
1447
- const mergedCatalog = mergeTemplateCatalogs(baseChannel?.catalog, ...pluginChannelCatalogs);
1462
+ const mergedTemplates = [...(baseChannel?.templates ?? []), ...pluginChannelTemplates];
1463
+ const mergedCatalog = mergeTemplateCatalogs(
1464
+ catalogFromTemplates(baseChannel?.templates),
1465
+ baseChannel?.catalog,
1466
+ catalogFromTemplates(pluginChannelTemplates),
1467
+ ...pluginChannelCatalogs,
1468
+ );
1448
1469
 
1449
1470
  cdcPkByTable = pkColumnByTableFromManifest(overrides?.manifest ?? options.manifest);
1450
1471
 
@@ -1465,7 +1486,7 @@ export function oke(options: OkeOptions): OkeApp {
1465
1486
  stores: overrides?.stores ?? effectiveStores,
1466
1487
  channel: {
1467
1488
  ...(baseChannel ?? {}),
1468
- templates: [...(baseChannel?.templates ?? []), ...pluginChannelTemplates],
1489
+ templates: mergedTemplates,
1469
1490
  ...(mergedCatalog ? { catalog: mergedCatalog } : {}),
1470
1491
  defaultLocale:
1471
1492
  baseChannel?.defaultLocale ??
@@ -1899,6 +1920,7 @@ export function oke(options: OkeOptions): OkeApp {
1899
1920
  },
1900
1921
  );
1901
1922
  if (inner.failure) return inner.failure;
1923
+ if (inner.ctx.error !== undefined) throw inner.ctx.error;
1902
1924
  return inner.output;
1903
1925
  },
1904
1926
  });
@@ -1965,7 +1987,8 @@ export function oke(options: OkeOptions): OkeApp {
1965
1987
  journalSession?.rewind();
1966
1988
  return flowDef.do(input as never, fx);
1967
1989
  };
1968
- const output = await (flowDef.retry ? fxRetry(run, flowDef.retry) : run());
1990
+ const raw = await (flowDef.retry ? fxRetry(run, flowDef.retry) : run());
1991
+ const output = isFlowFailure(raw) ? raw : await projectFlowOut(flowDef.out, raw);
1969
1992
  if (!isFlowFailure(output) && cache && storeRt && cacheEffects) {
1970
1993
  const ledgerFx = cache.effectsFromLedger(ledger.entries);
1971
1994
  const writeEffects: Effects = {
@@ -2127,6 +2150,9 @@ export function oke(options: OkeOptions): OkeApp {
2127
2150
  failure: result.failure,
2128
2151
  response: result.response,
2129
2152
  ctx: result.ctx,
2153
+ get error() {
2154
+ return result.ctx.error;
2155
+ },
2130
2156
  fx,
2131
2157
  cache: cacheDimensionOf(telemetry),
2132
2158
  durationMs,
@@ -2311,6 +2337,7 @@ export function oke(options: OkeOptions): OkeApp {
2311
2337
  response.headers.set("accept-query", '"application/json"');
2312
2338
  }
2313
2339
  if (shouldLogDevRequests()) {
2340
+ const envelope = await failureEnvelopeFromResponse(response);
2314
2341
  logDevRequest({
2315
2342
  surface: currentDevSurface(),
2316
2343
  method,
@@ -2319,7 +2346,8 @@ export function oke(options: OkeOptions): OkeApp {
2319
2346
  runId: runLabel,
2320
2347
  status: response.status,
2321
2348
  ms: Math.round(performance.now() - started),
2322
- detail: await failureDetailFromResponse(response),
2349
+ detail: envelope.detail,
2350
+ errorCode: envelope.code,
2323
2351
  });
2324
2352
  }
2325
2353
  const jcb = loadJsonCodeBlock();
@@ -2535,6 +2563,7 @@ export function oke(options: OkeOptions): OkeApp {
2535
2563
  flowDef.triggers[0] ?? ({ kind: "internal" } satisfies InternalTrigger);
2536
2564
  const result = await execute(flowDef, input, trigger);
2537
2565
  if (result.failure) return result.failure;
2566
+ if (result.ctx.error !== undefined) throw result.ctx.error;
2538
2567
  return result.output;
2539
2568
  },
2540
2569
  resolveMcpTool(name) {
@@ -2616,28 +2645,5 @@ function redactArchivedFields(input: unknown, fields: readonly string[] | undefi
2616
2645
  return obj;
2617
2646
  }
2618
2647
 
2619
- /**
2620
- * Deep-merge channel template catalogs (later parts win per locale).
2621
- *
2622
- * @param parts - Catalog fragments (undefined skipped)
2623
- */
2624
- function mergeTemplateCatalogs(
2625
- ...parts: readonly (TemplateCatalog | undefined)[]
2626
- ): TemplateCatalog | undefined {
2627
- const out: Record<
2628
- string,
2629
- Record<string, { readonly subject?: string; readonly text?: string; readonly html?: string }>
2630
- > = {};
2631
- let any = false;
2632
- for (const part of parts) {
2633
- if (!part) continue;
2634
- any = true;
2635
- for (const [template, locales] of Object.entries(part)) {
2636
- out[template] = { ...(out[template] ?? {}), ...locales };
2637
- }
2638
- }
2639
- return any ? out : undefined;
2640
- }
2641
-
2642
2648
  /** @internal expose smart router type for tests */
2643
2649
  export type { HttpTrigger, SmartRouter };
@@ -13,7 +13,7 @@
13
13
  */
14
14
 
15
15
  import { describe, expect, test } from "bun:test";
16
- import { channel } from "../elements/channel/declare.ts";
16
+ import { channel, resetChannelTemplates } from "../elements/channel/declare.ts";
17
17
  import { clock } from "../elements/clock/declare.ts";
18
18
  import { gate } from "../elements/gate/declare.ts";
19
19
  import { signal } from "../elements/signal/declare.ts";
@@ -291,4 +291,29 @@ describe("oke() auto-registry — stores/secrets/signals/clocks/gates/channel.te
291
291
  const json = (await res.json()) as { data: { summary: string } };
292
292
  expect(json.data.summary).toBe("one-line summary");
293
293
  });
294
+
295
+ test("channel.template({ catalog }) drains into oke() without channel.catalog bag", () => {
296
+ resetBindings();
297
+ resetChannelTemplates();
298
+ const mail = channel.email({ from: "Drain <drain@localhost>" });
299
+ mail.template("welcome-drain", {
300
+ locales: ["en"],
301
+ catalog: {
302
+ en: { subject: "Hi {{name}}", text: "Hello {{name}}" },
303
+ },
304
+ });
305
+ const sms = channel.sms();
306
+ sms.template("ops.drain", {
307
+ catalog: { en: { text: "Disk {{pct}}%" } },
308
+ });
309
+
310
+ const app = oke({
311
+ name: "catalog-drain",
312
+ autoBoot: false,
313
+ startScheduler: false,
314
+ });
315
+
316
+ expect(app.$options.channel?.catalog?.["welcome-drain"]?.en?.subject).toBe("Hi {{name}}");
317
+ expect(app.$options.channel?.catalog?.["ops.drain"]?.en?.text).toBe("Disk {{pct}}%");
318
+ });
294
319
  });
@@ -804,7 +804,7 @@ async function tryListFlowsUnits(rootDir: string): Promise<readonly string[] | u
804
804
  /**
805
805
  * Confirm every `src/flows/<unit>` folder on disk actually reached this
806
806
  * boot's adopted flows — the disk-file counterpart of a stale/missing
807
- * generated `.adopt()` barrel (`src/flows/generated.ts`). A folder present
807
+ * generated `.adopt()` barrel (`src/flows/index.ts`). A folder present
808
808
  * on disk with zero adopted flows under that unit means the barrel wasn't
809
809
  * regenerated (or was hand-edited) after the folder was added.
810
810
  *
@@ -846,7 +846,7 @@ export async function assertAdoptBarrelFresh(
846
846
  staleAdoptBarrelWarned = true;
847
847
  emitBootWarn(
848
848
  `oke boot: src/flows/${missing[0]} exists on disk but adopted no flows — the ` +
849
- '.adopt() barrel ("src/flows/generated.ts") is stale. Run `oke dev` or `oke build` ' +
849
+ '.adopt() barrel ("src/flows/index.ts") is stale. Run `oke dev` or `oke build` ' +
850
850
  "to regenerate it — dev+compose/prod refuse to boot this way.",
851
851
  );
852
852
  }
@@ -16,7 +16,12 @@ export interface BoundaryContract<
16
16
  > {
17
17
  /** Request / args schema (Standard Schema). */
18
18
  readonly in?: ISchema;
19
- /** Success reply schema (Manifest + typed client; not runtime-validated). */
19
+ /**
20
+ * Success reply schema (Manifest + typed client). Success values are
21
+ * projected onto it (`fx.json.create(row)` / `return row`) — Date timestamps
22
+ * become ISO-8601; extra keys strip when parse succeeds. Miss is not a client
23
+ * error.
24
+ */
20
25
  readonly out?: OSchema;
21
26
  /** Typed domain errors (`fx.fail`). */
22
27
  readonly errors?: E;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Built-in failure HTTP status map.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { fail } from "./errors.ts";
7
+ import { httpStatusForFailure, statusForBuiltinError } from "./builtin-errors.ts";
8
+ import { encodeExecuteResult, statusForFailure } from "../compiler/response.ts";
9
+
10
+ describe("statusForBuiltinError", () => {
11
+ test("maps REST codes", () => {
12
+ expect(statusForBuiltinError("NotFound")).toBe(404);
13
+ expect(statusForBuiltinError("Conflict")).toBe(409);
14
+ expect(statusForBuiltinError("ForeignKey")).toBe(409);
15
+ expect(statusForBuiltinError("Unauthorized")).toBe(401);
16
+ expect(statusForBuiltinError("Forbidden")).toBe(403);
17
+ expect(statusForBuiltinError("RateLimited")).toBe(429);
18
+ expect(statusForBuiltinError("AuthRateLimited")).toBe(429);
19
+ expect(statusForBuiltinError("ValidationError")).toBe(422);
20
+ expect(statusForBuiltinError("UnsupportedMediaType")).toBe(415);
21
+ expect(statusForBuiltinError("ServiceUnavailable")).toBe(503);
22
+ expect(statusForBuiltinError("InternalError")).toBe(500);
23
+ expect(statusForBuiltinError("AuthFailed")).toBe(400);
24
+ expect(statusForBuiltinError("InvalidQuery")).toBe(400);
25
+ });
26
+
27
+ test("DatabaseError status follows data.reason", () => {
28
+ expect(statusForBuiltinError("DatabaseError", { reason: "not_null" })).toBe(422);
29
+ expect(statusForBuiltinError("DatabaseError", { reason: "check" })).toBe(422);
30
+ expect(statusForBuiltinError("DatabaseError", { reason: "invalid" })).toBe(422);
31
+ expect(statusForBuiltinError("DatabaseError", { reason: "too_long" })).toBe(422);
32
+ expect(statusForBuiltinError("DatabaseError", { reason: "out_of_range" })).toBe(422);
33
+ expect(statusForBuiltinError("DatabaseError", { reason: "retryable" })).toBe(503);
34
+ expect(statusForBuiltinError("DatabaseError", { reason: "unknown" })).toBe(500);
35
+ expect(statusForBuiltinError("DatabaseError", {})).toBe(500);
36
+ });
37
+
38
+ test("domain codes are undefined so HTTP defaults to 400", () => {
39
+ expect(statusForBuiltinError("OutOfStock")).toBeUndefined();
40
+ expect(httpStatusForFailure("OutOfStock")).toBe(400);
41
+ expect(httpStatusForFailure("OKE1110")).toBe(500);
42
+ });
43
+ });
44
+
45
+ describe("statusForFailure", () => {
46
+ test("reads DatabaseError reason from the envelope", () => {
47
+ expect(statusForFailure(fail("DatabaseError", { reason: "not_null" }))).toBe(422);
48
+ expect(statusForFailure(fail("NotFound", { id: "n1" }))).toBe(404);
49
+ expect(statusForFailure(fail("FlightFull", { seatsLeft: 0 }))).toBe(400);
50
+ });
51
+ });
52
+
53
+ describe("encodeExecuteResult InternalError", () => {
54
+ test("never copies the thrown Error.message", async () => {
55
+ const res = await encodeExecuteResult({ error: new Error("secret leak") });
56
+ expect(res.status).toBe(500);
57
+ const body = (await res.json()) as {
58
+ data: null;
59
+ error: { code: string; message?: string; data?: unknown };
60
+ };
61
+ expect(body.error.code).toBe("InternalError");
62
+ expect(JSON.stringify(body)).not.toContain("secret leak");
63
+ });
64
+
65
+ test("maps leftover unique SQLSTATE to Conflict", async () => {
66
+ const res = await encodeExecuteResult({
67
+ error: {
68
+ code: "23505",
69
+ message: "duplicate key value violates unique constraint",
70
+ detail: "Key (email)=(a@b.c) already exists.",
71
+ },
72
+ });
73
+ expect(res.status).toBe(409);
74
+ const body = (await res.json()) as { error: { code: string } };
75
+ expect(body.error.code).toBe("Conflict");
76
+ expect(JSON.stringify(body)).not.toContain("a@b.c");
77
+ });
78
+
79
+ test("maps leftover Redis connection failure to ServiceUnavailable", async () => {
80
+ const res = await encodeExecuteResult({
81
+ error: Object.assign(new Error("Connection closed"), { code: "ECONNREFUSED" }),
82
+ });
83
+ expect(res.status).toBe(503);
84
+ const body = (await res.json()) as { error: { code: string } };
85
+ expect(body.error.code).toBe("ServiceUnavailable");
86
+ expect(JSON.stringify(body)).not.toContain("Connection closed");
87
+ });
88
+
89
+ test("maps leftover S3 AccessDenied to Forbidden", async () => {
90
+ const res = await encodeExecuteResult({
91
+ error: Object.assign(new Error("Access Denied"), { code: "AccessDenied", status: 403 }),
92
+ });
93
+ expect(res.status).toBe(403);
94
+ const body = (await res.json()) as { error: { code: string } };
95
+ expect(body.error.code).toBe("Forbidden");
96
+ expect(JSON.stringify(body)).not.toContain("Access Denied");
97
+ });
98
+
99
+ test("leaves WRONGTYPE as InternalError", async () => {
100
+ const res = await encodeExecuteResult({
101
+ error: new Error("WRONGTYPE Operation against a key holding the wrong kind of value"),
102
+ });
103
+ expect(res.status).toBe(500);
104
+ const body = (await res.json()) as { error: { code: string } };
105
+ expect(body.error.code).toBe("InternalError");
106
+ expect(JSON.stringify(body)).not.toContain("WRONGTYPE");
107
+ });
108
+
109
+ test("maps exhausted serialization failure to ServiceUnavailable", async () => {
110
+ const res = await encodeExecuteResult({
111
+ error: { code: "40001", message: "could not serialize access" },
112
+ });
113
+ expect(res.status).toBe(503);
114
+ const body = (await res.json()) as { error: { code: string } };
115
+ expect(body.error.code).toBe("ServiceUnavailable");
116
+ });
117
+ });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Built-in flow-boundary error codes — HTTP status + payload types.
3
+ *
4
+ * Always-on for `fx.fail` helpers and the typed client. Domain codes
5
+ * (`OutOfStock`, `FlightFull`) stay on the exposure `errors:` bag.
6
+ * No Zod — this module sits on the kernel edge graph.
7
+ */
8
+
9
+ /** Built-in codes with a fixed HTTP status (not `DatabaseError`). */
10
+ export const BUILTIN_ERROR_STATUS = {
11
+ ValidationError: 422,
12
+ Unauthorized: 401,
13
+ Forbidden: 403,
14
+ NotFound: 404,
15
+ Conflict: 409,
16
+ ForeignKey: 409,
17
+ UnsupportedMediaType: 415,
18
+ RateLimited: 429,
19
+ AuthRateLimited: 429,
20
+ InvalidQuery: 400,
21
+ AuthFailed: 400,
22
+ InternalError: 500,
23
+ ServiceUnavailable: 503,
24
+ } as const;
25
+
26
+ /** Built-in error code (including `DatabaseError`). */
27
+ export type BuiltinErrorCode = keyof typeof BUILTIN_ERROR_STATUS | "DatabaseError";
28
+
29
+ /** `DatabaseError.data.reason` — encoder maps these to HTTP status. */
30
+ export type DatabaseErrorReason =
31
+ | "not_null"
32
+ | "check"
33
+ | "invalid"
34
+ | "too_long"
35
+ | "out_of_range"
36
+ | "retryable"
37
+ | "unknown";
38
+
39
+ /**
40
+ * One `ValidationError` issue — same shape as the validation layer,
41
+ * inlined so this module stays off that graph.
42
+ */
43
+ export type BuiltinValidationIssue = {
44
+ readonly message: string;
45
+ readonly path: ReadonlyArray<string | number>;
46
+ };
47
+
48
+ /** Loose identity / constraint bag used by several built-in codes. */
49
+ export type BuiltinErrorBag = {
50
+ readonly id?: string;
51
+ readonly key?: string;
52
+ readonly code?: string;
53
+ readonly reason?: string;
54
+ readonly gate?: string;
55
+ readonly constraint?: string;
56
+ readonly table?: string;
57
+ readonly column?: string;
58
+ readonly sqlstate?: string;
59
+ readonly retryAfterMs?: number;
60
+ readonly retryAfter?: number;
61
+ readonly contentType?: string;
62
+ readonly [key: string]: unknown;
63
+ };
64
+
65
+ /**
66
+ * Built-in error code → payload. Client unions this with declared `errors:`.
67
+ */
68
+ export type BuiltinErrorMap = {
69
+ readonly ValidationError: { readonly issues: readonly BuiltinValidationIssue[] };
70
+ readonly Unauthorized: BuiltinErrorBag;
71
+ readonly Forbidden: BuiltinErrorBag;
72
+ readonly NotFound: BuiltinErrorBag;
73
+ readonly Conflict: BuiltinErrorBag;
74
+ readonly ForeignKey: BuiltinErrorBag;
75
+ readonly UnsupportedMediaType: BuiltinErrorBag;
76
+ readonly RateLimited: { readonly retryAfterMs?: number };
77
+ readonly AuthRateLimited: BuiltinErrorBag;
78
+ readonly InvalidQuery: BuiltinErrorBag;
79
+ readonly AuthFailed: BuiltinErrorBag;
80
+ readonly DatabaseError: {
81
+ readonly reason: DatabaseErrorReason;
82
+ readonly sqlstate?: string;
83
+ readonly constraint?: string;
84
+ readonly table?: string;
85
+ readonly column?: string;
86
+ };
87
+ readonly InternalError: Record<string, never>;
88
+ readonly ServiceUnavailable: { readonly retryAfter?: number };
89
+ };
90
+
91
+ /**
92
+ * HTTP status for a built-in failure code, or `undefined` for domain codes.
93
+ *
94
+ * `DatabaseError` uses `data.reason`: `retryable` → 503, `unknown` / missing
95
+ * → 500, any other reason (`not_null`, `check`, `invalid`, …) → 422.
96
+ *
97
+ * @param code - Failure `error.code`
98
+ * @param data - Failure `error.data` (reason for `DatabaseError`)
99
+ */
100
+ export function statusForBuiltinError(code: string, data?: unknown): number | undefined {
101
+ if (code === "DatabaseError") {
102
+ const reason = databaseErrorReason(data);
103
+ if (reason === "retryable") return 503;
104
+ if (reason !== undefined && reason !== "unknown") return 422;
105
+ return 500;
106
+ }
107
+ if (Object.prototype.hasOwnProperty.call(BUILTIN_ERROR_STATUS, code)) {
108
+ return BUILTIN_ERROR_STATUS[code as keyof typeof BUILTIN_ERROR_STATUS];
109
+ }
110
+ return undefined;
111
+ }
112
+
113
+ /**
114
+ * HTTP status for a typed flow failure. Domain codes default to 400.
115
+ *
116
+ * @param code - Failure code
117
+ * @param data - Failure payload
118
+ */
119
+ export function httpStatusForFailure(code: string, data?: unknown): number {
120
+ const mapped = statusForBuiltinError(code, data);
121
+ if (mapped !== undefined) return mapped;
122
+ return code.startsWith("OKE") ? 500 : 400;
123
+ }
124
+
125
+ function databaseErrorReason(data: unknown): string | undefined {
126
+ if (data === null || typeof data !== "object") return undefined;
127
+ const reason = (data as { reason?: unknown }).reason;
128
+ return typeof reason === "string" ? reason : undefined;
129
+ }