okengine 0.20.0 → 0.21.1

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 (104) hide show
  1. package/package.json +3 -7
  2. package/site/content/docs/client/calling.mdx +117 -29
  3. package/site/content/docs/client/index.mdx +3 -3
  4. package/site/content/docs/elements/flow/http.mdx +18 -15
  5. package/site/content/docs/elements/flow/index.mdx +26 -18
  6. package/site/content/docs/elements/gate/tenancy.mdx +1 -1
  7. package/site/content/docs/elements/store/files.mdx +11 -5
  8. package/site/content/docs/elements/store/index.mdx +2 -3
  9. package/site/content/docs/elements/store/kv.mdx +8 -2
  10. package/site/content/docs/elements/store/sql.mdx +9 -6
  11. package/site/content/docs/elements/vault/index.mdx +11 -8
  12. package/site/content/docs/elements/vault/secrets.mdx +6 -3
  13. package/site/content/docs/index.mdx +1 -1
  14. package/site/content/docs/recipes/rustfs.mdx +1 -1
  15. package/site/content/docs/reference/configuration.mdx +1 -1
  16. package/site/content/docs/reference/errors.mdx +199 -25
  17. package/site/content/docs/reference/fx.mdx +6 -1
  18. package/site/content/docs/understand/the-architecture.mdx +2 -2
  19. package/site/content/docs/understand/try-it.mdx +758 -25
  20. package/src/cli/dev-app-runner.ts +2 -1
  21. package/src/cli/dev.test.ts +105 -2
  22. package/src/cli/dev.ts +37 -2
  23. package/src/cli/start.ts +2 -1
  24. package/src/client/create.ts +14 -27
  25. package/src/client/explain.test.ts +252 -0
  26. package/src/client/explain.ts +272 -0
  27. package/src/client/live.test.ts +44 -0
  28. package/src/client/live.ts +44 -101
  29. package/src/client/notes-contract.test.ts +10 -0
  30. package/src/client/sse.ts +26 -68
  31. package/src/client/stream.ts +25 -67
  32. package/src/client/transport.test.ts +67 -0
  33. package/src/client/transport.ts +51 -112
  34. package/src/client/types.ts +17 -6
  35. package/src/client/wire.ts +119 -0
  36. package/src/client-react/live-resource.ts +6 -2
  37. package/src/compiler/aot.ts +3 -32
  38. package/src/compiler/dynamic.ts +13 -11
  39. package/src/compiler/interpret.ts +45 -0
  40. package/src/compiler/response.ts +17 -27
  41. package/src/console/server/invoke-user-flow.test.ts +8 -2
  42. package/src/console/server/invoke-user-flow.ts +12 -18
  43. package/src/console/server/security.gate.test.ts +1 -1
  44. package/src/console/ui-next/dist/assets/{access-page-DFLu0wTA.js → access-page-Bgt9bq2r.js} +1 -1
  45. package/src/console/ui-next/dist/assets/{agent-disclosure-DGscxaF5.js → agent-disclosure-B43CXZZR.js} +1 -1
  46. package/src/console/ui-next/dist/assets/{cache-glyph-BGmRZk7d.js → cache-glyph-92uM5MO7.js} +1 -1
  47. package/src/console/ui-next/dist/assets/{call-pii-button--feUYxvG.js → call-pii-button-DtGVPtcs.js} +1 -1
  48. package/src/console/ui-next/dist/assets/{collapsible-JWvpaiGY.js → collapsible-DN7l6zmC.js} +1 -1
  49. package/src/console/ui-next/dist/assets/{duration-tone-D9yCJG4n.js → duration-tone-BmIR9FV8.js} +1 -1
  50. package/src/console/ui-next/dist/assets/{flows-page-Bs6MD9GB.js → flows-page-BMHs-IzK.js} +1 -1
  51. package/src/console/ui-next/dist/assets/{highlighted-json-xH8MrEnv.js → highlighted-json-BlAEVgNW.js} +1 -1
  52. package/src/console/ui-next/dist/assets/{http-method-C4vB6ZIw.js → http-method-BDf7OAHv.js} +1 -1
  53. package/src/console/ui-next/dist/assets/{index-yTCY4AcS.js → index-BKpaes3n.js} +3 -3
  54. package/src/console/ui-next/dist/assets/{observability-page-BxJ3R6dU.js → observability-page-WnVLI-0j.js} +1 -1
  55. package/src/console/ui-next/dist/assets/{replica-lag-QRKB_IE8.js → replica-lag-B3GLNVfF.js} +1 -1
  56. package/src/console/ui-next/dist/assets/{request-meta-DqZ-fMu5.js → request-meta-DMbnAe3f.js} +1 -1
  57. package/src/console/ui-next/dist/assets/{store-page-Dixb6L7a.js → store-page-KvFDingJ.js} +1 -1
  58. package/src/console/ui-next/dist/assets/{trace-detail-sheet-CazhjtiU.js → trace-detail-sheet-Htk8Cm9t.js} +1 -1
  59. package/src/console/ui-next/dist/assets/{tree-expand-toggle-DlnqYKfr.js → tree-expand-toggle-CFMPWX4f.js} +1 -1
  60. package/src/console/ui-next/dist/assets/{units-page-BXTLjU2-.js → units-page-C4NdNuxP.js} +1 -1
  61. package/src/console/ui-next/dist/assets/{vault-page-39KR__bc.js → vault-page-CBYl0LW_.js} +1 -1
  62. package/src/console/ui-next/dist/index.html +1 -1
  63. package/src/docker/docker.test.ts +3 -3
  64. package/src/docker/images-config.test.ts +4 -4
  65. package/src/docker/stack-id.test.ts +1 -1
  66. package/src/elements/store/files-errors.test.ts +149 -0
  67. package/src/elements/store/files-errors.ts +189 -0
  68. package/src/elements/store/kv-errors.test.ts +98 -0
  69. package/src/elements/store/kv-errors.ts +139 -0
  70. package/src/elements/store/resource.ts +11 -7
  71. package/src/elements/store/runtime.ts +18 -13
  72. package/src/elements/store/sql-errors.test.ts +197 -0
  73. package/src/elements/store/sql-errors.ts +294 -0
  74. package/src/elements/store/sql-session.test.ts +52 -0
  75. package/src/elements/store/sql-session.ts +26 -4
  76. package/src/elements/store/store-errors.ts +47 -0
  77. package/src/http.ts +9 -1
  78. package/src/i18n/catalogs/ar.ts +18 -0
  79. package/src/i18n/catalogs/en.ts +18 -0
  80. package/src/index.ts +9 -1
  81. package/src/kernel/app.ts +23 -4
  82. package/src/kernel/builtin-errors.test.ts +117 -0
  83. package/src/kernel/builtin-errors.ts +129 -0
  84. package/src/kernel/call.test.ts +182 -0
  85. package/src/kernel/client-descriptor.test.ts +78 -0
  86. package/src/kernel/client-descriptor.ts +23 -0
  87. package/src/kernel/errors-text.ts +99 -0
  88. package/src/kernel/errors-vault.ts +16 -0
  89. package/src/kernel/errors.registry.test.ts +7 -0
  90. package/src/kernel/errors.ts +184 -159
  91. package/src/kernel/fail-helpers.ts +34 -0
  92. package/src/kernel/fx-sql-handle.ts +305 -0
  93. package/src/kernel/fx.test.ts +8 -0
  94. package/src/kernel/fx.ts +49 -335
  95. package/src/kernel/index.ts +12 -1
  96. package/src/kernel/json-result.ts +59 -0
  97. package/src/kernel/project-out.ts +6 -1
  98. package/src/okid-extended.ts +175 -0
  99. package/src/okid-shared.ts +103 -0
  100. package/src/okid.ts +30 -213
  101. package/src/release/build-lib.ts +9 -0
  102. package/src/runtime/dev-request-log.ts +29 -11
  103. package/src/term.test.ts +76 -0
  104. package/src/term.ts +166 -3
package/src/kernel/app.ts CHANGED
@@ -17,7 +17,6 @@ import {
17
17
  type RoutesFromNamespace,
18
18
  type RuntimeRouteMap,
19
19
  } from "./adopt-routes.ts";
20
- import { buildClientDescriptor } from "./client-descriptor.ts";
21
20
  import { liveExposureKey, liveGatesKey, liveMatchKeyFromPath } from "./live-http.ts";
22
21
  // `./boot.ts` pulls in every element + driver module (vault, store, signal,
23
22
  // clock, gate, channel, ai, runs) — a type-only import here keeps that whole
@@ -61,7 +60,7 @@ import type {
61
60
  } from "./fx.ts";
62
61
  import {
63
62
  currentDevSurface,
64
- failureDetailFromResponse,
63
+ failureEnvelopeFromResponse,
65
64
  logDevRequest,
66
65
  shouldLogDevRequests,
67
66
  } from "../runtime/dev-request-log.ts";
@@ -203,6 +202,14 @@ function loadJsonCodeBlock(): typeof import("../runtime/json-code-block.ts") {
203
202
  return lazyRequire(`${import.meta.dir}/../runtime`, ["json", "code", "block"].join("-"));
204
203
  }
205
204
 
205
+ /**
206
+ * Route descriptor for `GET /_oke/client.json`.
207
+ * A static import evaluates Zod on every HTTP cold start; load it on first fetch.
208
+ */
209
+ function loadClientDescriptor(): typeof import("./client-descriptor.ts") {
210
+ return lazyRequire(import.meta.dir, ["client", "descriptor"].join("-"));
211
+ }
212
+
206
213
  /** Options for {@link oke}. */
207
214
  export interface OkeOptions {
208
215
  /** Application name (Manifest `app`). */
@@ -351,6 +358,11 @@ export interface ExecuteResult {
351
358
  readonly failure: PipelineResult["failure"];
352
359
  readonly response: Response | undefined;
353
360
  readonly ctx: InvocationContext;
361
+ /**
362
+ * Derived projection of {@link InvocationContext.error} — never a separate
363
+ * store. Always `=== ctx.error` by reference.
364
+ */
365
+ readonly error: unknown;
354
366
  readonly fx: Fx;
355
367
  /** Wide-event cache dimension from this invocation's telemetry. */
356
368
  readonly cache: "hit" | "miss" | "none";
@@ -1915,6 +1927,7 @@ export function oke(options: OkeOptions): OkeApp {
1915
1927
  },
1916
1928
  );
1917
1929
  if (inner.failure) return inner.failure;
1930
+ if (inner.ctx.error !== undefined) throw inner.ctx.error;
1918
1931
  return inner.output;
1919
1932
  },
1920
1933
  });
@@ -2144,6 +2157,9 @@ export function oke(options: OkeOptions): OkeApp {
2144
2157
  failure: result.failure,
2145
2158
  response: result.response,
2146
2159
  ctx: result.ctx,
2160
+ get error() {
2161
+ return result.ctx.error;
2162
+ },
2147
2163
  fx,
2148
2164
  cache: cacheDimensionOf(telemetry),
2149
2165
  durationMs,
@@ -2328,6 +2344,7 @@ export function oke(options: OkeOptions): OkeApp {
2328
2344
  response.headers.set("accept-query", '"application/json"');
2329
2345
  }
2330
2346
  if (shouldLogDevRequests()) {
2347
+ const envelope = await failureEnvelopeFromResponse(response);
2331
2348
  logDevRequest({
2332
2349
  surface: currentDevSurface(),
2333
2350
  method,
@@ -2336,7 +2353,8 @@ export function oke(options: OkeOptions): OkeApp {
2336
2353
  runId: runLabel,
2337
2354
  status: response.status,
2338
2355
  ms: Math.round(performance.now() - started),
2339
- detail: await failureDetailFromResponse(response),
2356
+ detail: envelope.detail,
2357
+ errorCode: envelope.code,
2340
2358
  });
2341
2359
  }
2342
2360
  const jcb = loadJsonCodeBlock();
@@ -2370,7 +2388,7 @@ export function oke(options: OkeOptions): OkeApp {
2370
2388
  }
2371
2389
 
2372
2390
  if (method === "GET" && url.pathname === "/_oke/client.json") {
2373
- const descriptor = buildClientDescriptor(routes, flowsByName);
2391
+ const descriptor = loadClientDescriptor().buildClientDescriptor(routes, flowsByName);
2374
2392
  return respond(
2375
2393
  new Response(JSON.stringify(descriptor), {
2376
2394
  headers: { "content-type": "application/json" },
@@ -2552,6 +2570,7 @@ export function oke(options: OkeOptions): OkeApp {
2552
2570
  flowDef.triggers[0] ?? ({ kind: "internal" } satisfies InternalTrigger);
2553
2571
  const result = await execute(flowDef, input, trigger);
2554
2572
  if (result.failure) return result.failure;
2573
+ if (result.ctx.error !== undefined) throw result.ctx.error;
2555
2574
  return result.output;
2556
2575
  },
2557
2576
  resolveMcpTool(name) {
@@ -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
+ }
@@ -1,6 +1,8 @@
1
1
  import { describe, expect, test, beforeEach } from "bun:test";
2
2
  import { oke } from "./app.ts";
3
+ import { fail } from "./fail-helpers.ts";
3
4
  import { flow, resetFlowSeq } from "./flow.ts";
5
+ import { isFlowFailure } from "./hooks.ts";
4
6
  import { on, resetBindings } from "./on.ts";
5
7
  import { http } from "./triggers.ts";
6
8
 
@@ -120,3 +122,183 @@ describe("fx.call — untriggered flows", () => {
120
122
  expect(runs).toBe(2);
121
123
  });
122
124
  });
125
+
126
+ describe("error pipeline — RPC / call propagation", () => {
127
+ test("regression: unhandled RPC exception must never become 204 No Content", async () => {
128
+ const boom = flow("math.boom", {
129
+ do: () => {
130
+ throw new Error("sensitive leak");
131
+ },
132
+ });
133
+ const app = oke({ autoBoot: false, name: "rpc-boom" }).adopt(boom);
134
+
135
+ const res = await app.fetch(
136
+ new Request("http://localhost/_oke/math/boom", {
137
+ method: "POST",
138
+ headers: { "content-type": "application/json" },
139
+ body: "{}",
140
+ }),
141
+ );
142
+
143
+ expect(res.status).toBe(500);
144
+ expect(res.status).not.toBe(204);
145
+ const body = (await res.json()) as {
146
+ data: unknown;
147
+ error: { code: string; data: unknown; message?: string };
148
+ };
149
+ expect(body.data).toBeNull();
150
+ expect(body.error.code).toBe("InternalError");
151
+ expect(body.error.data).toEqual({});
152
+ expect(body.error.message).toBe("Something went wrong. Try again.");
153
+ expect(JSON.stringify(body)).not.toContain("sensitive leak");
154
+ });
155
+
156
+ test("RPC store auto-map unique violation is Conflict 409", async () => {
157
+ const clash = flow("math.clash", {
158
+ do: () => {
159
+ throw Object.assign(new Error("duplicate key"), {
160
+ code: "23505",
161
+ constraint: "users_email_key",
162
+ });
163
+ },
164
+ });
165
+ const app = oke({ autoBoot: false, name: "rpc-clash" }).adopt(clash);
166
+
167
+ const res = await app.fetch(
168
+ new Request("http://localhost/_oke/math/clash", {
169
+ method: "POST",
170
+ headers: { "content-type": "application/json" },
171
+ body: "{}",
172
+ }),
173
+ );
174
+
175
+ expect(res.status).toBe(409);
176
+ const body = (await res.json()) as { error: { code: string } };
177
+ expect(body.error.code).toBe("Conflict");
178
+ });
179
+
180
+ test("ExecuteResult.error is a derived projection of ctx.error", async () => {
181
+ const boom = flow("math.boom", {
182
+ do: () => {
183
+ throw new Error("disk failed");
184
+ },
185
+ });
186
+ const app = oke({ autoBoot: false, name: "exec-err" }).adopt(boom);
187
+ const result = await app.execute(boom, {}, { kind: "internal" });
188
+
189
+ expect(result.failure).toBeUndefined();
190
+ expect(result.ctx.error).toBeDefined();
191
+ expect(result.error).toBe(result.ctx.error);
192
+ });
193
+
194
+ test("app.call returns FlowFailure value for controlled fail", async () => {
195
+ const missing = flow("notes.missing", {
196
+ do: () => fail.notFound({ id: "1" }),
197
+ });
198
+ const app = oke({ autoBoot: false, name: "call-fail" }).adopt(missing);
199
+ const out = await app.call(missing, {});
200
+ expect(isFlowFailure(out)).toBe(true);
201
+ if (isFlowFailure(out)) {
202
+ expect(out.error.code).toBe("NotFound");
203
+ }
204
+ });
205
+
206
+ test("app.call rethrows unhandled exception (never undefined)", async () => {
207
+ const boom = flow("notes.boom", {
208
+ do: () => {
209
+ throw new Error("disk failed");
210
+ },
211
+ });
212
+ const app = oke({ autoBoot: false, name: "call-throw" }).adopt(boom);
213
+ await expect(app.call(boom, {})).rejects.toThrow("disk failed");
214
+ });
215
+
216
+ test("fx.call returns FlowFailure value for controlled fail", async () => {
217
+ const child = flow("child.fail", {
218
+ do: () => fail.notFound({ id: "x" }),
219
+ });
220
+ on(
221
+ http.post("/run"),
222
+ flow("parent.run", {
223
+ effects: { calls: ["child.fail"] },
224
+ do: async (_input: Record<string, never>, fx) => {
225
+ const out = await fx.call("child.fail", {});
226
+ expect(isFlowFailure(out)).toBe(true);
227
+ return isFlowFailure(out) ? out : { unexpected: true };
228
+ },
229
+ }),
230
+ );
231
+ const app = oke({ autoBoot: false, name: "fx-fail" }).adopt(child);
232
+ const res = await app.fetch(
233
+ new Request("http://localhost/run", {
234
+ method: "POST",
235
+ headers: { "content-type": "application/json" },
236
+ body: "{}",
237
+ }),
238
+ );
239
+ expect(res.status).toBe(404);
240
+ const body = (await res.json()) as { error: { code: string } };
241
+ expect(body.error.code).toBe("NotFound");
242
+ });
243
+
244
+ test("fx.call rethrows when child throws", async () => {
245
+ const child = flow("child.boom", {
246
+ do: () => {
247
+ throw new Error("worker crash");
248
+ },
249
+ });
250
+ on(
251
+ http.post("/run"),
252
+ flow("parent.run", {
253
+ effects: { calls: ["child.boom"] },
254
+ do: async (_input: Record<string, never>, fx) => {
255
+ return fx.call("child.boom", {});
256
+ },
257
+ }),
258
+ );
259
+ const app = oke({ autoBoot: false, name: "fx-throw" }).adopt(child);
260
+ const res = await app.fetch(
261
+ new Request("http://localhost/run", {
262
+ method: "POST",
263
+ headers: { "content-type": "application/json" },
264
+ body: "{}",
265
+ }),
266
+ );
267
+ expect(res.status).toBe(500);
268
+ const body = (await res.json()) as { data: unknown; error: { code: string; message?: string } };
269
+ expect(body.data).toBeNull();
270
+ expect(body.error.code).toBe("InternalError");
271
+ expect(JSON.stringify(body)).not.toContain("worker crash");
272
+ });
273
+
274
+ test("nested fx.call chain propagates throw to outer boundary", async () => {
275
+ const deep = flow("deep.boom", {
276
+ do: () => {
277
+ throw new Error("deep crash");
278
+ },
279
+ });
280
+ const mid = flow("mid.call", {
281
+ effects: { calls: ["deep.boom"] },
282
+ do: async (_input: Record<string, never>, fx) => fx.call("deep.boom", {}),
283
+ });
284
+ on(
285
+ http.post("/run"),
286
+ flow("outer.run", {
287
+ effects: { calls: ["mid.call"] },
288
+ do: async (_input: Record<string, never>, fx) => fx.call("mid.call", {}),
289
+ }),
290
+ );
291
+ const app = oke({ autoBoot: false, name: "nested-throw" }).adopt(deep).adopt(mid);
292
+ const res = await app.fetch(
293
+ new Request("http://localhost/run", {
294
+ method: "POST",
295
+ headers: { "content-type": "application/json" },
296
+ body: "{}",
297
+ }),
298
+ );
299
+ expect(res.status).toBe(500);
300
+ const body = (await res.json()) as { error: { code: string } };
301
+ expect(body.error.code).toBe("InternalError");
302
+ expect(JSON.stringify(body)).not.toContain("deep crash");
303
+ });
304
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Client descriptor printing — schema type strings are cached per object.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { z } from "zod";
7
+ import type { AnyFlowDef } from "./flow.ts";
8
+ import type { RuntimeRouteMap } from "./adopt-routes.ts";
9
+ import { buildClientDescriptor } from "./client-descriptor.ts";
10
+
11
+ const FLOW_COUNT = 64;
12
+
13
+ function fieldSchema() {
14
+ return z.object({
15
+ id: z.string().uuid(),
16
+ title: z.string().min(1).max(120),
17
+ tags: z.array(z.string()),
18
+ count: z.number().int().nonnegative(),
19
+ });
20
+ }
21
+
22
+ function fixture(): {
23
+ routes: RuntimeRouteMap;
24
+ flows: Map<string, AnyFlowDef>;
25
+ } {
26
+ const routes: RuntimeRouteMap = { app: {} };
27
+ const flows = new Map<string, AnyFlowDef>();
28
+ for (let i = 0; i < FLOW_COUNT; i++) {
29
+ const name = `flow${i}`;
30
+ routes.app![name] = { method: "GET", path: `/${name}` };
31
+ flows.set(`app.${name}`, {
32
+ in: fieldSchema(),
33
+ out: z.object({
34
+ id: z.string(),
35
+ title: z.string(),
36
+ createdAt: z.iso.datetime(),
37
+ }),
38
+ } as AnyFlowDef);
39
+ }
40
+ return { routes, flows };
41
+ }
42
+
43
+ describe("schemaToTsString cache", () => {
44
+ test("a second pass over the same schema objects skips reprinting them", () => {
45
+ const { routes, flows } = fixture();
46
+
47
+ const coldStart = performance.now();
48
+ const cold = buildClientDescriptor(routes, flows);
49
+ const coldMs = performance.now() - coldStart;
50
+
51
+ const warmStart = performance.now();
52
+ const warm = buildClientDescriptor(routes, flows);
53
+ const warmMs = performance.now() - warmStart;
54
+
55
+ expect(warm).toEqual(cold);
56
+ expect(cold.routes.app?.flow0?.in).toContain("title");
57
+ // Uncached toJSONSchema is the whole map. A cache hit is the map lookup.
58
+ expect(warmMs).toBeLessThan(coldMs / 4);
59
+ console.log(
60
+ `client descriptor ${FLOW_COUNT} flows cold=${coldMs.toFixed(1)}ms warm=${warmMs.toFixed(1)}ms`,
61
+ );
62
+
63
+ const previous = flows.get("app.flow0");
64
+ flows.set("app.flow0", {
65
+ in: z.object({ id: z.string(), extra: z.boolean() }),
66
+ out: previous?.out,
67
+ } as AnyFlowDef);
68
+
69
+ const touchedStart = performance.now();
70
+ const touched = buildClientDescriptor(routes, flows);
71
+ const touchedMs = performance.now() - touchedStart;
72
+
73
+ expect(touched.routes.app?.flow0?.in).toContain("extra");
74
+ expect(touched.routes.app?.flow1?.in).toBe(warm.routes.app?.flow1?.in);
75
+ expect(touchedMs).toBeLessThan(coldMs / 4);
76
+ console.log(`client descriptor one schema replaced=${touchedMs.toFixed(1)}ms`);
77
+ });
78
+ });
@@ -9,6 +9,15 @@ import type { AnyFlowDef } from "./flow.ts";
9
9
  import type { RuntimeRouteMap } from "./adopt-routes.ts";
10
10
  import { z } from "zod";
11
11
 
12
+ /**
13
+ * Printed type string per schema object.
14
+ *
15
+ * A flows-tree reload replaces the schema objects of the modules that
16
+ * actually changed. Every other flow keeps the same reference and hits
17
+ * this map instead of running `toJSONSchema` again.
18
+ */
19
+ const schemaTsCache = new WeakMap<object, string>();
20
+
12
21
  /**
13
22
  * Build a client descriptor from runtime routes + flow defs (type strings + stamps).
14
23
  *
@@ -62,6 +71,20 @@ export function buildClientDescriptor(
62
71
  */
63
72
  export function schemaToTsString(schema: unknown): string {
64
73
  if (schema === undefined || schema === null) return "unknown";
74
+ if (typeof schema !== "object" && typeof schema !== "function") return "unknown";
75
+ const cached = schemaTsCache.get(schema);
76
+ if (cached !== undefined) return cached;
77
+ const printed = printSchemaToTs(schema);
78
+ schemaTsCache.set(schema, printed);
79
+ return printed;
80
+ }
81
+
82
+ /**
83
+ * Convert one schema object to a TypeScript type string. Uncached.
84
+ *
85
+ * @param schema - Object or function schema
86
+ */
87
+ function printSchemaToTs(schema: object): string {
65
88
  try {
66
89
  if (hasToJSONSchema(schema)) {
67
90
  return jsonSchemaToTs(schema.toJSONSchema());