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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okengine",
3
- "version": "0.20.0",
3
+ "version": "0.21.1",
4
4
  "description": "One law. Eight elements. One contract. The backend model stays small; operational surfaces are derived from it instead of maintained separately.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -70,6 +70,7 @@
70
70
  },
71
71
  "./client": "./src/client/index.ts",
72
72
  "./client/auth": "./src/client/auth.ts",
73
+ "./client/explain": "./src/client/explain.ts",
73
74
  "./client-react": "./src/client-react/index.ts",
74
75
  "./test": "./src/test/index.ts",
75
76
  "./testing": {
@@ -140,7 +141,6 @@
140
141
  },
141
142
  "dependencies": {
142
143
  "@clack/prompts": "^1.8.0",
143
- "@tanstack/react-virtual": "^3.14.11",
144
144
  "intl-messageformat": "^11.2.14",
145
145
  "oxc-parser": "^0.149.0",
146
146
  "sently": "1.2.2"
@@ -168,6 +168,7 @@
168
168
  "@tanstack/react-query": "^5.102.8",
169
169
  "@tanstack/react-router": "^1.170.34",
170
170
  "@tanstack/react-table": "^9.2.4",
171
+ "@tanstack/react-virtual": "^3.14.11",
171
172
  "@types/bun": "latest",
172
173
  "@types/d3-array": "^3.2.2",
173
174
  "@types/d3-shape": "^3.2.0",
@@ -193,7 +194,6 @@
193
194
  "date-fns": "^4.4.0",
194
195
  "drizzle-kit": "1.0.0-rc.5-ab785fc",
195
196
  "drizzle-orm": "1.0.0-rc.5-169397b",
196
- "drizzle-seed": "^0.3.1",
197
197
  "gflows": "^1.2.1",
198
198
  "happy-dom": "^20.14.3",
199
199
  "hast-util-to-jsx-runtime": "^2.3.6",
@@ -230,7 +230,6 @@
230
230
  "ajv-formats": ">=3.0.0",
231
231
  "drizzle-kit": ">=1.0.0-rc.0",
232
232
  "drizzle-orm": ">=1.0.0-rc.0",
233
- "drizzle-seed": ">=0.3.0",
234
233
  "react": ">=18.0.0",
235
234
  "zod": ">=3.23.0"
236
235
  },
@@ -274,9 +273,6 @@
274
273
  "drizzle-orm": {
275
274
  "optional": true
276
275
  },
277
- "drizzle-seed": {
278
- "optional": true
279
- },
280
276
  "zod": {
281
277
  "optional": true
282
278
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: "Calling"
3
- description: "createClient forms, REST vs RPC, options, result envelopes, store resources, and remote types."
3
+ description: "createClient forms, REST vs RPC, match/explain for unknown errors, options, envelopes, and remote types."
4
4
  icon: "Braces"
5
5
  source: "docs/spec/unified-theory.md"
6
6
  ---
@@ -13,8 +13,8 @@ For developers wiring a storefront, mobile app, or partner service to an okengin
13
13
  **6530**).
14
14
 
15
15
  <Callout title="The one rule">
16
- Treat every call as a result envelope: `{ data, error }`. Switch on `error.code`. Only transport
17
- / protocol problems use `code: "TransportError"`.
16
+ Treat every call as `{ data, error }`. Use `match` from `okengine/client/explain` for success plus
17
+ unknown failures. Name a code only when this screen has special recovery.
18
18
  </Callout>
19
19
 
20
20
  ## Smallest Example
@@ -125,7 +125,7 @@ if (isOk(result)) {
125
125
  <Tab value="Failures">
126
126
 
127
127
  ```typescript
128
- import { isOk, isErrorCode } from "okengine/client";
128
+ import { match } from "okengine/client/explain";
129
129
 
130
130
  const result = await api.bookings.create({
131
131
  flightId: "SK481",
@@ -133,16 +133,18 @@ const result = await api.bookings.create({
133
133
  cabin: "economy",
134
134
  });
135
135
 
136
- if (isOk(result)) {
137
- result.data.confirmationCode;
138
- } else if (result.error.code === "FlightFull") {
139
- offerWaitlist(result.error.data.seatsLeft);
140
- } else if (isErrorCode(result.error, "NotFound")) {
141
- // shared helper narrowing
142
- }
136
+ match(result, {
137
+ ok: (data) => show(data.confirmationCode),
138
+ FlightFull: (data) => offerWaitlist(data.seatsLeft),
139
+ auth: () => goSignIn(),
140
+ invalid: (e) => setForm(e.fields),
141
+ _: (e) => toast(e.message),
142
+ });
143
143
  ```
144
144
 
145
- Prefer `error?.code === "FlightFull"` for inference; use `isErrorCode` in shared helpers.
145
+ One import. Named codes narrow `data`. Kind arms (`auth`, `invalid`) cover chrome. `_` is required
146
+ — store auto-map, transport, and unknown domain codes land there. Full table: [`match` /
147
+ `explain`](#match--explain).
146
148
 
147
149
  </Tab>
148
150
 
@@ -154,13 +156,13 @@ import { isTransportError } from "okengine/client";
154
156
  const result = await api.orders.list({ limit: 20, status: "shipped" });
155
157
 
156
158
  if (result.error && isTransportError(result.error)) {
157
- showOfflineBanner(result.error.data.message);
159
+ showOfflineBanner(result.error.message); // === error.data.message
158
160
  // optional: result.error.data.status
159
161
  }
160
162
  ```
161
163
 
162
164
  Network failure, abort (`timeout`), non-JSON body, or HTTP status without a `{ data, error }`
163
- envelope — never a declared Flow code.
165
+ envelope — never a declared Flow code. Prefer `error.message`; it always matches `error.data.message`.
164
166
 
165
167
  </Tab>
166
168
 
@@ -180,6 +182,80 @@ JSON envelopes.
180
182
 
181
183
  </Tabs>
182
184
 
185
+ ## `match` / `explain`
186
+
187
+ A `create` can return `ValidationError`, store auto-map (`Conflict`, `DatabaseError`), gates,
188
+ `TransportError`, plus any domain code. Do not switch on every `error.code`.
189
+
190
+ ```typescript
191
+ import { match } from "okengine/client/explain";
192
+
193
+ const result = await api.bookings.create({ flightId: "SK481", seats: 2 });
194
+
195
+ match(result, {
196
+ ok: (data) => show(data.confirmationCode),
197
+ FlightFull: (data) => offerWaitlist(data.seatsLeft),
198
+ auth: () => goSignIn(),
199
+ invalid: (e) => setForm(e.fields),
200
+ _: (e) => toast(e.message),
201
+ });
202
+ ```
203
+
204
+ <Callout title="Dispatch order">
205
+ `ok` → named code (`FlightFull`) → UX kind (`auth`, `invalid`) → required `_`. Kind names are
206
+ reserved — do not use `auth` or `failed` as domain codes.
207
+ </Callout>
208
+
209
+ Import from `okengine/client/explain` so the 5 kB `okengine/client` graph stays small. `isOk` /
210
+ `isFail` stay on `okengine/client` when you only need a predicate.
211
+
212
+ | Helper | When |
213
+ | ----------------------------- | ------------------------- |
214
+ | `match(result, { ok, …, _ })` | Whole envelope — one call |
215
+ | `matchError(error, { …, _ })` | You already have `error` |
216
+ | `explain(error)` | Chrome only — no matcher |
217
+
218
+ ### Kinds
219
+
220
+ | `kind` | Codes | Typical UI |
221
+ | ------------- | -------------------------------------------------------------------------------------- | --------------------------- |
222
+ | `auth` | `Unauthorized`, `AuthFailed` | Sign-in |
223
+ | `permission` | `Forbidden` | “You can’t do that” |
224
+ | `missing` | `NotFound`; `TransportError` HTTP 404 | Empty state |
225
+ | `conflict` | `Conflict`, `ForeignKey` | Retry or pick another value |
226
+ | `invalid` | `ValidationError`, `InvalidQuery`, `UnsupportedMediaType`; `DatabaseError` 422 reasons | Form fields |
227
+ | `limited` | `RateLimited`, `AuthRateLimited` | Wait / retry (`retryable`) |
228
+ | `unavailable` | `ServiceUnavailable`; `DatabaseError` `retryable`; other `TransportError` | Offline / retry |
229
+ | `failed` | `InternalError`, unknown `DatabaseError`, unnamed domain codes | Toast `message` |
230
+
231
+ Named code arms receive typed `data`. Kind arms and `_` receive `{ kind, code, message, retryable,
232
+ fields? }`. `_` is required even if you listed every kind.
233
+
234
+ `explain(error).fields` maps `ValidationError` issues to `{ email: "Required" }` (`.`-joined
235
+ path; empty path is `_`). `retryable` is true for `limited` and `unavailable`; `retryAfterMs` is
236
+ set when the payload has `retryAfterMs` or `retryAfter` (seconds).
237
+
238
+ ```typescript
239
+ import { explain, matchError } from "okengine/client/explain";
240
+
241
+ if (result.error) {
242
+ matchError(result.error, {
243
+ FlightFull: (data) => offerWaitlist(data.seatsLeft),
244
+ _: (e) => toast(e.message),
245
+ });
246
+ }
247
+
248
+ explain(result.error!); // { kind, code, message, retryable, fields? }
249
+ ```
250
+
251
+ `error.code === "FlightFull"` still narrows `error.data` if you prefer an `if`. HTTP status is on
252
+ the response, not on `error`.
253
+
254
+ Domain codes with no `{ message }` toast the code name (`OutOfStock`). Pass
255
+ `fx.fail("OutOfStock", data, { message })` on the Flow, or name that code in `match`.
256
+
257
+ See [Errors](/docs/reference/errors) for `fx.fail` helpers and store auto-map.
258
+
183
259
  ## `createClient` forms
184
260
 
185
261
  | Form | Types from | Wire |
@@ -324,24 +400,19 @@ See [Store](/docs/elements/store) for the list query language. Handwritten lists
324
400
  ## Result envelopes
325
401
 
326
402
  Success may include optional top-level `meta` (for example pagination). Declared flow errors and
327
- transport failures share the failure arm:
403
+ transport failures share the failure arm. Handle them with [`match`](#match--explain).
328
404
 
329
405
  ```typescript
330
- import { isOk, isFail, isErrorCode, isTransportError } from "okengine/client";
331
-
332
- const result = await api.bookings.create({ flightId: "SK481", seats: 2 });
333
-
334
- if (isOk(result)) {
335
- result.data.confirmationCode;
336
- } else if (result.error.code === "FlightFull") {
337
- result.error.data.seatsLeft;
338
- } else if (isTransportError(result.error)) {
339
- result.error.data.message;
340
- }
406
+ import { isOk, isFail, isTransportError } from "okengine/client";
341
407
 
342
408
  isFail(result); // true when error !== null
409
+ isOk(result); // narrows to success `data`
410
+ isTransportError(result.error); // network / non-envelope HTTP
343
411
  ```
344
412
 
413
+ `error.code === "FlightFull"` still narrows `error.data`. HTTP status is on the response, not on
414
+ `error`.
415
+
345
416
  ## Remote types
346
417
 
347
418
  `oke dev` regenerates `oke-client.d.ts` from `GET /_oke/client.json`. A separate storefront repo —
@@ -376,6 +447,7 @@ CLI details: [CLI Reference](/docs/reference/cli).
376
447
  | `createTransport` | function | Low-level HTTP transport (timeout / retry / auth) |
377
448
  | `isOk` / `isFail` | function | Envelope predicates |
378
449
  | `isErrorCode` / `isTransportError` | function | Error narrowing |
450
+ | `match` / `matchError` / `explain` | function | `okengine/client/explain` — `ok`, codes, kinds, `_` |
379
451
  | `Client`, `ClientCall`, `ClientResult`, … | types | Contracts, `page.next()` / `for await` of `list()` |
380
452
  | `Register` | interface | Module-augmentation slot for ambient App types |
381
453
  | `AppOf` | type | Brand a bare route map as an App |
@@ -402,8 +474,24 @@ Restart `oke dev` after renaming exports.
402
474
 
403
475
  <Accordion title='error.code is "TransportError"'>
404
476
  Network failure, abort (`timeout`), non-JSON body, empty error response, or HTTP status without a
405
- `{ data, error }` envelope. Declared flow codes (`NotFound`, `FlightFull`, …) never use this code.
406
- Message text lives in `error.data.message`; HTTP status may appear as `error.data.status`.
477
+ `{ data, error }` envelope. Built-in and domain flow codes never use this code.
478
+ Prefer `error.message` — it always equals `error.data.message`. `explain` maps HTTP 404 transport
479
+ to kind `missing`.
480
+ </Accordion>
481
+
482
+ <Accordion title="Why import from okengine/client/explain?">
483
+ `match` / `explain` stay off the measured `okengine/client` gzip cap. Predicates (`isOk`,
484
+ `isTransportError`) stay on `okengine/client`.
485
+ </Accordion>
486
+
487
+ <Accordion title="Toast showed OutOfStock / FlightFull">
488
+ Domain codes with no `{message}` use the code name as `explain.message`. Handle the named arm, or
489
+ pass `fx.fail("OutOfStock", data, {message})` on the Flow.
490
+ </Accordion>
491
+
492
+ <Accordion title="404 — route miss or NotFound?">
493
+ Bare body `Not Found` means no HTTP route — `TransportError` (kind `missing`). JSON
494
+ `{ error: { code: "NotFound" } }` is `fx.fail.notFound`. Distinguish by envelope, not status.
407
495
  </Accordion>
408
496
 
409
497
  <Accordion title="Failed to fetch …/_oke/client.json">
@@ -427,7 +515,7 @@ Restart `oke dev` after renaming exports.
427
515
  - [Vault · Config](/docs/elements/vault/config) — `PUBLIC_API_URL`, `vault.env`
428
516
  - [Routing](/docs/elements/flow/routing) — `$routes` stamps
429
517
  - [HTTP](/docs/elements/flow/http) — verbs and envelopes on the server
430
- - [Errors](/docs/reference/errors) — framework codes vs failure values
518
+ - [Errors](/docs/reference/errors) — `fx.fail` helpers, store auto-map, `match` on the client
431
519
  - [CLI](/docs/reference/cli) — `oke client add`, `oke dev`
432
520
 
433
521
  ## Next
@@ -51,7 +51,7 @@ const api = createClient(app, vault.env("PUBLIC_API_URL") ?? "");
51
51
  const { data, error } = await api.bookings.get({ id: "bkg_7f3a" });
52
52
 
53
53
  if (error) {
54
- // TransportError or a declared flow code (NotFound, …)
54
+ // TransportError or a flow code (NotFound, …)
55
55
  return;
56
56
  }
57
57
 
@@ -160,7 +160,7 @@ dev so `createClient("")` stays same-origin behind your proxy.
160
160
  <Cards>
161
161
  <Card
162
162
  title="Calling"
163
- description="createClient forms, REST vs RPC, options, envelopes, resources, remote types."
163
+ description="createClient forms, REST vs RPC, match/explain, envelopes, remote types."
164
164
  href="/docs/client/calling"
165
165
  />
166
166
  <Card
@@ -192,7 +192,7 @@ dev so `createClient("")` stays same-origin behind your proxy.
192
192
  <Cards>
193
193
  <Card
194
194
  title="Calling"
195
- description="REST vs RPC, options, helpers, and remote types."
195
+ description="REST vs RPC, match/explain, options, and remote types."
196
196
  href="/docs/client/calling"
197
197
  />
198
198
  <Card
@@ -110,7 +110,7 @@ export const create = on(
110
110
 
111
111
  <Tab value="Failures">
112
112
 
113
- Declare typed domain errors and return clean failure responses using `fx.fail`:
113
+ Declare typed domain errors on `errors:`. Built-in codes use helpers (`fx.fail.notFound`) with no bag:
114
114
 
115
115
  ```typescript title="src/flows/orders/[id]/get.ts"
116
116
  import { on, flow, http } from "okengine";
@@ -120,12 +120,11 @@ export const get = on(
120
120
  http.get({
121
121
  in: z.object({ id: z.string() }),
122
122
  out: z.object({ id: z.string(), sku: z.string(), qty: z.number() }),
123
- errors: { NotFound: z.object({ id: z.string() }) },
124
123
  }),
125
124
  flow({
126
125
  do: async ({ id }, fx) => {
127
126
  const [order] = await fx.store(db).select().from(orders).where(eq(orders.id, id));
128
- if (!order) return fx.fail("NotFound", { id });
127
+ if (!order) return fx.fail.notFound({ id });
129
128
  return order;
130
129
  },
131
130
  }),
@@ -287,12 +286,11 @@ export const get = on(
287
286
  http.get({
288
287
  in: z.object({ id: z.string() }),
289
288
  out: z.object({ id: z.string(), title: z.string() }),
290
- errors: { NotFound: z.object({ id: z.string() }) },
291
289
  }),
292
290
  flow({
293
291
  do: async ({ id }, fx) => {
294
292
  const [note] = await fx.store(db).select().from(notes).where(eq(notes.id, id));
295
- if (!note) return fx.fail("NotFound", { id });
293
+ if (!note) return fx.fail.notFound({ id });
296
294
  return note;
297
295
  },
298
296
  }),
@@ -378,7 +376,6 @@ export const update = on(
378
376
  title: z.string().min(1).optional(),
379
377
  }),
380
378
  out: z.object({ id: z.string(), title: z.string() }),
381
- errors: { NotFound: z.object({ id: z.string() }) },
382
379
  }),
383
380
  flow({
384
381
  do: async ({ id, title }, fx) => {
@@ -386,7 +383,7 @@ export const update = on(
386
383
  await fx.store(db).update(notes).set({ title }).where(eq(notes.id, id));
387
384
  }
388
385
  const [note] = await fx.store(db).select().from(notes).where(eq(notes.id, id));
389
- if (!note) return fx.fail("NotFound", { id });
386
+ if (!note) return fx.fail.notFound({ id });
390
387
  return note;
391
388
  },
392
389
  }),
@@ -465,7 +462,6 @@ import { db, notes } from "@/schema";
465
462
  export const head = on(
466
463
  http.head("/notes/:id", {
467
464
  in: z.object({ id: z.string() }),
468
- errors: { NotFound: z.object({ id: z.string() }) },
469
465
  }),
470
466
  flow({
471
467
  do: async ({ id }, fx) => {
@@ -474,7 +470,7 @@ export const head = on(
474
470
  .select({ id: notes.id })
475
471
  .from(notes)
476
472
  .where(eq(notes.id, id));
477
- if (!note) return fx.fail("NotFound", { id });
473
+ if (!note) return fx.fail.notFound({ id });
478
474
  return;
479
475
  },
480
476
  }),
@@ -577,7 +573,7 @@ pathless `http.resource()` — pass an explicit base path.
577
573
  | `out` | Schema | _(required)_ | Item shape (get / list / update return) |
578
574
  | `update` | Schema | `in` | Patch fields. Wire body is `{ id, ...patch }` |
579
575
  | `idSchema` | Schema | `update`/`in` + `{ id: string }` | Replaces the update Flow `in` when set (include the id key) |
580
- | `errors` | error map | `{ NotFound }` | Typed failures on get / update / remove |
576
+ | `errors` | error map | — | Extra domain codes. Built-in `NotFound` is always on |
581
577
  | `id` | column | table PK | Column bound to `:id` |
582
578
  | `list` | object | see List Options | List query grammar (`GET /notes`) |
583
579
  | `breaking` | `boolean` | `false` | Marks the five Flows `breaking: true` (handwritten → resource migration) |
@@ -965,10 +961,11 @@ return fx.json.ok({ id: "ord_1" }, { meta: { traceId: fx.runId } });
965
961
  return new Response(null, { status: 302, headers: { Location: url } });
966
962
  ```
967
963
 
968
- **Typed failures** — `fx.fail(code, data)` formats the error envelope and maps status:
964
+ **Typed failures** — built-in helpers need no `errors:` bag. Domain codes still go on `errors:`:
969
965
 
970
966
  ```typescript
971
- return fx.fail("NotFound", { id: "123" });
967
+ return fx.fail.notFound({ id: "123" });
968
+ return fx.fail("OutOfStock", { available: 0 });
972
969
  ```
973
970
 
974
971
  ```json
@@ -976,7 +973,7 @@ return fx.fail("NotFound", { id: "123" });
976
973
  "data": null,
977
974
  "error": {
978
975
  "code": "NotFound",
979
- "message": "Resource not found",
976
+ "message": "The requested resource was not found.",
980
977
  "data": { "id": "123" }
981
978
  }
982
979
  }
@@ -987,8 +984,14 @@ Standard status code mappings:
987
984
  - `ValidationError` → `422 Unprocessable Entity`
988
985
  - `Unauthorized` → `401 Unauthorized`
989
986
  - `Forbidden` → `403 Forbidden`
990
- - `RateLimited` → `429 Too Many Requests`
991
- - Custom error codes → `400 Bad Request`
987
+ - `NotFound` → `404 Not Found` (JSON envelope — router miss is plain-text `404`)
988
+ - `Conflict` / `ForeignKey` → `409 Conflict`
989
+ - `UnsupportedMediaType` → `415 Unsupported Media Type`
990
+ - `RateLimited` / `AuthRateLimited` → `429 Too Many Requests`
991
+ - `DatabaseError` → `422` (`not_null` / `check` / `invalid` / `too_long` / `out_of_range`), `503` (`retryable`), else `500`
992
+ - `ServiceUnavailable` → `503 Service Unavailable`
993
+ - `InternalError` → `500 Internal Server Error` (catalog message only)
994
+ - Domain codes (`OutOfStock`, `FlightFull`, `Duplicate`) → `400 Bad Request`
992
995
 
993
996
  ## Troubleshooting
994
997
 
@@ -118,7 +118,7 @@ export const create = on(
118
118
 
119
119
  <Tab value="Failures">
120
120
 
121
- Declare domain errors on the exposure bag and return `fx.fail` — do not throw for expected failures:
121
+ Declare domain errors on the exposure bag. Built-in codes (`NotFound`, `Forbidden`, …) need no `errors:` — use the helpers:
122
122
 
123
123
  ```typescript title="src/flows/orders/[id]/get.ts"
124
124
  import { on, flow, http } from "okengine";
@@ -130,12 +130,11 @@ export const get = on(
130
130
  http.get({
131
131
  in: z.object({ id: z.string() }),
132
132
  out: z.object({ id: z.string(), sku: z.string(), qty: z.number() }),
133
- errors: { NotFound: z.object({ id: z.string() }) },
134
133
  }),
135
134
  flow({
136
135
  do: async ({ id }, fx) => {
137
136
  const [order] = await fx.store(db).select().from(orders).where(eq(orders.id, id));
138
- if (!order) return fx.fail("NotFound", { id });
137
+ if (!order) return fx.fail.notFound({ id });
139
138
  return order;
140
139
  },
141
140
  }),
@@ -318,15 +317,22 @@ HTTP success from a returned value is `200` + `{ data, error: null }`. `undefine
318
317
 
319
318
  Status for `error.code`:
320
319
 
321
- | Code | Status |
322
- | ----------------------------------------------------- | ------ |
323
- | `ValidationError` | `422` |
324
- | `Unauthorized` | `401` |
325
- | `Forbidden` | `403` |
326
- | `RateLimited` | `429` |
327
- | Any other declared code (`NotFound`, `OutOfStock`, …) | `400` |
328
-
329
- A bare `404` with body `Not Found` means **no route matched** — not `fx.fail("NotFound")`.
320
+ | Code | Status |
321
+ | ------------------------------------------------------ | ------ |
322
+ | `ValidationError` | `422` |
323
+ | `Unauthorized` | `401` |
324
+ | `Forbidden` | `403` |
325
+ | `NotFound` | `404` |
326
+ | `Conflict` / `ForeignKey` | `409` |
327
+ | `UnsupportedMediaType` | `415` |
328
+ | `RateLimited` / `AuthRateLimited` | `429` |
329
+ | `DatabaseError` (`not_null` / `check`) | `422` |
330
+ | `DatabaseError` (`retryable`) / `ServiceUnavailable` | `503` |
331
+ | `DatabaseError` (else) / `InternalError` | `500` |
332
+ | Domain codes (`OutOfStock`, `FlightFull`, `Duplicate`) | `400` |
333
+
334
+ A bare `404` with body `Not Found` means **no route matched** — not `fx.fail.notFound`. Clients
335
+ distinguish router miss (plain text) from domain miss (JSON envelope).
330
336
 
331
337
  </Tab>
332
338
 
@@ -386,7 +392,9 @@ A bare `404` with body `Not Found` means **no route matched** — not `fx.fail("
386
392
 
387
393
  <Accordion title="fx.call identity">
388
394
  `fx.call` starts the callee with an **empty** `fx.auth` (fail-closed). `fx.tenant.id` propagates.
389
- For audit only, read `fx.principal` — gates never consult it. See [fx](/docs/reference/fx).
395
+ For audit only, read `fx.principal` — gates never consult it. Controlled `return fx.fail(...)`
396
+ comes back as a `FlowFailure` value; an unhandled `throw` rethrows to the caller (never silent
397
+ `undefined`). See [fx](/docs/reference/fx).
390
398
  </Accordion>
391
399
 
392
400
  <Accordion title="Undeclared effects (OKE1001–1007)">
@@ -546,8 +554,8 @@ Default is `true` once `gate.auth.tenant` is on.
546
554
  </Accordion>
547
555
 
548
556
  <Accordion title="Thrown Error becomes a mystery 500 instead of a typed envelope">
549
- Uncaught exceptions are defects. Declare the code in `errors` on the exposure and `return
550
- fx.fail("OutOfStock", payload)` from `do`.
557
+ Uncaught exceptions become `InternalError` (catalog message only — the thrown `message` is not
558
+ copied). For expected misses use `fx.fail.notFound` / domain `fx.fail("OutOfStock", payload)`.
551
559
  </Accordion>
552
560
 
553
561
  <Accordion title="422 ValidationError — path param missing from in">
@@ -556,9 +564,9 @@ Default is `true` once `gate.auth.tenant` is on.
556
564
  Parsing](/docs/elements/flow/http#request-parsing).
557
565
  </Accordion>
558
566
 
559
- <Accordion title="fx.fail('NotFound') is 400, not 404">
560
- Custom domain codes map to **400**. A bare `404` `Not Found` means the router found no method +
561
- path. Use `fx.fail` for domain misses; fix the route for missing bindings.
567
+ <Accordion title="Router 404 vs domain NotFound">
568
+ A bare `404` with body `Not Found` means **no route matched**. `fx.fail.notFound` is a JSON
569
+ envelope at **404** with `error.code: "NotFound"`. Clients distinguish by envelope, not status.
562
570
  </Accordion>
563
571
 
564
572
  <Accordion title="Read-only Flow never cache-hits">
@@ -64,7 +64,7 @@ export const list = on(
64
64
  flow({
65
65
  do: async (_, fx) => {
66
66
  const tenantId = fx.tenant.id;
67
- if (!tenantId) return fx.fail("Forbidden", { reason: "tenant_required" });
67
+ if (!tenantId) return fx.fail.forbidden({ reason: "tenant_required" });
68
68
  return await fx.store(db).select().from(invoices).where(eq(invoices.tenantId, tenantId));
69
69
  },
70
70
  }),
@@ -318,13 +318,12 @@ import { uploads } from "@/core";
318
318
  export const get = on(
319
319
  http.get({
320
320
  in: z.object({ id: z.string(), name: z.string() }),
321
- errors: { NotFound: z.object({ key: z.string() }) },
322
321
  }),
323
322
  flow({
324
323
  do: async ({ id, name }, fx) => {
325
324
  const key = `notes/${id}/${name}`;
326
325
  const bytes = await fx.store(uploads).get(key);
327
- if (!bytes) return fx.fail("NotFound", { key });
326
+ if (!bytes) return fx.fail.notFound({ key });
328
327
  return { key, size: bytes.byteLength };
329
328
  },
330
329
  }),
@@ -582,7 +581,7 @@ export default defineConfig({
582
581
  },
583
582
  images: {
584
583
  store: {
585
- files: "rustfs/rustfs:1.0.0-rc.5",
584
+ files: "rustfs/rustfs:1.0.0",
586
585
  },
587
586
  },
588
587
  });
@@ -607,8 +606,14 @@ For `s3`, Compose / env typically supply `S3_ENDPOINT`, `S3_ACCESS_KEY_ID`,
607
606
  </Accordion>
608
607
 
609
608
  <Accordion title="Invalid object key">
610
- Cause: `Invalid object key: …` on the `fs` driver when the key starts with `/` or contains `..`.
611
- Use relative, non-escaping keys.
609
+ The `fs` driver rejects keys that start with `/` or contain `..`. HTTP is `Forbidden` **403**
610
+ (`reason: invalid_key`) — the key is not copied. Use relative, non-escaping keys.
611
+ </Accordion>
612
+
613
+ <Accordion title="S3 AccessDenied / disk full is InternalError">
614
+ `AccessDenied` / `EACCES` map to `Forbidden` **403**. Missing `get` stays `null`. `SlowDown` stays
615
+ thrown until `flow.retry` exhausts, then **503**. `NoSuchBucket` / `ENOSPC` are **503** — see
616
+ [Errors · Files auto-map](/docs/reference/errors#files-auto-map).
612
617
  </Accordion>
613
618
 
614
619
  <Accordion title="ERR_IMAGE_TOO_MANY_PIXELS">
@@ -644,6 +649,7 @@ For `s3`, Compose / env typically supply `S3_ENDPOINT`, `S3_ACCESS_KEY_ID`,
644
649
  - [HTTP](/docs/elements/flow/http) — routes that accept uploads
645
650
  - [Vault](/docs/elements/vault) — credentials for S3 when you configure bindings
646
651
  - [fx](/docs/reference/fx) — `fx.store(decl)`
652
+ - [Errors](/docs/reference/errors) — Files auto-map
647
653
  - [Configuration](/docs/reference/configuration) — `drivers.store.files`
648
654
 
649
655
  ## Next
@@ -117,12 +117,11 @@ import { db, notes } from "@/schema";
117
117
  export const get = on(
118
118
  http.get({
119
119
  in: z.object({ id: z.string() }),
120
- errors: { NotFound: z.object({ id: z.string() }) },
121
120
  }),
122
121
  flow({
123
122
  do: async ({ id }, fx) => {
124
123
  const [note] = await fx.store(db).select().from(notes).where(eq(notes.id, id));
125
- if (!note) return fx.fail("NotFound", { id });
124
+ if (!note) return fx.fail.notFound({ id });
126
125
  return note;
127
126
  },
128
127
  }),
@@ -228,7 +227,7 @@ export default defineConfig({
228
227
  store: {
229
228
  sql: "postgres:18-alpine",
230
229
  kv: "redis:8-alpine",
231
- files: "rustfs/rustfs:1.0.0-rc.5",
230
+ files: "rustfs/rustfs:1.0.0",
232
231
  },
233
232
  },
234
233
  });
@@ -262,7 +262,7 @@ export const get = on(
262
262
  do: async ({ token }, fx) => {
263
263
  const userId = await fx.store(sessions).get(`session:${token}`);
264
264
  if (userId === undefined || userId === null) {
265
- return fx.fail("NotFound", { token });
265
+ return fx.fail.notFound({ token });
266
266
  }
267
267
  return { userId };
268
268
  },
@@ -599,6 +599,12 @@ Gate rate strategies inherit `drivers.store.kv` (no separate `drivers.gate`).
599
599
  does). Prefer prefix filters in admin Flows.
600
600
  </Accordion>
601
601
 
602
+ <Accordion title="Redis down returns InternalError">
603
+ Connection / `CLUSTERDOWN` / `READONLY` map to `ServiceUnavailable` **503**. `BUSY` / `TRYAGAIN`
604
+ stay thrown until `flow.retry` exhausts, then **503**. `WRONGTYPE` stays `InternalError` — see
605
+ [Errors · KV auto-map](/docs/reference/errors#kv-auto-map).
606
+ </Accordion>
607
+
602
608
  <Accordion title="I expected incr / setNx / fx.store.kv">
603
609
  Those helpers are not on the public handle. Use `get` / `set` / `delete` / `list` / `ttlMs` via
604
610
  `fx.store(decl)`. Gate rates use Redis Lua internally.
@@ -614,7 +620,7 @@ Gate rate strategies inherit `drivers.store.kv` (no separate `drivers.gate`).
614
620
  - [Gate](/docs/elements/gate) — rate buckets use Redis internally
615
621
  - [fx](/docs/reference/fx) — `fx.store(decl)`
616
622
  - [Configuration](/docs/reference/configuration) — `drivers.store.kv`
617
- - [Errors](/docs/reference/errors) — OKE1810
623
+ - [Errors](/docs/reference/errors) — OKE1810 · KV auto-map
618
624
 
619
625
  ## Next
620
626