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
@@ -1,60 +1,793 @@
1
1
  ---
2
2
  title: "Try It"
3
- description: "From an empty folder to a Flow running in the Console — one sitting, minimal detour."
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
- ## What you need
8
+ Scaffold a backend. See which file is `GET /health`. Add a users routes file. Save a row.
8
9
 
9
- Bun ≥ 1.4.2, and Docker running. `oke dev` starts everything else — Postgres, Redis, mail — for you.
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
- bun --version
13
- docker info
29
+ # [!code focus:2]
30
+ bun --version # 1.4.2 or newer
31
+ docker info # engine running
14
32
  ```
15
33
 
16
- ## Scaffold and run
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
- Default is `-t blank` (health + Store/Vault). `-t shorter` is a URL shortener (`POST /links`, `GET /:code`).
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
- Open the Console at the address printed in the terminal and claim it with the code shown there. The Flows listed weren't configured anywhere — they were derived from the code you just scaffolded.
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
- ## Call it
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
- The starter ships a health Flow. Call it from a typed client, the same way your frontend would:
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 type { App } from "./app";
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
- const api = createClient<App>("http://localhost:6530");
37
- const { data, error } = await api.main.health({});
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
- `data` and `error` are inferred straight from the Flow you just ran — not from a separate schema you maintain by hand.
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
- ## Prove it, don't just believe it
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
- test("boots — health flow", async () => {
46
- const t = await createTestApp(app);
47
- const { data } = await t.api.main.health({});
48
- expect(data).toEqual({ ok: true });
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
- bun test
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
- No mocked HTTP server, no real clock, no live mail provider. `createTestApp` swaps every driver behind `fx` for a deterministic one — because effects were never allowed to happen any other way.
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
- ## Where this goes next
772
+ ## Next
59
773
 
60
- If this went smoothly, the next page is the honest one: what's solid today, what's still moving, and who this is actually ready for right now.
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>