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
@@ -344,12 +344,11 @@ export const get = on(
344
344
  http.get({
345
345
  in: z.object({ id: z.string() }),
346
346
  out: z.object({ id: z.string(), title: z.string() }),
347
- errors: { NotFound: z.object({ id: z.string() }) },
348
347
  }),
349
348
  flow({
350
349
  do: async ({ id }, fx) => {
351
350
  const [note] = await fx.store(db).select().from(notes).where(eq(notes.id, id));
352
- if (!note) return fx.fail("NotFound", { id });
351
+ if (!note) return fx.fail.notFound({ id });
353
352
  return note;
354
353
  },
355
354
  }),
@@ -404,7 +403,6 @@ export const update = on(
404
403
  id: z.string(),
405
404
  title: z.string().min(1).optional(),
406
405
  }),
407
- errors: { NotFound: z.object({ id: z.string() }) },
408
406
  }),
409
407
  flow({
410
408
  do: async ({ id, title }, fx) => {
@@ -412,7 +410,7 @@ export const update = on(
412
410
  await fx.store(db).update(notes).set({ title }).where(eq(notes.id, id));
413
411
  }
414
412
  const row = await fx.store(db).findById(notes, id);
415
- if (!row) return fx.fail("NotFound", { id });
413
+ if (!row) return fx.fail.notFound({ id });
416
414
  return row;
417
415
  },
418
416
  }),
@@ -591,7 +589,7 @@ The URL id segment is always `:id`. Update is **PATCH**, not PUT.
591
589
  | `out` | Schema | _(required)_ | Item shape (get / list / update return) |
592
590
  | `update` | Schema | `in` | Patch fields. Wire body is `{ id, …patch }` |
593
591
  | `idSchema` | Schema | `update`/`in` + `{ id: string }` | Replaces the update Flow `in` when set (include the id key) |
594
- | `errors` | error map | `{ NotFound }` | Typed failures on get / update / remove |
592
+ | `errors` | error map | — | Extra domain codes. Built-in `NotFound` is always on |
595
593
  | `id` | column | table PK | Column bound to `:id` |
596
594
  | `list` | object | see List Options | List query grammar (`GET /notes`) |
597
595
  | `breaking` | `boolean` | `false` | Marks the five Flows `breaking: true` (handwritten → resource migration) |
@@ -917,6 +915,11 @@ export default defineConfig({
917
915
  `oke db migrate` in that environment.
918
916
  </Accordion>
919
917
 
918
+ <Accordion title="Unique insert is 500 InternalError">
919
+ Unique / exclusion maps to `Conflict` **409**; missing FK to `ForeignKey` **409**. See [Errors ·
920
+ SQL auto-map](/docs/reference/errors#sql-auto-map).
921
+ </Accordion>
922
+
920
923
  <Accordion title="update().set().where(): condition required">
921
924
  Updates and deletes without a `where` are rejected (`delete().where(): condition required`). Pass
922
925
  a Drizzle condition or equality map — or use `delete(table, id)` for PK deletes.
@@ -976,7 +979,7 @@ export default defineConfig({
976
979
  - [HTTP · Resources](/docs/elements/flow/http#resources) — mount, live SSE, verb table
977
980
  - [Consumers · CDC](/docs/elements/flow/consumers#cdc) — `db.table(…).changed()`
978
981
  - [fx](/docs/reference/fx) — `fx.store` session
979
- - [Errors](/docs/reference/errors) — OKE1110 · OKE1041
982
+ - [Errors](/docs/reference/errors) — OKE1110 · OKE1041 · SQL auto-map
980
983
 
981
984
  ## Next
982
985
 
@@ -196,13 +196,16 @@ Order (Console labels match these source ids):
196
196
  3. **`.env.local`** — local overrides (gitignored)
197
197
  4. **dev-fallback** — `dev:` on the contract (dev boot only)
198
198
 
199
- Miss every layer → `VaultBootError` listing **all** gaps in one pass (including
200
- `vault.env.required` names):
199
+ Miss every layer → `VaultBootError` (TTY title **OKE1510**) listing **all** gaps in one pass
200
+ (including `vault.env.required` names):
201
201
 
202
202
  ```text
203
- vault boot failed — 2 missing secret(s):
204
- - STRIPE_KEY: Payments gateway key
205
- - DATABASE_URL: Primary SQL URL
203
+ ◇ OKE1510
204
+ │ 2 secrets have no value in any resolution layer.
205
+ │ - STRIPE_KEY
206
+ │ - DATABASE_URL
207
+ │ → Set each name (`oke vault set <name>`, or `.env.local`).
208
+ │ https://oke.omqkhafi.dev/e/1510
206
209
  ```
207
210
 
208
211
  **Consequence:** fix every listed name before traffic — boot does not take a half-configured app.
@@ -297,9 +300,9 @@ Managed provider ids: `aws-secrets-manager` · `azure-key-vault` · `gcp-secret-
297
300
  <Accordions>
298
301
 
299
302
  <Accordion title="VaultBootError — N missing secret(s)">
300
- Cause: `vault boot failed — N missing secret(s):` with every gap listed. On a TTY, `oke dev`
301
- prompts each into `.env.local`; otherwise use `oke vault set` / env / managed, or a `dev:`
302
- fallback. `vault.env.required` joins the same list.
303
+ TTY title **OKE1510**. Cause: N secrets have no value in any resolution layer, with every gap
304
+ listed. On a TTY, `oke dev` prompts each into `.env.local`; otherwise use `oke vault set` / env /
305
+ managed, or a `dev:` fallback. `vault.env.required` joins the same list.
303
306
  </Accordion>
304
307
 
305
308
  <Accordion title='vault: secret "…" is not loaded'>
@@ -209,9 +209,12 @@ capability check.
209
209
  Missing non-tenant contracts fail boot with every hole listed once:
210
210
 
211
211
  ```text
212
- vault boot failed — 2 missing secret(s):
213
- - STRIPE_KEY: Stripe secret API key
214
- - APP_WEBHOOK_SECRET: HMAC secret for outbound webhooks
212
+ ◇ OKE1510
213
+ │ 2 secrets have no value in any resolution layer.
214
+ │ - STRIPE_KEY
215
+ │ - APP_WEBHOOK_SECRET
216
+ │ → Set each name (`oke vault set <name>`, or `.env.local`).
217
+ │ https://oke.omqkhafi.dev/e/1510
215
218
  ```
216
219
 
217
220
  Fill gaps with:
@@ -29,7 +29,7 @@ Master the mental model before exploring features:
29
29
  />
30
30
  <Card
31
31
  title="Try It"
32
- description="From an empty folder to a Flow running in the Console — one sitting, minimal detour."
32
+ description="Scaffold, see the files, add GET and POST /users, save a row — then email if you want."
33
33
  href="/docs/understand/try-it"
34
34
  />
35
35
  </Cards>
@@ -27,7 +27,7 @@ drivers: {
27
27
  },
28
28
  },
29
29
  images: {
30
- store: { files: "rustfs/rustfs:1.0.0-rc.5" },
30
+ store: { files: "rustfs/rustfs:1.0.0" },
31
31
  },
32
32
  ```
33
33
 
@@ -151,7 +151,7 @@ images: {
151
151
  store: {
152
152
  sql: "postgres:18-alpine",
153
153
  kv: "redis:8-alpine",
154
- files: "rustfs/rustfs:1.0.0-rc.5",
154
+ files: "rustfs/rustfs:1.0.0",
155
155
  },
156
156
  channel: { email: "axllent/mailpit:v1.31.1" },
157
157
  pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.57",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: "Errors"
3
- description: "OKE codes, gate denials, and subsystem errors — stable numeric codes with causes and fixes."
3
+ description: "fx.fail helpers, built-in HTTP statuses, OKE codes, and subsystem errors — values vs thrown invariants."
4
4
  icon: "OctagonAlert"
5
5
  source: "docs/spec/unified-theory.md"
6
6
  ---
@@ -19,8 +19,8 @@ Codes are stable after the domain-range renumber — match them safely across up
19
19
  (see the Unreleased Breaking Changes mapping if you still have a pre-renumber note).
20
20
 
21
21
  <Callout title="The one rule">
22
- Switch on `error.code` for Flow failures. Catch / match `OKE####` only for thrown framework
23
- invariants. Do not treat a typed `NotFound` as an unhandled exception.
22
+ Switch on `error.code` (or a `match` named arm) for typed `data`. Use kind arms / `explain` for
23
+ chrome when the code is unknown. Catch `OKE####` only for thrown framework invariants.
24
24
  </Callout>
25
25
 
26
26
  ## Smallest Example
@@ -34,12 +34,11 @@ Codes are stable after the domain-range renumber — match them safely across up
34
34
  on(
35
35
  http.get({
36
36
  in: z.object({ id: z.string() }),
37
- errors: { NotFound: z.object({ id: z.string() }) },
38
37
  }),
39
38
  flow({
40
39
  do: async ({ id }, fx) => {
41
40
  const [row] = await fx.store(db).select().from(notes).where(eq(notes.id, id));
42
- if (!row) return fx.fail("NotFound", { id });
41
+ if (!row) return fx.fail.notFound({ id });
43
42
  return row;
44
43
  },
45
44
  }),
@@ -52,16 +51,163 @@ on(
52
51
  ### Handle it on the client
53
52
 
54
53
  ```typescript
55
- const { data, error } = await api.notes.get({ id });
56
- if (error?.code === "NotFound") {
57
- // value — not a throw
58
- }
54
+ import { match } from "okengine/client/explain";
55
+
56
+ const result = await api.notes.get({ id });
57
+ match(result, {
58
+ ok: (note) => render(note),
59
+ NotFound: () => showMissing(),
60
+ _: (e) => toast(e.message),
61
+ });
59
62
  ```
60
63
 
61
64
  </Step>
62
65
 
63
66
  </Steps>
64
67
 
68
+ ## `fx.fail`
69
+
70
+ Same function as `import { fail } from "okengine"`. Returns `{ data: null, error }` — it does **not**
71
+ throw. Built-in codes need no `errors:` bag. Domain codes (`OutOfStock`, `FlightFull`, `Duplicate`)
72
+ still go on the exposure:
73
+
74
+ ```typescript
75
+ on(
76
+ http.post({
77
+ in: z.object({ sku: z.string() }),
78
+ errors: { OutOfStock: z.object({ available: z.number() }) },
79
+ }),
80
+ flow({
81
+ do: async ({ sku }, fx) => {
82
+ const available = 0;
83
+ if (available === 0) return fx.fail("OutOfStock", { available });
84
+ return { sku };
85
+ },
86
+ }),
87
+ );
88
+ ```
89
+
90
+ Optional `{ message }` overrides the catalog: `fx.fail("OutOfStock", data, { message })`.
91
+
92
+ ### Helpers
93
+
94
+ | Call | Code | Status | Payload |
95
+ | ----------------------------------- | -------------------- | ---------------- | --------------------------------------------------------- |
96
+ | `fx.fail.notFound(data?)` | `NotFound` | **404** | `id?`, `key?`, … |
97
+ | `fx.fail.unauthorized(data?)` | `Unauthorized` | **401** | `reason?` |
98
+ | `fx.fail.forbidden(data?)` | `Forbidden` | **403** | `gate?`, `reason?` |
99
+ | `fx.fail.conflict(data?)` | `Conflict` | **409** | `constraint?`, `table?`, `column?`, `id?` |
100
+ | `fx.fail.foreignKey(data?)` | `ForeignKey` | **409** | `constraint?`, `table?`, `column?` |
101
+ | `fx.fail.rateLimited(data?)` | `RateLimited` | **429** | `retryAfterMs?` |
102
+ | `fx.fail.serviceUnavailable(data?)` | `ServiceUnavailable` | **503** | `retryAfter?` |
103
+ | `fx.fail.database(data)` | `DatabaseError` | by `data.reason` | `reason`, `sqlstate?`, `constraint?`, `table?`, `column?` |
104
+ | `fx.fail.internal(data?)` | `InternalError` | **500** | `{}` — catalog message only |
105
+
106
+ `DatabaseError` status: `not_null` / `check` / `invalid` / `too_long` / `out_of_range` → **422**,
107
+ `retryable` → **503**, `unknown` / missing → **500**.
108
+
109
+ Declare a built-in on `errors:` **only to tighten** the payload. Shorter keeps
110
+ `NotFound: z.object({ code: z.string() })` because `{ code }` is not the built-in `{ id? }`.
111
+ Declared schema wins on the Manifest and the typed client.
112
+
113
+ The typed client always includes built-in codes. A declared key still wins.
114
+
115
+ ### HTTP status
116
+
117
+ A bare `404` with body `Not Found` means **no route matched**. `fx.fail.notFound` is a JSON envelope
118
+ at **404**. Clients distinguish by envelope, not status. Domain codes stay **400**.
119
+
120
+ | Code | Status | Helper |
121
+ | ------------------------------------------------ | ----------- | ------------------------------------------------------ |
122
+ | `ValidationError` | `422` | none — failed `in`, `do` never runs |
123
+ | `Unauthorized` | `401` | `unauthorized` · also a gate denial |
124
+ | `Forbidden` | `403` | `forbidden` · also a gate denial |
125
+ | `NotFound` | `404` | `notFound` |
126
+ | `Conflict` / `ForeignKey` | `409` | `conflict` / `foreignKey` |
127
+ | `UnsupportedMediaType` | `415` | none — QUERY `Content-Type` is not `application/json` |
128
+ | `RateLimited` / `AuthRateLimited` | `429` | `rateLimited` · `AuthRateLimited` has none |
129
+ | `InvalidQuery` | `400` | none — QUERY missing `Content-Type` or body isn't JSON |
130
+ | `AuthFailed` | `400` | none — credentials / policy, not “no session” |
131
+ | `DatabaseError` | by `reason` | `database` |
132
+ | `ServiceUnavailable` | `503` | `serviceUnavailable` |
133
+ | `InternalError` | `500` | `internal` — never copies a thrown `message` |
134
+ | Domain (`OutOfStock`, `FlightFull`, `Duplicate`) | `400` | `fx.fail("OutOfStock", data)` |
135
+
136
+ ### Client chrome
137
+
138
+ `import { match, matchError, explain } from "okengine/client/explain"`. Do not switch on every
139
+ code — a `create` can return store auto-map, gates, transport, and domain codes.
140
+
141
+ | `kind` | Codes |
142
+ | ------------- | -------------------------------------------------------------------------------------- |
143
+ | `auth` | `Unauthorized`, `AuthFailed` |
144
+ | `permission` | `Forbidden` |
145
+ | `missing` | `NotFound`; `TransportError` with HTTP 404 |
146
+ | `conflict` | `Conflict`, `ForeignKey` |
147
+ | `invalid` | `ValidationError`, `InvalidQuery`, `UnsupportedMediaType`; `DatabaseError` 422 reasons |
148
+ | `limited` | `RateLimited`, `AuthRateLimited` |
149
+ | `unavailable` | `ServiceUnavailable`; `DatabaseError` `retryable`; other `TransportError` |
150
+ | `failed` | `InternalError`, unknown `DatabaseError`, domain codes |
151
+
152
+ ```typescript
153
+ match(result, {
154
+ ok: (data) => show(data),
155
+ FlightFull: (data) => offerWaitlist(data.seatsLeft),
156
+ auth: () => goSignIn(),
157
+ invalid: (e) => setForm(e.fields),
158
+ _: (e) => toast(e.message),
159
+ });
160
+ ```
161
+
162
+ Dispatch: `ok` → named code → kind → required `_`. `explain(error).fields` maps
163
+ `ValidationError` issues to `{ email: "Required" }`. Full loop: [Calling](/docs/client/calling).
164
+
165
+ `TransportError` always sets both `error.message` and `error.data.message` to the same string.
166
+ Prefer `error.message` in UI code.
167
+
168
+ ### Store auto-map
169
+
170
+ `fx.store` constraint and connection failures become typed `fail` values (same family as
171
+ `ValidationError`). Public `data` is names only — never `message` / `detail` / `hint`.
172
+ `get` returning `null` is **not** auto-`NotFound` — return `fx.fail.notFound` yourself.
173
+
174
+ #### SQL auto-map
175
+
176
+ | Signal | Result |
177
+ | --------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
178
+ | Postgres `23505` / `23P01` · SQLite UNIQUE / PRIMARY KEY | `Conflict` **409** |
179
+ | Postgres `23503` / `23001` · SQLite FOREIGN KEY | `ForeignKey` **409** |
180
+ | Postgres `23502` / `23514` · SQLite NOT NULL / CHECK | `DatabaseError` `not_null` / `check` **422** |
181
+ | Postgres `22P02` / `22007` / `22023` / `42804` | `DatabaseError` `invalid` **422** |
182
+ | Postgres `22001` | `DatabaseError` `too_long` **422** |
183
+ | Postgres `22003` / `22008` | `DatabaseError` `out_of_range` **422** |
184
+ | Postgres `40001` / `40P01` / `55P03` · SQLite BUSY | left thrown so `flow.retry` can run; after exhaust → `ServiceUnavailable` **503** |
185
+ | `08xxx` / `53xxx` / `57P0x` / `57014` / `25P03` / connection / paused | `ServiceUnavailable` **503** |
186
+ | `42P01` / `42703` with `domainDdl: "off"` | **OKE1110** (unchanged) |
187
+ | other SQLSTATE | `DatabaseError` `unknown` **500** |
188
+
189
+ #### KV auto-map
190
+
191
+ Redis-wire `store.kv` only (not Gate / Signal clients). Memory KV does not throw these.
192
+
193
+ | Signal | Result |
194
+ | ----------------------------------------------------------- | --------------------------------------------------------------------------------- |
195
+ | Connection / `CLUSTERDOWN` / `READONLY` / `OOM` / `MISCONF` | `ServiceUnavailable` **503** |
196
+ | `BUSY` / `TRYAGAIN` / `LOADING` | left thrown so `flow.retry` can run; after exhaust → `ServiceUnavailable` **503** |
197
+ | `WRONGTYPE` / `NOSCRIPT` | unmapped — `InternalError` **500** |
198
+
199
+ #### Files auto-map
200
+
201
+ `fs` and `s3` (`fx.store` files handle). Missing objects stay `null`.
202
+
203
+ | Signal | Result |
204
+ | -------------------------------------------------- | --------------------------------------------------------------------------------- |
205
+ | `AccessDenied` / `EACCES` / `EPERM` / HTTP **403** | `Forbidden` **403** |
206
+ | `fs` key starts with `/` or contains `..` | `Forbidden` **403** (`reason: invalid_key`) |
207
+ | `NoSuchBucket` / `ENOSPC` / connection | `ServiceUnavailable` **503** |
208
+ | `SlowDown` / `EBUSY` / HTTP **429** | left thrown so `flow.retry` can run; after exhaust → `ServiceUnavailable` **503** |
209
+ | `NoSuchKey` (thrown) | unmapped — `InternalError` **500** |
210
+
65
211
  ## Localized messages
66
212
 
67
213
  Typed failures and OKE codes ship English and Arabic ICU catalogs. Locale comes
@@ -100,6 +246,7 @@ string. Custom app codes stay message-less until registered. Full catalogs:
100
246
  | `1071` | once-signal multi-flow | Two different Flows bound to the same `signal.once` | Use `signal.broadcast`, or bind only one Flow |
101
247
  | `1072` | flow unnamed | Signal / Clock consumer still has no `unit.export` | Export from `flows/<unit>/` or pass a named `flow` |
102
248
  | `1110` | schema missing | Domain table absent in `prod` — no auto-DDL | Run `oke db migrate` against this environment |
249
+ | `1510` | vault secret missing | Contract has no value in any resolution layer | `oke vault set <name>`, `.env.local`, or a `dev:` fallback |
103
250
  | `1210` | live resume gap | `Last-Event-ID` is not on the retained tape | Reconnect without the cursor; remaining tape replays |
104
251
  | `1240` | orphan emit | Emit with zero subscribers and `optional` false | Add `on(signal, …)` or declare `optional: true` |
105
252
  | `1250` | signal schema | Emit payload failed the signal's `schema` | Pass a payload that matches `schema`, or remove it |
@@ -116,7 +263,8 @@ string. Custom app codes stay message-less until registered. Full catalogs:
116
263
 
117
264
  ## Gate denials (typed failures)
118
265
 
119
- Returned, not thrown — the request never reached `do`:
266
+ Returned before `do` — not thrown. Same codes as `fx.fail.unauthorized` / `.forbidden` /
267
+ `.rateLimited`:
120
268
 
121
269
  | Code | When | Payload |
122
270
  | -------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------ |
@@ -124,22 +272,13 @@ Returned, not thrown — the request never reached `do`:
124
272
  | `Forbidden` | Policy denied, authenticated but not allowed | `gate`, `reason` (`tenant_required` · `not_member` · `unknown_scope` · `session_only` · …) |
125
273
  | `RateLimited` | Rate gate budget exhausted | `retryAfterMs` |
126
274
 
127
- ## Framework validation failures
128
-
129
- | Code | When |
130
- | ---------------------- | ------------------------------------------------------------------------------------------------------------------- |
131
- | `ValidationError` | Input failed the `in` schema, or a list param isn't whitelisted (`unknown list param "x"`) |
132
- | `NotFound` | A `store.resource` get/update/remove hit a missing row |
133
- | `InvalidQuery` | QUERY missing `Content-Type` (`reason: missing_content_type`) or body isn't JSON (`inconsistent_content`) — **400** |
134
- | `UnsupportedMediaType` | QUERY `Content-Type` is present but not `application/json` — **415**, `Accept-Query` lists JSON |
135
-
136
275
  ## Subsystem errors
137
276
 
138
277
  Thrown by specific subsystems — each names its own cause:
139
278
 
140
279
  | Error | Thrown when | What to do |
141
280
  | ----------------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------- |
142
- | `VaultBootError` | A vault contract has no value in any resolution layer | Set the missing names — the error lists every gap |
281
+ | `VaultBootError` | A vault contract has no value in any resolution layer | TTY **OKE1510** — set each listed name |
143
282
  | `VaultError` (`UNSUPPORTED`) | Managed provider unknown | Use an official id, built-in `vault`, or `env` |
144
283
  | `VaultSealed` | Console rotate-master while this process holds no master key | Export `OKE_VAULT_MASTER_KEY` or run `oke vault unseal` |
145
284
  | `VaultRotateBusy` | Master-rotation lease held or a batch is already in flight | Wait and continue, or resume with `oke vault rotate-master --new-key` |
@@ -196,20 +335,55 @@ Thrown by specific subsystems — each names its own cause:
196
335
  membership, or rate budget. See [Gate](/docs/elements/gate).
197
336
  </Accordion>
198
337
 
338
+ <Accordion title="Do I need errors: { NotFound }?">
339
+ No. Built-in codes are always on — `return fx.fail.notFound({ id })`. Put `errors:` only for
340
+ domain codes, or to tighten a built-in payload (`{ code }` instead of `{ id? }`).
341
+ </Accordion>
342
+
343
+ <Accordion title="Unique insert is 500 InternalError">
344
+ Unique / exclusion → `Conflict` **409**; missing FK → `ForeignKey` **409**; invalid →
345
+ `DatabaseError` **422**. Serialization (`40001` / `40P01`) stays thrown until retries exhaust,
346
+ then **503**. See [SQL auto-map](#sql-auto-map).
347
+ </Accordion>
348
+
349
+ <Accordion title="Redis down returns InternalError">
350
+ Connection / `CLUSTERDOWN` / `READONLY` map to `ServiceUnavailable` **503**. `BUSY` / `TRYAGAIN`
351
+ stay thrown until `flow.retry` exhausts, then **503**. `WRONGTYPE` stays `InternalError` — see [KV
352
+ auto-map](#kv-auto-map).
353
+ </Accordion>
354
+
355
+ <Accordion title="S3 AccessDenied / disk full is InternalError">
356
+ `AccessDenied` and `fs` path-escape keys → `Forbidden` **403**. `SlowDown` / `EBUSY` stay thrown
357
+ until retries exhaust, then **503**. `ENOSPC` / `NoSuchBucket` → **503**. Missing `get` is still
358
+ `null`, not `NotFound` — see [Files auto-map](#files-auto-map).
359
+ </Accordion>
360
+
361
+ <Accordion title="404 Not Found — route missing or domain miss?">
362
+ Bare body `Not Found` means no HTTP route. JSON `{ error: { code: "NotFound" } }` is
363
+ `fx.fail.notFound`. Clients distinguish by envelope.
364
+ </Accordion>
365
+
366
+ <Accordion title="InternalError leaked a stack / thrown message">
367
+ Unhandled throws use the catalog string only. The thrown `message` is not copied onto the
368
+ envelope. Expected misses belong in `fx.fail.notFound` or a domain `fx.fail("OutOfStock", data)`.
369
+ </Accordion>
370
+
199
371
  <Accordion title="VaultBootError at startup">
200
- A vault contract has no value in any resolution layer. The error lists every gap — set the missing
201
- names ([Vault](/docs/elements/vault)).
372
+ TTY title **OKE1510**. A vault contract has no value in any resolution layer. The printer lists
373
+ every gap — set the missing names ([Vault](/docs/elements/vault)).
202
374
  </Accordion>
203
375
 
204
376
  </Accordions>
205
377
 
206
378
  ## Learn more
207
379
 
380
+ - [fx](/docs/reference/fx) — `fx.fail` helpers on the door
208
381
  - [i18n](/docs/reference/i18n) — catalogs, `fx.t`, locale matching
209
- - [Flow](/docs/elements/flow) — `fx.fail` and the response envelope
382
+ - [Flow](/docs/elements/flow) — envelope and domain `errors:`
383
+ - [HTTP](/docs/elements/flow/http) — response envelopes
384
+ - [Calling](/docs/client/calling) — `match` / `explain` on the client
210
385
  - [Gate](/docs/elements/gate) — where the three denials come from
211
386
  - [CLI](/docs/reference/cli) — `oke db migrate` and friends
212
- - [fx](/docs/reference/fx) — how failures and effects surface
213
387
 
214
388
  ## Next
215
389
 
@@ -220,5 +394,5 @@ Thrown by specific subsystems — each names its own cause:
220
394
  description="Where Unauthorized and Forbidden come from."
221
395
  href="/docs/elements/gate"
222
396
  />
223
- <Card title="Client" description="Switch on error.code in the browser." href="/docs/client" />
397
+ <Card title="Client" description="match unknown errors in the browser." href="/docs/client" />
224
398
  </Cards>
@@ -141,7 +141,10 @@ on(
141
141
  | `fx.retry(fn, opts?)` | — | Exponential backoff + jitter (plain Promise) |
142
142
  | `fx.using(acq, rel, use)` | — | `release` runs once on settle or ambient abort |
143
143
  | `fx.signal` | — | Ambient `AbortSignal` for the current branch |
144
- | `fx.fail(code, data, opts?)` | — | Typed failure value (`opts.message` overrides) |
144
+ | `fx.fail(code, data, opts?)` | — | Domain failure value (`opts.message` overrides) |
145
+ | `fx.fail.notFound(data?)` | — | Built-in `NotFound` — HTTP 404. No `errors:` bag required |
146
+ | `fx.fail.forbidden(data?)` / `.unauthorized` / `.conflict` / `.foreignKey` | — | Built-in 403 / 401 / 409 |
147
+ | `fx.fail.rateLimited` / `.serviceUnavailable` / `.database` / `.internal` | — | Built-in 429 / 503 / DatabaseError / 500 |
145
148
  | `fx.auth.createApiKey({ name, scopes, expiresIn?, ipAllowlist?, rateLimit? })` | `write` `auth:api-keys` | Secret once. Creator is live `userId` / `scopes`. Session only. `ipAllowlist` is IPs or hostnames. |
146
149
  | `fx.auth.listApiKeys()` | `read` `auth:api-keys` | Keys this session minted |
147
150
  | `fx.auth.revokeApiKey(id)` | `write` `auth:api-keys` | Owner only |
@@ -155,6 +158,8 @@ on(
155
158
  `fx.call` starts the callee with an **empty** `fx.auth` (fail-closed for authorization) and
156
159
  propagates `fx.tenant.id`. For audit/attribution only, read `fx.principal` — it propagates the
157
160
  originating identity without copying into `fx.auth`. Gates never consult `fx.principal`.
161
+ Controlled `return fx.fail(...)` returns a `FlowFailure` value; an unhandled `throw` rethrows
162
+ (never silent `undefined`). The same rules apply to `app.call`.
158
163
 
159
164
  ## Concurrency and retry
160
165
 
@@ -248,12 +248,12 @@ The Elements section walks through each element in depth — this is just enough
248
248
 
249
249
  ## Where this goes next
250
250
 
251
- You now hold the whole model: the drift, the rule, the eight elements, the five-piece anatomy. Don't read more — run it. From an empty folder to this exact signup Flow answering a real request, in one sitting:
251
+ You now hold the whole model: the drift, the rule, the eight elements, the five-piece anatomy. Don't read more — run it. From an empty folder to `GET` and `POST /users` answering a real request:
252
252
 
253
253
  <Cards>
254
254
  <Card
255
255
  title="Try It"
256
- description="From an empty folder to a Flow running in the Console — one sitting, minimal detour."
256
+ description="Scaffold, see the files, add GET and POST /users, save a row — then email if you want."
257
257
  href="/docs/understand/try-it"
258
258
  />
259
259
  <Card