okengine 0.20.0 → 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.
- package/package.json +2 -1
- package/site/content/docs/client/calling.mdx +117 -29
- package/site/content/docs/client/index.mdx +3 -3
- package/site/content/docs/elements/flow/http.mdx +18 -15
- package/site/content/docs/elements/flow/index.mdx +26 -18
- package/site/content/docs/elements/gate/tenancy.mdx +1 -1
- package/site/content/docs/elements/store/files.mdx +11 -5
- package/site/content/docs/elements/store/index.mdx +2 -3
- package/site/content/docs/elements/store/kv.mdx +8 -2
- package/site/content/docs/elements/store/sql.mdx +9 -6
- package/site/content/docs/elements/vault/index.mdx +11 -8
- package/site/content/docs/elements/vault/secrets.mdx +6 -3
- package/site/content/docs/index.mdx +1 -1
- package/site/content/docs/recipes/rustfs.mdx +1 -1
- package/site/content/docs/reference/configuration.mdx +1 -1
- package/site/content/docs/reference/errors.mdx +199 -25
- package/site/content/docs/reference/fx.mdx +6 -1
- package/site/content/docs/understand/the-architecture.mdx +2 -2
- package/site/content/docs/understand/try-it.mdx +758 -25
- package/src/cli/dev-app-runner.ts +2 -1
- package/src/cli/start.ts +2 -1
- package/src/client/create.ts +3 -3
- package/src/client/explain.test.ts +252 -0
- package/src/client/explain.ts +272 -0
- package/src/client/live.test.ts +44 -0
- package/src/client/notes-contract.test.ts +10 -0
- package/src/client/sse.ts +6 -2
- package/src/client/transport.test.ts +67 -0
- package/src/client/transport.ts +24 -40
- package/src/client/types.ts +17 -6
- package/src/client-react/live-resource.ts +6 -2
- package/src/compiler/response.ts +17 -27
- package/src/console/server/invoke-user-flow.test.ts +8 -2
- package/src/console/server/invoke-user-flow.ts +12 -18
- package/src/console/server/security.gate.test.ts +1 -1
- package/src/console/ui-next/dist/assets/{access-page-DFLu0wTA.js → access-page-tIbsiphz.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-DGscxaF5.js → agent-disclosure-CSKumwS2.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-BGmRZk7d.js → cache-glyph-BCC-DxKT.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button--feUYxvG.js → call-pii-button-jOmYISdc.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-JWvpaiGY.js → collapsible-DC2xNaAb.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-D9yCJG4n.js → duration-tone-CwoV56jn.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-Bs6MD9GB.js → flows-page-gT1lsWQK.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-xH8MrEnv.js → highlighted-json-C2GZEJNI.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-C4vB6ZIw.js → http-method-BdYjcIrD.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-yTCY4AcS.js → index-Cul17AcV.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-BxJ3R6dU.js → observability-page-oY9vdYBk.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-QRKB_IE8.js → replica-lag-C4QdAF7J.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-DqZ-fMu5.js → request-meta-C43DHyld.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-Dixb6L7a.js → store-page-CL0D9dOq.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-CazhjtiU.js → trace-detail-sheet-Dcpo4Us_.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-DlnqYKfr.js → tree-expand-toggle-CKJTuv43.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-BXTLjU2-.js → units-page-1PlT19ft.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-39KR__bc.js → vault-page-BwZ9YjTW.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/docker/docker.test.ts +3 -3
- package/src/docker/images-config.test.ts +4 -4
- package/src/docker/stack-id.test.ts +1 -1
- package/src/elements/store/files-errors.test.ts +149 -0
- package/src/elements/store/files-errors.ts +189 -0
- package/src/elements/store/kv-errors.test.ts +98 -0
- package/src/elements/store/kv-errors.ts +139 -0
- package/src/elements/store/resource.ts +11 -7
- package/src/elements/store/runtime.ts +18 -13
- package/src/elements/store/sql-errors.test.ts +197 -0
- package/src/elements/store/sql-errors.ts +294 -0
- package/src/elements/store/sql-session.test.ts +52 -0
- package/src/elements/store/sql-session.ts +6 -0
- package/src/elements/store/store-errors.ts +47 -0
- package/src/http.ts +9 -1
- package/src/i18n/catalogs/ar.ts +18 -0
- package/src/i18n/catalogs/en.ts +18 -0
- package/src/index.ts +9 -1
- package/src/kernel/app.ts +14 -2
- package/src/kernel/builtin-errors.test.ts +117 -0
- package/src/kernel/builtin-errors.ts +129 -0
- package/src/kernel/call.test.ts +182 -0
- package/src/kernel/errors-vault.ts +16 -0
- package/src/kernel/errors.registry.test.ts +7 -0
- package/src/kernel/errors.ts +94 -21
- package/src/kernel/fail-helpers.ts +34 -0
- package/src/kernel/fx.test.ts +8 -0
- package/src/kernel/fx.ts +12 -6
- package/src/kernel/index.ts +12 -1
- package/src/runtime/dev-request-log.ts +29 -11
- package/src/term.test.ts +76 -0
- package/src/term.ts +166 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "okengine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
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": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: "Calling"
|
|
3
|
-
description: "createClient forms, REST vs RPC,
|
|
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
|
|
17
|
-
|
|
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 {
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
406
|
-
|
|
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) —
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 |
|
|
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** —
|
|
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(
|
|
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": "
|
|
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
|
-
- `
|
|
991
|
-
-
|
|
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
|
|
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(
|
|
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
|
|
322
|
-
|
|
|
323
|
-
| `ValidationError`
|
|
324
|
-
| `Unauthorized`
|
|
325
|
-
| `Forbidden`
|
|
326
|
-
| `
|
|
327
|
-
|
|
|
328
|
-
|
|
329
|
-
|
|
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.
|
|
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
|
|
550
|
-
fx.fail("OutOfStock", payload)
|
|
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="
|
|
560
|
-
|
|
561
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
|
@@ -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(
|
|
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(
|
|
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 |
|
|
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
|
|