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
|
@@ -1,60 +1,793 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: "Try It"
|
|
3
|
-
description: "
|
|
3
|
+
description: "Scaffold an app, see the files, add GET and POST /users, save a row — then email and the rest of the model if you want."
|
|
4
4
|
icon: "Terminal"
|
|
5
|
+
source: "docs/spec/unified-theory.md"
|
|
5
6
|
---
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
Scaffold a backend. See which file is `GET /health`. Add a users routes file. Save a row.
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
Console on **6533** is derived from that code. API is **6530**. Stop after the first working POST if you want — keep reading for email, then the rest of the model.
|
|
11
|
+
|
|
12
|
+
<Callout title="The one rule">
|
|
13
|
+
Every URL is a Flow: `on(trigger, flow({ do }))`. Talk to the database, mail, and clock only
|
|
14
|
+
through `fx` (the second argument to `do`). In a many-routes file you write the path. In a
|
|
15
|
+
one-route file you can omit it.
|
|
16
|
+
</Callout>
|
|
17
|
+
|
|
18
|
+
## Create
|
|
19
|
+
|
|
20
|
+
Your app runs on the host. Postgres, Redis, and mail run in Docker so local is the same _shape_ as production — protocol drivers, not a laptop-only stack.
|
|
21
|
+
|
|
22
|
+
<Callout title="Why Bun and Docker">
|
|
23
|
+
OKE is [Bun](https://bun.sh) **≥ 1.4.2** (native SQL, Redis, S3, streams) —
|
|
24
|
+
[install](https://bun.sh/docs/installation). [Docker](https://docs.docker.com/get-docker/) runs
|
|
25
|
+
Postgres / Redis / mail so local matches production; your app stays on the host.
|
|
26
|
+
</Callout>
|
|
10
27
|
|
|
11
28
|
```bash
|
|
12
|
-
|
|
13
|
-
|
|
29
|
+
# [!code focus:2]
|
|
30
|
+
bun --version # 1.4.2 or newer
|
|
31
|
+
docker info # engine running
|
|
14
32
|
```
|
|
15
33
|
|
|
16
|
-
|
|
34
|
+
<Steps>
|
|
35
|
+
|
|
36
|
+
<Step>
|
|
37
|
+
### Scaffold and run
|
|
17
38
|
|
|
18
39
|
```bash
|
|
19
|
-
bunx create-oke@latest my-app
|
|
40
|
+
bunx create-oke@latest my-app # [!code focus]
|
|
20
41
|
cd my-app
|
|
21
|
-
bun run dev
|
|
42
|
+
bun run dev # [!code focus]
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Default is `-t blank`. `-t shorter` is a finished URL shortener to read later — not this walkthrough.
|
|
46
|
+
|
|
47
|
+
</Step>
|
|
48
|
+
|
|
49
|
+
<Step>
|
|
50
|
+
### Call health
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
curl -X GET http://localhost:6530/health -H "accept: application/json" # [!code focus]
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{ "data": { "ok": true }, "error": null } // [!code focus]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Every call returns `{ data, error }` — see [What comes back](#what-comes-back). The typed client uses the same envelope.
|
|
61
|
+
|
|
62
|
+
Open Console at **6533** and claim it with the code in the terminal. Health is listed because the file exists, not because you registered a dashboard.
|
|
63
|
+
|
|
64
|
+
</Step>
|
|
65
|
+
|
|
66
|
+
</Steps>
|
|
67
|
+
|
|
68
|
+
## The files
|
|
69
|
+
|
|
70
|
+
OKE does not ship one folder layout. Roles matter: **Flow** is behavior, **Store** is data at rest, **root** is how the process boots. Names below are common — not a law.
|
|
71
|
+
|
|
72
|
+
| Role | Meaning | Often lives at |
|
|
73
|
+
| ----------- | -------------------------------------------------------------------------------------- | --------------------------------------- |
|
|
74
|
+
| Root config | Drivers and Docker images for `dev` / `test` / `prod` | `oke.config.ts` |
|
|
75
|
+
| Boot | `oke({ name })` — the process | `src/app.ts` |
|
|
76
|
+
| Flow | Behavior: a URL, a job, a worker. A folder is a router; a file is a route (or several) | `src/flows/<router>/` |
|
|
77
|
+
| Store | Tables and other data at rest. `db.declare` in config points here | `src/db/schema.ts` or `src/db/schema/` |
|
|
78
|
+
| Vault | Secret and config _names_ — not values | `src/vault.ts` or `src/core/vault.ts` |
|
|
79
|
+
| Wiring | Imported before `oke()` so store, mail, gates exist | `src/core.ts` or `src/core/` |
|
|
80
|
+
| Generated | Catalog of Flows; SQL emit from your tables. Do not hand-edit | `src/flows/index.ts`, `src/db/drizzle/` |
|
|
81
|
+
| Tests | `createTestApp` — deterministic drivers | `tests/` |
|
|
82
|
+
|
|
83
|
+
The blank starter is one arrangement of those roles (`main/health.ts` is `GET /health`). Shorter splits `core/` and uses one file per verb. Both are OKE.
|
|
84
|
+
|
|
85
|
+
<Callout title="Write the path in a many-routes file">
|
|
86
|
+
One file, one URL can omit the path. Next you put `GET` and `POST` in one file, so you write
|
|
87
|
+
`"/users"` yourself. Pathless HTTP there fails **OKE1040**.
|
|
88
|
+
</Callout>
|
|
89
|
+
|
|
90
|
+
## First HTTP
|
|
91
|
+
|
|
92
|
+
You add a users router. This walkthrough uses `src/flows/users/` — a folder name you choose, not a required path.
|
|
93
|
+
|
|
94
|
+
```text
|
|
95
|
+
src/flows/users/
|
|
96
|
+
index.ts # GET + POST /users
|
|
97
|
+
shapes.ts # optional — shared Zod
|
|
22
98
|
```
|
|
23
99
|
|
|
24
|
-
|
|
100
|
+
Do not add `users/route.ts` next to this `index.ts`. Do not omit `"/users"`. `oke dev` rewrites `src/flows/index.ts` when the folder appears.
|
|
101
|
+
|
|
102
|
+
<Steps>
|
|
103
|
+
|
|
104
|
+
<Step>
|
|
105
|
+
### Contracts
|
|
106
|
+
|
|
107
|
+
`in` is the JSON you send. `out` is what `do` returns — that value is `data` in the envelope. List is `UserOut[]`; create is one `UserOut`.
|
|
108
|
+
|
|
109
|
+
<Tabs items={["In the router", "shapes.ts"]}>
|
|
110
|
+
|
|
111
|
+
<Tab value="In the router">
|
|
112
|
+
|
|
113
|
+
```typescript title="src/flows/users/index.ts"
|
|
114
|
+
import { z } from "zod";
|
|
115
|
+
|
|
116
|
+
// [!code focus:4]
|
|
117
|
+
export const CreateIn = z.object({
|
|
118
|
+
email: z.string().email(),
|
|
119
|
+
name: z.string().min(1),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// [!code focus:4]
|
|
123
|
+
export const UserOut = z.object({
|
|
124
|
+
email: z.string().email(),
|
|
125
|
+
name: z.string(),
|
|
126
|
+
});
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
</Tab>
|
|
130
|
+
|
|
131
|
+
<Tab value="shapes.ts">
|
|
132
|
+
|
|
133
|
+
```typescript title="src/flows/users/shapes.ts"
|
|
134
|
+
import { z } from "zod";
|
|
135
|
+
|
|
136
|
+
// [!code focus:4]
|
|
137
|
+
export const CreateIn = z.object({
|
|
138
|
+
email: z.string().email(),
|
|
139
|
+
name: z.string().min(1),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// [!code focus:4]
|
|
143
|
+
export const UserOut = z.object({
|
|
144
|
+
email: z.string().email(),
|
|
145
|
+
name: z.string(),
|
|
146
|
+
});
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
```typescript title="src/flows/users/index.ts"
|
|
150
|
+
import { CreateIn, UserOut } from "./shapes"; // [!code focus]
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
</Tab>
|
|
154
|
+
|
|
155
|
+
</Tabs>
|
|
156
|
+
|
|
157
|
+
</Step>
|
|
158
|
+
|
|
159
|
+
<Step>
|
|
160
|
+
### Routes
|
|
161
|
+
|
|
162
|
+
Each export is one Flow. `CreateIn` / `UserOut` come from the Contracts step.
|
|
163
|
+
|
|
164
|
+
<Callout title=".public()">
|
|
165
|
+
Every HTTP trigger needs `.public()` or `.gate(...)`. Omit both and boot fails (`GateBootError`).
|
|
166
|
+
`.public()` means anyone may call — no session. `GET /health` already uses it. `.gate(member)`
|
|
167
|
+
comes later when a route needs a signed-in user ([Gate](/docs/elements/gate)).
|
|
168
|
+
</Callout>
|
|
169
|
+
|
|
170
|
+
```typescript title="src/flows/users/index.ts"
|
|
171
|
+
import { on, flow, http } from "okengine/http";
|
|
172
|
+
import { z } from "zod";
|
|
173
|
+
|
|
174
|
+
// [!code focus:6]
|
|
175
|
+
export const list = on(
|
|
176
|
+
http.get("/users", { out: z.array(UserOut) }).public(),
|
|
177
|
+
flow("users.list", {
|
|
178
|
+
do: () => [],
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
// [!code focus:6]
|
|
183
|
+
export const create = on(
|
|
184
|
+
http.post("/users", { in: CreateIn, out: UserOut }).public(),
|
|
185
|
+
flow("users.create", {
|
|
186
|
+
do: async (input) => input,
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
</Step>
|
|
192
|
+
|
|
193
|
+
<Step>
|
|
194
|
+
### What comes back
|
|
25
195
|
|
|
26
|
-
|
|
196
|
+
Every HTTP Flow returns `{ data, error }` (optional `meta`). You return a value from `do`; the kernel wraps it. Failures are **values** — not thrown.
|
|
27
197
|
|
|
28
|
-
|
|
198
|
+
| Field | Success | Failure |
|
|
199
|
+
| ------- | -------------------------------- | ------------------------------------------------------------------------------- |
|
|
200
|
+
| `data` | `out` — what `do` returned | `null` |
|
|
201
|
+
| `error` | `null` | `{ code, message?, data? }` |
|
|
202
|
+
| Status | `200` (`fx.json.create` → `201`) | `422` if `in` fails; `fx.fail.notFound` → `404`; domain `fx.fail` codes → `400` |
|
|
29
203
|
|
|
30
|
-
|
|
204
|
+
Bad `in` never runs `do`:
|
|
205
|
+
|
|
206
|
+
```json
|
|
207
|
+
{
|
|
208
|
+
"data": null,
|
|
209
|
+
"error": {
|
|
210
|
+
"code": "ValidationError", // [!code focus]
|
|
211
|
+
"message": "The request failed validation."
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
On the client, branch on `error` first:
|
|
31
217
|
|
|
32
218
|
```typescript
|
|
33
219
|
import { createClient } from "okengine/client";
|
|
34
|
-
import
|
|
220
|
+
import { app } from "@/app";
|
|
221
|
+
|
|
222
|
+
const api = createClient(app, "http://localhost:6530"); // [!code focus]
|
|
223
|
+
// [!code focus:4]
|
|
224
|
+
const { data, error } = await api.users.create({
|
|
225
|
+
email: "you@localhost",
|
|
226
|
+
name: "You",
|
|
227
|
+
});
|
|
228
|
+
// [!code focus:4]
|
|
229
|
+
if (error) {
|
|
230
|
+
// error.code — ValidationError, or a code you declared with fx.fail
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
data.email; // [!code focus]
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Full table: [HTTP · Response Envelopes](/docs/elements/flow/http#response-envelopes).
|
|
237
|
+
|
|
238
|
+
</Step>
|
|
239
|
+
|
|
240
|
+
<Step>
|
|
241
|
+
### Call both
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
curl -X GET http://localhost:6530/users -H "accept: application/json" # [!code focus]
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`do` returned `[]`, so `data` is an empty array:
|
|
248
|
+
|
|
249
|
+
```json
|
|
250
|
+
{ "data": [], "error": null } // [!code focus]
|
|
251
|
+
```
|
|
35
252
|
|
|
36
|
-
|
|
37
|
-
|
|
253
|
+
```bash
|
|
254
|
+
curl -X POST http://localhost:6530/users \
|
|
255
|
+
-H "content-type: application/json" \
|
|
256
|
+
-d '{"email":"you@localhost","name":"You"}' # [!code focus]
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
`do` returned the body, so `data` is one `UserOut`:
|
|
260
|
+
|
|
261
|
+
```json
|
|
262
|
+
{
|
|
263
|
+
"data": { "email": "you@localhost", "name": "You" }, // [!code focus]
|
|
264
|
+
"error": null
|
|
265
|
+
}
|
|
38
266
|
```
|
|
39
267
|
|
|
40
|
-
|
|
268
|
+
</Step>
|
|
269
|
+
|
|
270
|
+
</Steps>
|
|
271
|
+
|
|
272
|
+
## First Store
|
|
273
|
+
|
|
274
|
+
`src/db/schema.ts` was empty. `src/core.ts` already has `store.sql("app", { schema })` — do not move it.
|
|
275
|
+
|
|
276
|
+
```text
|
|
277
|
+
src/db/schema.ts # you fill this
|
|
278
|
+
src/core.ts # db is already here
|
|
279
|
+
src/flows/users/index.ts
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
<Steps>
|
|
283
|
+
|
|
284
|
+
<Step>
|
|
285
|
+
### Declare the table
|
|
286
|
+
|
|
287
|
+
```typescript title="src/db/schema.ts"
|
|
288
|
+
import { store, field } from "okengine";
|
|
289
|
+
|
|
290
|
+
// [!code focus:6]
|
|
291
|
+
export const users = store.schema.table("users", {
|
|
292
|
+
id: field.id().primaryKey(),
|
|
293
|
+
email: field.text().notNull(),
|
|
294
|
+
name: field.text().notNull(),
|
|
295
|
+
createdAt: field.timestamp().notNull().now(),
|
|
296
|
+
});
|
|
297
|
+
```
|
|
41
298
|
|
|
42
|
-
|
|
299
|
+
`oke dev` pushes schema. Identity and time go through `fx` — `fx.id()`, not a hand-rolled uuid.
|
|
300
|
+
|
|
301
|
+
</Step>
|
|
302
|
+
|
|
303
|
+
<Step>
|
|
304
|
+
### Read and write in the routes file
|
|
305
|
+
|
|
306
|
+
Widen `UserOut` with `id` and `createdAt` — same file as the other contracts (`index.ts` or `shapes.ts`):
|
|
43
307
|
|
|
44
308
|
```typescript
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
309
|
+
export const UserOut = z.object({
|
|
310
|
+
id: z.string(), // [!code focus]
|
|
311
|
+
email: z.string().email(),
|
|
312
|
+
name: z.string(),
|
|
313
|
+
createdAt: z.iso.datetime(), // [!code focus]
|
|
49
314
|
});
|
|
50
315
|
```
|
|
51
316
|
|
|
317
|
+
```typescript title="src/flows/users/index.ts"
|
|
318
|
+
import { db } from "@/core"; // [!code focus]
|
|
319
|
+
import { users } from "@/db/schema"; // [!code focus]
|
|
320
|
+
|
|
321
|
+
export const list = on(
|
|
322
|
+
http.get("/users", { out: z.array(UserOut) }).public(),
|
|
323
|
+
flow("users.list", {
|
|
324
|
+
// [!code focus:6]
|
|
325
|
+
do: async (_input, fx) => {
|
|
326
|
+
return await fx.store(db).page(users, {
|
|
327
|
+
orderBy: [users.createdAt],
|
|
328
|
+
limit: 20,
|
|
329
|
+
});
|
|
330
|
+
},
|
|
331
|
+
}),
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
export const create = on(
|
|
335
|
+
http.post("/users", { in: CreateIn, out: UserOut }).public(),
|
|
336
|
+
flow("users.create", {
|
|
337
|
+
// [!code focus:8]
|
|
338
|
+
do: async (input, fx) => {
|
|
339
|
+
const [row] = await fx
|
|
340
|
+
.store(db)
|
|
341
|
+
.insert(users)
|
|
342
|
+
.values({ id: fx.id(), email: input.email, name: input.name })
|
|
343
|
+
.returning();
|
|
344
|
+
return fx.json.create(row);
|
|
345
|
+
},
|
|
346
|
+
}),
|
|
347
|
+
);
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
`fx.json.create` is **201**. Declared `out` projects the row — extra columns strip; timestamps become ISO-8601.
|
|
351
|
+
|
|
352
|
+
</Step>
|
|
353
|
+
|
|
354
|
+
<Step>
|
|
355
|
+
### Call it and test
|
|
356
|
+
|
|
52
357
|
```bash
|
|
53
|
-
|
|
358
|
+
curl -X POST http://localhost:6530/users \
|
|
359
|
+
-H "content-type: application/json" \
|
|
360
|
+
-d '{"email":"you@localhost","name":"You"}' # [!code focus]
|
|
54
361
|
```
|
|
55
362
|
|
|
56
|
-
|
|
363
|
+
**201** — `data` is the projected row (`id` + ISO `createdAt`):
|
|
364
|
+
|
|
365
|
+
```json
|
|
366
|
+
{
|
|
367
|
+
"data": {
|
|
368
|
+
"id": "…", // [!code focus]
|
|
369
|
+
"email": "you@localhost",
|
|
370
|
+
"name": "You",
|
|
371
|
+
"createdAt": "2026-09-15T14:44:00.000Z" // [!code focus]
|
|
372
|
+
},
|
|
373
|
+
"error": null
|
|
374
|
+
}
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
```typescript title="tests/users.test.ts"
|
|
378
|
+
import { afterAll, beforeAll, expect, test } from "bun:test";
|
|
379
|
+
import { createTestApp, type TestApp } from "okengine/test";
|
|
380
|
+
import { app, type App } from "@/app";
|
|
381
|
+
|
|
382
|
+
let t: TestApp<App>;
|
|
383
|
+
|
|
384
|
+
// [!code focus:3]
|
|
385
|
+
beforeAll(async () => {
|
|
386
|
+
t = await createTestApp(app);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
afterAll(async () => {
|
|
390
|
+
await t.close();
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// [!code focus:8]
|
|
394
|
+
test("creates a user", async () => {
|
|
395
|
+
const { data, error } = await t.api.users.create({
|
|
396
|
+
email: "you@localhost",
|
|
397
|
+
name: "You",
|
|
398
|
+
});
|
|
399
|
+
expect(error).toBeNull();
|
|
400
|
+
expect(data?.email).toBe("you@localhost");
|
|
401
|
+
});
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
```bash
|
|
405
|
+
bun test # [!code focus]
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
`createTestApp` swaps every driver behind `fx` for a deterministic one.
|
|
409
|
+
|
|
410
|
+
</Step>
|
|
411
|
+
|
|
412
|
+
</Steps>
|
|
413
|
+
|
|
414
|
+
<Callout title="You can stop here">
|
|
415
|
+
This is a working OKE app: files under `src/flows/` are the API, `fx` is the door, Console lists
|
|
416
|
+
what you wrote. What follows is email off the request path, then optional model.
|
|
417
|
+
</Callout>
|
|
418
|
+
|
|
419
|
+
## First Signal
|
|
420
|
+
|
|
421
|
+
The POST should return the user before welcome-mail work runs. `signal.once` is a competing queue: one worker claims each message.
|
|
422
|
+
|
|
423
|
+
```text
|
|
424
|
+
src/signals/users.ts # declare — not a URL
|
|
425
|
+
src/flows/users/index.ts # emit after insert
|
|
426
|
+
src/flows/workers/welcome.ts # bind worker
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
Keep the worker in its own unit. A Flow file next to `users/index.ts` is a generate error (`mixes a barrel index.ts with tree route files`).
|
|
430
|
+
|
|
431
|
+
<Steps>
|
|
432
|
+
|
|
433
|
+
<Step>
|
|
434
|
+
### Declare
|
|
435
|
+
|
|
436
|
+
Physics live on the Signal. The worker inherits this payload type — not `flow.in`.
|
|
437
|
+
|
|
438
|
+
```typescript title="src/signals/users.ts"
|
|
439
|
+
import { signal } from "okengine";
|
|
440
|
+
import { z } from "zod";
|
|
441
|
+
|
|
442
|
+
// [!code focus:9]
|
|
443
|
+
export const userRegistered = signal.once("users.registered", {
|
|
444
|
+
schema: z.object({
|
|
445
|
+
id: z.string(),
|
|
446
|
+
email: z.string().email(),
|
|
447
|
+
name: z.string(),
|
|
448
|
+
}),
|
|
449
|
+
retries: 3,
|
|
450
|
+
deadLetter: true,
|
|
451
|
+
});
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
</Step>
|
|
455
|
+
|
|
456
|
+
<Step>
|
|
457
|
+
### Bind the worker
|
|
458
|
+
|
|
459
|
+
The worker is a Flow that is not a route. Name it — nameless `flow({ do })` here is **OKE1072**. `once` binds **one** worker; next section edits this `do`, it does not add a second `on`.
|
|
460
|
+
|
|
461
|
+
```typescript title="src/flows/workers/welcome.ts"
|
|
462
|
+
import { on, flow } from "okengine";
|
|
463
|
+
import { userRegistered } from "@/signals/users";
|
|
464
|
+
|
|
465
|
+
// [!code focus:6]
|
|
466
|
+
export const welcome = on(
|
|
467
|
+
userRegistered,
|
|
468
|
+
flow("workers.welcome", {
|
|
469
|
+
do: async () => {},
|
|
470
|
+
}),
|
|
471
|
+
);
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
</Step>
|
|
475
|
+
|
|
476
|
+
<Step>
|
|
477
|
+
### Emit after insert
|
|
478
|
+
|
|
479
|
+
In `create`, after the insert. `{ key: row.id }` is one job per user — retries stay ordered; different ids run in parallel.
|
|
480
|
+
|
|
481
|
+
```typescript title="src/flows/users/index.ts"
|
|
482
|
+
import { userRegistered } from "@/signals/users"; // [!code focus]
|
|
483
|
+
|
|
484
|
+
export const create = on(
|
|
485
|
+
http.post("/users", { in: CreateIn, out: UserOut }).public(),
|
|
486
|
+
flow("users.create", {
|
|
487
|
+
do: async (input, fx) => {
|
|
488
|
+
const [row] = await fx
|
|
489
|
+
.store(db)
|
|
490
|
+
.insert(users)
|
|
491
|
+
.values({ id: fx.id(), email: input.email, name: input.name })
|
|
492
|
+
.returning();
|
|
493
|
+
if (!row) return; // [!code focus]
|
|
494
|
+
// [!code focus:5]
|
|
495
|
+
await fx.emit(
|
|
496
|
+
userRegistered,
|
|
497
|
+
{ id: row.id, email: row.email, name: row.name },
|
|
498
|
+
{ key: row.id },
|
|
499
|
+
);
|
|
500
|
+
return fx.json.create(row);
|
|
501
|
+
},
|
|
502
|
+
}),
|
|
503
|
+
);
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
POST `/users` again — **201** is the same envelope. The worker runs after that response.
|
|
507
|
+
|
|
508
|
+
</Step>
|
|
509
|
+
|
|
510
|
+
</Steps>
|
|
511
|
+
|
|
512
|
+
## First Channel
|
|
513
|
+
|
|
514
|
+
Mailpit is already in the blank images. Add a template, import it from `core.ts` (that file is already loaded before `oke()`).
|
|
515
|
+
|
|
516
|
+
```text
|
|
517
|
+
src/email.ts # you add this
|
|
518
|
+
src/core.ts # re-export
|
|
519
|
+
src/flows/workers/welcome.ts # worker calls fx.send
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
<Steps>
|
|
523
|
+
|
|
524
|
+
<Step>
|
|
525
|
+
### Template
|
|
526
|
+
|
|
527
|
+
```typescript title="src/email.ts"
|
|
528
|
+
import { channel } from "okengine";
|
|
529
|
+
import { z } from "zod";
|
|
530
|
+
|
|
531
|
+
const mail = channel.email({ from: "App <app@localhost>" }); // [!code focus]
|
|
532
|
+
|
|
533
|
+
// [!code focus:11]
|
|
534
|
+
export const welcomeMail = mail.template("welcome", {
|
|
535
|
+
description: "Welcome after signup",
|
|
536
|
+
locales: ["en"],
|
|
537
|
+
schema: z.object({ name: z.string() }),
|
|
538
|
+
catalog: {
|
|
539
|
+
en: {
|
|
540
|
+
subject: "Welcome, {{name}}",
|
|
541
|
+
text: "Hi {{name}} — your account is ready.",
|
|
542
|
+
},
|
|
543
|
+
},
|
|
544
|
+
});
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
```typescript title="src/core.ts"
|
|
548
|
+
export * from "@/email"; // [!code focus]
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
</Step>
|
|
552
|
+
|
|
553
|
+
<Step>
|
|
554
|
+
### Send from the worker
|
|
555
|
+
|
|
556
|
+
```typescript title="src/flows/workers/welcome.ts"
|
|
557
|
+
import { on, flow } from "okengine";
|
|
558
|
+
import { userRegistered } from "@/signals/users";
|
|
559
|
+
import { welcomeMail } from "@/core"; // [!code focus]
|
|
560
|
+
|
|
561
|
+
export const welcome = on(
|
|
562
|
+
userRegistered,
|
|
563
|
+
flow("workers.welcome", {
|
|
564
|
+
// [!code focus:6]
|
|
565
|
+
do: async (payload, fx) => {
|
|
566
|
+
await fx.send(welcomeMail, {
|
|
567
|
+
to: payload.email,
|
|
568
|
+
data: { name: payload.name },
|
|
569
|
+
});
|
|
570
|
+
},
|
|
571
|
+
}),
|
|
572
|
+
);
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
Open Mailpit at `http://127.0.0.1:8025` (`MAILPIT_UI_URL` in `src/vault.ts`). POST `/users` again — the message lands after the HTTP response.
|
|
576
|
+
|
|
577
|
+
</Step>
|
|
578
|
+
|
|
579
|
+
</Steps>
|
|
580
|
+
|
|
581
|
+
## Split files or one mount
|
|
582
|
+
|
|
583
|
+
Optional. You already have a working routes file. These two shapes omit hand-written paths — **never mix them with `users/index.ts`**, and never put `list.ts` next to `route.ts`.
|
|
584
|
+
|
|
585
|
+
<Tabs items={["Split files", "One mount"]}>
|
|
586
|
+
|
|
587
|
+
<Tab value="Split files">
|
|
588
|
+
|
|
589
|
+
Delete `users/index.ts`. One verb per file. `list` / `create` / `get` do **not** add `/list` to the URL.
|
|
590
|
+
|
|
591
|
+
```text
|
|
592
|
+
src/flows/users/
|
|
593
|
+
list.ts # GET /users
|
|
594
|
+
create.ts # POST /users
|
|
595
|
+
[id]/get.ts # GET /users/:id
|
|
596
|
+
shapes.ts
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
```typescript title="src/flows/users/list.ts"
|
|
600
|
+
import { on, flow, http } from "okengine/http";
|
|
601
|
+
|
|
602
|
+
export const list = on(
|
|
603
|
+
http.get().public(), // [!code focus]
|
|
604
|
+
flow({
|
|
605
|
+
do: async (_input, fx) => fx.store(db).page(users, { orderBy: [users.createdAt], limit: 20 }),
|
|
606
|
+
}),
|
|
607
|
+
);
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
Pathless `http.get()` is correct **here**. Shorter (`-t shorter`) is this tree in a real app.
|
|
611
|
+
|
|
612
|
+
</Tab>
|
|
613
|
+
|
|
614
|
+
<Tab value="One mount">
|
|
615
|
+
|
|
616
|
+
`store.resource` builds list / create / get / update / remove. `http.resource` mounts them. There is no pathless resource — pass `"/users"`.
|
|
617
|
+
|
|
618
|
+
```typescript title="src/flows/users/resource.ts"
|
|
619
|
+
import { store } from "okengine";
|
|
620
|
+
import { z } from "zod";
|
|
621
|
+
import { db } from "@/core";
|
|
622
|
+
import { users } from "@/db/schema";
|
|
623
|
+
|
|
624
|
+
// [!code focus:11]
|
|
625
|
+
export const usersResource = store.resource(db, users, {
|
|
626
|
+
in: z.object({
|
|
627
|
+
email: z.string().email(),
|
|
628
|
+
name: z.string().min(1),
|
|
629
|
+
}),
|
|
630
|
+
out: z.object({
|
|
631
|
+
id: z.string(),
|
|
632
|
+
email: z.string(),
|
|
633
|
+
name: z.string(),
|
|
634
|
+
}),
|
|
635
|
+
});
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
```typescript title="src/flows/users/index.ts"
|
|
639
|
+
import { on, http } from "okengine/http";
|
|
640
|
+
import { usersResource } from "./resource";
|
|
641
|
+
|
|
642
|
+
export const usersApi = on(http.resource("/users", usersResource.all()).public()); // [!code focus]
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
Client: `api.users.list` / `.create` / `.get` / `.update` / `.remove`. Update is **PATCH**. Full table: [HTTP · Resources](/docs/elements/flow/http#resources).
|
|
646
|
+
|
|
647
|
+
</Tab>
|
|
648
|
+
|
|
649
|
+
</Tabs>
|
|
650
|
+
|
|
651
|
+
## The rest of the model
|
|
652
|
+
|
|
653
|
+
Same app. One snippet each — not four more labs.
|
|
654
|
+
|
|
655
|
+
### Gate
|
|
656
|
+
|
|
657
|
+
You already used `.public()`. Every HTTP trigger needs `.public()` or `.gate(...)` — boot lists the gaps:
|
|
658
|
+
|
|
659
|
+
```text
|
|
660
|
+
gate boot failed — 2 trigger(s) missing auth posture (attach a gate or .public()):
|
|
661
|
+
- users.list GET /users
|
|
662
|
+
- users.create POST /users
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
A signed-in check is a policy on the trigger. Identity is `oke({ gate: { auth } })` plus a plugin — [Gate · Auth](/docs/elements/gate/auth), not this sitting.
|
|
666
|
+
|
|
667
|
+
```typescript title="src/core.ts"
|
|
668
|
+
import { gate } from "okengine";
|
|
669
|
+
|
|
670
|
+
// [!code focus:4]
|
|
671
|
+
export const member = gate.policy("member", {
|
|
672
|
+
description: "Signed-in user",
|
|
673
|
+
check: ({ auth }) => !!auth.verified,
|
|
674
|
+
});
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
```typescript
|
|
678
|
+
http.get("/users/me").gate(member); // [!code focus]
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
### Clock
|
|
682
|
+
|
|
683
|
+
Time is an element. `Date.now()` inside `do` is a defect. While `users/index.ts` is still the routes file, put the schedule **in that file** — do not add `expire.ts` beside `index.ts`.
|
|
684
|
+
|
|
685
|
+
```typescript
|
|
686
|
+
import { clock } from "okengine/http";
|
|
687
|
+
|
|
688
|
+
export const expire = on(
|
|
689
|
+
clock.every("users.expire", "1h"), // [!code focus]
|
|
690
|
+
flow("users.expire", {
|
|
691
|
+
do: async (_input, fx) => ({ at: fx.clock.now() }), // [!code focus]
|
|
692
|
+
}),
|
|
693
|
+
);
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
`fx.clock.now()` is epoch-ms. [Clock](/docs/elements/clock).
|
|
697
|
+
|
|
698
|
+
### Vault
|
|
699
|
+
|
|
700
|
+
`src/vault.ts` already lists contracts. Values resolve at boot — not in the file. Read through `fx`:
|
|
701
|
+
|
|
702
|
+
```typescript
|
|
703
|
+
import { publicApiUrl } from "@/core";
|
|
704
|
+
|
|
705
|
+
const origin = await fx.vault.get(publicApiUrl); // [!code focus]
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
Never log a revealed secret. [Vault](/docs/elements/vault).
|
|
709
|
+
|
|
710
|
+
### AI
|
|
711
|
+
|
|
712
|
+
Optional. `fx.ask` is the same door as `fx.store`. Needs a key — skip it to finish Try It.
|
|
713
|
+
|
|
714
|
+
```typescript
|
|
715
|
+
return await fx.ask(triage, { message }); // [!code focus]
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
`oke ai setup` and [AI](/docs/elements/ai).
|
|
719
|
+
|
|
720
|
+
## Troubleshooting
|
|
721
|
+
|
|
722
|
+
<Accordions>
|
|
723
|
+
|
|
724
|
+
<Accordion title="bun --version is below 1.4.2">
|
|
725
|
+
OKE needs Bun ≥ 1.4.2 (`engines.bun`). Install or upgrade from
|
|
726
|
+
[bun.sh/docs/installation](https://bun.sh/docs/installation), then `bun --version`.
|
|
727
|
+
</Accordion>
|
|
728
|
+
|
|
729
|
+
<Accordion title="Docker is not running">
|
|
730
|
+
`oke dev` starts Postgres, Redis, and Mailpit in Compose so local matches production. Install
|
|
731
|
+
[Docker](https://docs.docker.com/get-docker/), start the engine, then `bun run dev`.
|
|
732
|
+
</Accordion>
|
|
733
|
+
|
|
734
|
+
<Accordion title="OKE1040 — pathless HTTP never stamped">
|
|
735
|
+
Cause: `Flow "{flow}" bound {method} with no path — the file-tree stamp never ran.` In
|
|
736
|
+
`users/index.ts` pass `http.get("/users")`. Pathless `http.get()` belongs on one-file-per-route
|
|
737
|
+
folders (`health.ts`, or `list.ts` after you delete `index.ts`).
|
|
738
|
+
</Accordion>
|
|
739
|
+
|
|
740
|
+
<Accordion title="Unit mixes index.ts with other route files">
|
|
741
|
+
Cause: `Unit "users" mixes a barrel index.ts with tree route files.` Keep only `index.ts` (and
|
|
742
|
+
`shapes.ts`). Signals in `src/signals/`; workers in another unit (`workers/welcome.ts`). After
|
|
743
|
+
deleting `index.ts`, tree files (`list.ts` / `create.ts`) can share the folder.
|
|
744
|
+
</Accordion>
|
|
745
|
+
|
|
746
|
+
<Accordion title="OKE1072 — Signal or Clock flow unnamed">
|
|
747
|
+
Cause: `A {kind} flow on "{trigger}" has no name.`
|
|
748
|
+
Pass `flow("workers.welcome", { do })` on the Signal worker, `flow("users.expire", { do })`
|
|
749
|
+
on a Clock in the routes file.
|
|
750
|
+
</Accordion>
|
|
751
|
+
|
|
752
|
+
<Accordion title="GateBootError — missing auth posture">
|
|
753
|
+
Attach `.public()` or `.gate(...)` on every HTTP trigger. The error lists each gap.
|
|
754
|
+
</Accordion>
|
|
755
|
+
|
|
756
|
+
<Accordion title="VaultBootError at startup">
|
|
757
|
+
TTY title **OKE1510**. A contract has no value in any resolution layer — set the missing names
|
|
758
|
+
([Vault](/docs/elements/vault)).
|
|
759
|
+
</Accordion>
|
|
760
|
+
|
|
761
|
+
</Accordions>
|
|
762
|
+
|
|
763
|
+
## Learn more
|
|
764
|
+
|
|
765
|
+
- [Routing](/docs/elements/flow/routing) — when to omit the path, `list` / `create` / `get` filenames
|
|
766
|
+
- [Flow](/docs/elements/flow) — one species; `fx` is the door
|
|
767
|
+
- [Signal](/docs/elements/signal) — `once` / `broadcast` / `live`
|
|
768
|
+
- [Store](/docs/elements/store) — SQL, KV, files, search
|
|
769
|
+
- [Channel](/docs/elements/channel) — templates and `fx.send`
|
|
770
|
+
- [Client · Calling](/docs/client/calling) — `createClient(app, url)`, `{ data, error }`
|
|
57
771
|
|
|
58
|
-
##
|
|
772
|
+
## Next
|
|
59
773
|
|
|
60
|
-
|
|
774
|
+
<Cards>
|
|
775
|
+
<Card
|
|
776
|
+
title="Routing"
|
|
777
|
+
description="File-tree stamps, many-routes files, and reserved leaves."
|
|
778
|
+
href="/docs/elements/flow/routing"
|
|
779
|
+
/>
|
|
780
|
+
<Card title="Flow" description="on(trigger) → do through fx." href="/docs/elements/flow" />
|
|
781
|
+
<Card title="Signal" description="Move work off the request." href="/docs/elements/signal" />
|
|
782
|
+
<Card title="Store" description="Tables, fx.store, and resources." href="/docs/elements/store" />
|
|
783
|
+
<Card
|
|
784
|
+
title="The Architecture"
|
|
785
|
+
description="Why this shape stops month-eight drift."
|
|
786
|
+
href="/docs/understand/the-architecture"
|
|
787
|
+
/>
|
|
788
|
+
<Card
|
|
789
|
+
title="Elements"
|
|
790
|
+
description="Clock, Gate, Vault, Channel, AI — each in depth."
|
|
791
|
+
href="/docs/elements"
|
|
792
|
+
/>
|
|
793
|
+
</Cards>
|