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
@@ -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,16 @@
1
+ /**
2
+ * OKE1510 — kept off the kernel edge graph.
3
+ *
4
+ * Vault boot gaps. Loaded via computed `import.meta.require` from
5
+ * {@link lookupOkeError}.
6
+ */
7
+
8
+ import type { OkeErrorDefinition } from "./errors.ts";
9
+
10
+ /** Vault contract has no value in any resolution layer (boot). */
11
+ export const VAULT_SECRET_MISSING: OkeErrorDefinition = {
12
+ code: 1510,
13
+ domain: "vault",
14
+ cause: "{count} secrets have no value in any resolution layer.",
15
+ fix: "Set each name (`oke vault set <name>`, or `.env.local`).",
16
+ };
@@ -20,6 +20,7 @@ import { CHANNEL_SCHEMA } from "./errors-channel.ts";
20
20
  import { FLOW_NAME_DUPLICATE, FLOW_UNNAMED } from "./errors-flow-name.ts";
21
21
  import { ONCE_SIGNAL_MULTI_FLOW } from "./errors-once-signal.ts";
22
22
  import { TENANT_NOT_MEMBER, TENANT_REQUIRED, TENANT_UNKNOWN_SCOPE } from "./errors-tenant.ts";
23
+ import { VAULT_SECRET_MISSING } from "./errors-vault.ts";
23
24
  import {
24
25
  assertCodesInDomainRanges,
25
26
  assertUniqueCodes,
@@ -65,8 +66,10 @@ describe("OKE error-code registry", () => {
65
66
  expect(files).toContain("errors-live-resume.ts");
66
67
  expect(files).toContain("errors-channel.ts");
67
68
  expect(files).toContain("errors-tenant.ts");
69
+ expect(files).toContain("errors-vault.ts");
68
70
  const codes = defs.map((d) => d.code);
69
71
  expect(codes).toContain(1210);
72
+ expect(codes).toContain(1510);
70
73
  expect(codes).toContain(1605);
71
74
  expect(codes).toContain(1810);
72
75
  expect(codes).toContain(1820);
@@ -160,6 +163,10 @@ describe("OKE error-code registry", () => {
160
163
  expect(lookupOkeError(1605)).toEqual(CHANNEL_SCHEMA);
161
164
  });
162
165
 
166
+ test("lookupOkeError finds the lazy VAULT_SECRET_MISSING entry", () => {
167
+ expect(lookupOkeError(1510)).toEqual(VAULT_SECRET_MISSING);
168
+ });
169
+
163
170
  test("lookupOkeError finds lazy tenant entries", () => {
164
171
  expect(lookupOkeError(1810)).toEqual(TENANT_REQUIRED);
165
172
  expect(lookupOkeError(1820)).toEqual(TENANT_NOT_MEMBER);
@@ -9,6 +9,7 @@
9
9
  import { docsUrl as absoluteDocsUrl } from "../docs-origin.ts";
10
10
  import { getActiveDefaultLocale, getActiveLocale } from "../i18n/locale-context.ts";
11
11
  import { lazyRequire } from "./lazy-require.ts";
12
+ import type { BuiltinErrorMap } from "./builtin-errors.ts";
12
13
 
13
14
  /** Catalogs — kept off the edge `fail` / `OKE_ERRORS` static graph. */
14
15
  function loadMessages(): typeof import("../i18n/messages.ts") {
@@ -144,6 +145,93 @@ export interface FailOptions {
144
145
  readonly message?: string;
145
146
  }
146
147
 
148
+ /**
149
+ * Callable `fail` plus built-in helpers (`fail.notFound`, `fail.forbidden`, …).
150
+ */
151
+ export interface FailFn {
152
+ /**
153
+ * Flow-boundary failure value (does not throw).
154
+ *
155
+ * @param code - Declared or built-in error code
156
+ * @param data - Error payload
157
+ * @param opts - Optional message override
158
+ */
159
+ <E>(code: string, data: E, opts?: FailOptions): FlowFailure<E>;
160
+ /** Missing row / object — HTTP 404. */
161
+ notFound(
162
+ data?: BuiltinErrorMap["NotFound"],
163
+ opts?: FailOptions,
164
+ ): FlowFailure<BuiltinErrorMap["NotFound"]>;
165
+ /** Not authenticated — HTTP 401. */
166
+ unauthorized(
167
+ data?: BuiltinErrorMap["Unauthorized"],
168
+ opts?: FailOptions,
169
+ ): FlowFailure<BuiltinErrorMap["Unauthorized"]>;
170
+ /** Authenticated but denied — HTTP 403. */
171
+ forbidden(
172
+ data?: BuiltinErrorMap["Forbidden"],
173
+ opts?: FailOptions,
174
+ ): FlowFailure<BuiltinErrorMap["Forbidden"]>;
175
+ /** Unique / exclusion conflict — HTTP 409. */
176
+ conflict(
177
+ data?: BuiltinErrorMap["Conflict"],
178
+ opts?: FailOptions,
179
+ ): FlowFailure<BuiltinErrorMap["Conflict"]>;
180
+ /** Foreign-key violation — HTTP 409. */
181
+ foreignKey(
182
+ data?: BuiltinErrorMap["ForeignKey"],
183
+ opts?: FailOptions,
184
+ ): FlowFailure<BuiltinErrorMap["ForeignKey"]>;
185
+ /** Rate budget exhausted — HTTP 429. */
186
+ rateLimited(
187
+ data?: BuiltinErrorMap["RateLimited"],
188
+ opts?: FailOptions,
189
+ ): FlowFailure<BuiltinErrorMap["RateLimited"]>;
190
+ /** Maintenance / connection — HTTP 503. */
191
+ serviceUnavailable(
192
+ data?: BuiltinErrorMap["ServiceUnavailable"],
193
+ opts?: FailOptions,
194
+ ): FlowFailure<BuiltinErrorMap["ServiceUnavailable"]>;
195
+ /** SQL / store constraint that is not unique or FK. */
196
+ database(
197
+ data: BuiltinErrorMap["DatabaseError"],
198
+ opts?: FailOptions,
199
+ ): FlowFailure<BuiltinErrorMap["DatabaseError"]>;
200
+ /** Unhandled throw — HTTP 500. Catalog message only. */
201
+ internal(
202
+ data?: BuiltinErrorMap["InternalError"],
203
+ opts?: FailOptions,
204
+ ): FlowFailure<BuiltinErrorMap["InternalError"]>;
205
+ }
206
+
207
+ /**
208
+ * Create a flow-boundary failure value (does not throw).
209
+ *
210
+ * When `opts.message` is omitted, attaches a localized message from the
211
+ * built-in / app catalogs (`errors.{code}.{reason}` → `errors.{code}`) using
212
+ * the active request locale. Custom codes with no catalog entry stay
213
+ * message-less.
214
+ *
215
+ * @param code - Declared error code from the flow's `errors` map
216
+ * @param data - Error payload
217
+ * @param opts - Optional message override
218
+ */
219
+ function failImpl<E>(code: string, data: E, opts?: FailOptions): FlowFailure<E> {
220
+ const message =
221
+ opts?.message !== undefined
222
+ ? opts.message
223
+ : loadFailureMessage().resolveFailureMessage(code, data);
224
+ const error: FlowErrorValue<E> = message !== undefined ? { code, data, message } : { code, data };
225
+ return { data: null, error };
226
+ }
227
+
228
+ /**
229
+ * Flow-boundary failure (callable). Built-in helpers live on the lazy
230
+ * `fail-helpers` chunk so they stay off the kernel edge graph — {@link createFx}
231
+ * and the public `okengine` export load that chunk.
232
+ */
233
+ export const fail: FailFn = failImpl as FailFn;
234
+
147
235
  /**
148
236
  * Permanent error registry. Codes are stable within their domain range after
149
237
  * the domain-range renumber; each entry declares `domain` for range guards.
@@ -239,7 +327,7 @@ export const OKE_ERRORS = {
239
327
  },
240
328
  /**
241
329
  * A `src/flows/<unit>` folder exists on disk but no adopted flow carries
242
- * that unit — the generated `.adopt()` barrel (`src/flows/generated.ts`)
330
+ * that unit — the generated `.adopt()` barrel (`src/flows/index.ts`)
243
331
  * is stale or was hand-edited. dev+compose / prod — never a silently-incomplete
244
332
  * route table in a deploy-shaped environment.
245
333
  */
@@ -247,7 +335,7 @@ export const OKE_ERRORS = {
247
335
  code: 1030,
248
336
  domain: "kernel",
249
337
  cause: "src/flows/{unit} exists on disk but adopted no flows — the .adopt() barrel is stale.",
250
- fix: "Run `oke dev` or `oke build` to regenerate `src/flows/generated.ts`.",
338
+ fix: "Run `oke dev` or `oke build` to regenerate `src/flows/index.ts`.",
251
339
  },
252
340
  /**
253
341
  * `http.get()` was never stamped from the file tree — refuse a silent `/`.
@@ -256,7 +344,7 @@ export const OKE_ERRORS = {
256
344
  code: 1040,
257
345
  domain: "kernel",
258
346
  cause: 'Flow "{flow}" bound {method} with no path — the file-tree stamp never ran.',
259
- fix: 'Put the file under `src/flows/<unit>/` and import `@/flows/generated`, or pass an explicit path to `http.{method}("/…")`.',
347
+ fix: 'Put the file under `src/flows/<unit>/` and import `@/flows`, or pass an explicit path to `http.{method}("/…")`.',
260
348
  },
261
349
  /**
262
350
  * Two HTTP bindings share method + path — last-add-wins is the opposite of this DX.
@@ -368,6 +456,12 @@ export function lookupOkeError(code: OkeErrorCode): OkeErrorDefinition | undefin
368
456
  ["errors", "live", "resume"].join("-"),
369
457
  ).LIVE_RESUME_GAP;
370
458
  }
459
+ if (code === 1510) {
460
+ return lazyRequire<typeof import("./errors-vault.ts")>(
461
+ import.meta.dir,
462
+ ["errors", "vault"].join("-"),
463
+ ).VAULT_SECRET_MISSING;
464
+ }
371
465
  if (code === 1605) {
372
466
  return lazyRequire<typeof import("./errors-channel.ts")>(
373
467
  import.meta.dir,
@@ -383,27 +477,6 @@ export function lookupOkeError(code: OkeErrorCode): OkeErrorDefinition | undefin
383
477
  return undefined;
384
478
  }
385
479
 
386
- /**
387
- * Create a flow-boundary failure value (does not throw).
388
- *
389
- * When `opts.message` is omitted, attaches a localized message from the
390
- * built-in / app catalogs (`errors.{code}.{reason}` → `errors.{code}`) using
391
- * the active request locale. Custom codes with no catalog entry stay
392
- * message-less.
393
- *
394
- * @param code - Declared error code from the flow's `errors` map
395
- * @param data - Error payload
396
- * @param opts - Optional message override
397
- */
398
- export function fail<E>(code: string, data: E, opts?: FailOptions): FlowFailure<E> {
399
- const message =
400
- opts?.message !== undefined
401
- ? opts.message
402
- : loadFailureMessage().resolveFailureMessage(code, data);
403
- const error: FlowErrorValue<E> = message !== undefined ? { code, data, message } : { code, data };
404
- return { data: null, error };
405
- }
406
-
407
480
  /**
408
481
  * Format the canonical multi-line OKE error message (§21).
409
482
  *
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Built-in `fail.notFound` / `fail.forbidden` / … helpers.
3
+ *
4
+ * Kept off the kernel edge graph — loaded via computed `import.meta.require`.
5
+ * Mutates the shared {@link fail} function so `fx.fail` and `import { fail }`
6
+ * share one identity.
7
+ */
8
+
9
+ import { fail as failImpl, type FailFn, type FailOptions } from "./errors.ts";
10
+ import type { BuiltinErrorMap } from "./builtin-errors.ts";
11
+
12
+ const empty = {};
13
+ const helper =
14
+ (code: string) =>
15
+ (data = empty, opts?: FailOptions) =>
16
+ failImpl(code, data, opts);
17
+
18
+ /**
19
+ * Callable `fail` plus helpers. Same function object as kernel `fail`.
20
+ */
21
+ export const fail: FailFn = Object.assign(failImpl, {
22
+ notFound: helper("NotFound"),
23
+ unauthorized: helper("Unauthorized"),
24
+ forbidden: helper("Forbidden"),
25
+ conflict: helper("Conflict"),
26
+ foreignKey: helper("ForeignKey"),
27
+ rateLimited: helper("RateLimited"),
28
+ serviceUnavailable: helper("ServiceUnavailable"),
29
+ database: (data: BuiltinErrorMap["DatabaseError"], opts?: FailOptions) =>
30
+ failImpl("DatabaseError", data, opts),
31
+ internal: helper("InternalError"),
32
+ });
33
+
34
+ export type { FailFn, FailOptions } from "./errors.ts";
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Module-evaluation registry for `src/flows/generated.ts`.
2
+ * Module-evaluation registry for `src/flows/index.ts`.
3
3
  *
4
- * Same consume/ignore posture as `on()` / `listBindings()`: generated.ts
4
+ * Same consume/ignore posture as `on()` / `listBindings()`: the adopt barrel
5
5
  * calls {@link registerFlowUnits}; {@link oke} drains the bag into `$routes`
6
6
  * unless `registry: "ignore"`.
7
7
  */
@@ -19,7 +19,7 @@ const pending: Record<string, FlowUnitBag> = {};
19
19
  /**
20
20
  * Record generated flow units for the next {@link oke} construction.
21
21
  *
22
- * @param units - `{ notes, main, … }` from `generated.ts`
22
+ * @param units - `{ notes, main, … }` from `src/flows/index.ts`
23
23
  */
24
24
  export function registerFlowUnits(units: Record<string, FlowUnitBag>): void {
25
25
  Object.assign(pending, units);
@@ -728,6 +728,14 @@ describe("errors — registry", () => {
728
728
  });
729
729
  });
730
730
 
731
+ test("fx.fail.notFound is callable without errors: declaration", () => {
732
+ const fx = createFx({ flow: "x", effects: {} });
733
+ const result = fx.fail.notFound({ id: "n1" });
734
+ expect(result.error.code).toBe("NotFound");
735
+ expect(result.error.data).toEqual({ id: "n1" });
736
+ expect(result.data).toBeNull();
737
+ });
738
+
731
739
  test("capability token allows only declared resources", () => {
732
740
  const token = createCapabilityToken("f", {
733
741
  reads: ["sql:a"],
package/src/kernel/fx.ts CHANGED
@@ -58,7 +58,7 @@ import {
58
58
  recordWouldHaveFired,
59
59
  touchDryRunStore,
60
60
  } from "./dry-run.ts";
61
- import { fail, type FailOptions, type FlowFailure } from "./errors.ts";
61
+ import type { FailFn } from "./errors.ts";
62
62
  import { currentAbortSignal, linkAbort } from "./abort-scope.ts";
63
63
  import {
64
64
  fxAll,
@@ -82,6 +82,13 @@ import { okid } from "../okid.ts";
82
82
  import { lazyRequire } from "./lazy-require.ts";
83
83
  import type { AppMessageKey, MessageValues } from "../i18n/types.ts";
84
84
 
85
+ function loadFail(): FailFn {
86
+ return lazyRequire<typeof import("./fail-helpers.ts")>(
87
+ import.meta.dir,
88
+ ["fail", "helpers"].join("-"),
89
+ ).fail;
90
+ }
91
+
85
92
  /** Lazy runs/window helpers — kept off the cold `oke` static graph. */
86
93
  async function loadRunsWindow(): Promise<typeof import("../runs/window.ts")> {
87
94
  return import("../runs/window.ts");
@@ -182,7 +189,11 @@ export interface FxTenant {
182
189
 
183
190
  /** Clock surface on `fx`. */
184
191
  export interface FxClock {
185
- /** Current epoch-ms (injectable via {@link CreateFxOptions.now}). */
192
+ /**
193
+ * Current epoch-ms (injectable via {@link CreateFxOptions.now}).
194
+ * Pass into SQL `timestamp` / `date` columns as-is — store insert/update
195
+ * and WHERE binds coerce finite numbers and parseable ISO strings to `Date`.
196
+ */
186
197
  now(): number;
187
198
  /**
188
199
  * Instant `duration` before {@link FxClock.now} (`"30d"` → now − 30 days).
@@ -465,11 +476,17 @@ export type JsonPage<T> = {
465
476
  * JSON response helpers. `fx.json.create` answers 201; `fx.json.ok` can carry
466
477
  * a top-level `meta` (Stripe-style `{ data, meta?, error }`);
467
478
  * `fx.json.empty` answers 204.
479
+ * When the exposure declares `out`, the kernel projects the success value onto
480
+ * that schema — `fx.json.create(row)` is enough; no Date→ISO mapper.
468
481
  */
469
482
  export interface FxJson {
470
483
  /** 200 — body `{ data: value, meta?, error: null }`. */
471
484
  ok<T>(value: T, opts?: { readonly meta?: Record<string, unknown> }): JsonResult<T>;
472
- /** 201 — body `{ data: value, error: null }`. */
485
+ /**
486
+ * 201 — body `{ data: value, error: null }`.
487
+ * When the exposure declares `out`, the kernel projects `value` onto that
488
+ * schema (Date → ISO-8601, extra keys stripped) so a store `row` is enough.
489
+ */
473
490
  create<T>(value: T): JsonResult<T>;
474
491
  /** 204 — no body. */
475
492
  empty(): JsonResult<never>;
@@ -874,11 +891,10 @@ export interface Fx {
874
891
  /**
875
892
  * Flow-boundary failure value (does not throw).
876
893
  *
877
- * @param code - Declared error code (narrowed by clients via `error.code`)
878
- * @param data - Error payload
879
- * @param opts - Optional message
894
+ * Built-in helpers (`fx.fail.notFound`, `fx.fail.forbidden`, …) need no
895
+ * `errors:` declaration. Domain codes still use `fx.fail("OutOfStock", data)`.
880
896
  */
881
- fail<E>(code: string, data: E, opts?: FailOptions): FlowFailure<E>;
897
+ readonly fail: FailFn;
882
898
  /** JSON response helpers (status + Stripe-style envelope). */
883
899
  readonly json: FxJson;
884
900
  /**
@@ -2236,7 +2252,7 @@ export function createFxContext(options: CreateFxOptions): FxContext {
2236
2252
  operator,
2237
2253
  principal,
2238
2254
  tenant,
2239
- fail,
2255
+ fail: loadFail(),
2240
2256
  json: {
2241
2257
  ok<T>(value: T, opts?: { readonly meta?: Record<string, unknown> }): JsonResult<T> {
2242
2258
  return {
@@ -106,14 +106,15 @@ export {
106
106
  type RunTelemetry,
107
107
  } from "./run-telemetry.ts";
108
108
 
109
+ export { fail } from "./fail-helpers.ts";
109
110
  export {
110
- fail,
111
111
  formatOkeMessage,
112
112
  lookupOkeError,
113
113
  OKE_ERROR_RANGES,
114
114
  OKE_ERRORS,
115
115
  OkeError,
116
116
  throwOke,
117
+ type FailFn,
117
118
  type FailOptions,
118
119
  type FlowErrorValue,
119
120
  type FlowFailure,
@@ -122,6 +123,16 @@ export {
122
123
  type OkeErrorDomain,
123
124
  type OkeErrorParams,
124
125
  } from "./errors.ts";
126
+ export {
127
+ BUILTIN_ERROR_STATUS,
128
+ httpStatusForFailure,
129
+ statusForBuiltinError,
130
+ type BuiltinErrorBag,
131
+ type BuiltinErrorCode,
132
+ type BuiltinErrorMap,
133
+ type BuiltinValidationIssue,
134
+ type DatabaseErrorReason,
135
+ } from "./builtin-errors.ts";
125
136
 
126
137
  export {
127
138
  flow,
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Write-path mutation id header — shared by the kernel HTTP glue and
3
+ * `okengine/client-react` optimistic mutate. Keep this file free of Node
4
+ * APIs so Vite SPAs can import `Can` / `useLiveQuery`.
5
+ */
6
+
7
+ /** Header echoed into `mutationId` on write-path live-query events. */
8
+ export const MUTATION_ID_HEADER = "x-oke-mutation-id";
@@ -290,9 +290,10 @@ export interface PluginApi {
290
290
  */
291
291
  channelTemplate(decl: ChannelTemplateDecl): PluginApi;
292
292
  /**
293
- * Contribute template body catalog entries (merged into boot channel catalog).
293
+ * Contribute template body catalog entries — overlay merged into boot
294
+ * channel catalog after `.template({ catalog })` bodies (`{{field}}`).
294
295
  *
295
- * @param catalog - Locale bodies keyed by template name (`{{field}}` interpolation)
296
+ * @param catalog - Locale bodies keyed by template name
296
297
  */
297
298
  channelCatalog(catalog: TemplateCatalog): PluginApi;
298
299
  }
@@ -475,7 +476,7 @@ export interface PluginDef<D extends Record<string, unknown> = {}> {
475
476
  gate(decl: GateDecl | GateAllDecl): PluginDef<D>;
476
477
  /** Queue a channel template. */
477
478
  channelTemplate(decl: ChannelTemplateDecl): PluginDef<D>;
478
- /** Queue channel template body catalog entries. */
479
+ /** Queue channel template body catalog entries (overlay after `.template({ catalog })`). */
479
480
  channelCatalog(catalog: TemplateCatalog): PluginDef<D>;
480
481
  }
481
482