okengine 0.19.9 → 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.
Files changed (156) hide show
  1. package/AGENTS.md +1 -1
  2. package/package.json +2 -1
  3. package/site/content/docs/client/auth.mdx +1 -2
  4. package/site/content/docs/client/calling.mdx +125 -37
  5. package/site/content/docs/client/index.mdx +7 -7
  6. package/site/content/docs/client/react.mdx +5 -0
  7. package/site/content/docs/elements/channel/email.mdx +25 -36
  8. package/site/content/docs/elements/channel/index.mdx +88 -46
  9. package/site/content/docs/elements/channel/push.mdx +7 -9
  10. package/site/content/docs/elements/channel/sms.mdx +6 -4
  11. package/site/content/docs/elements/channel/whatsapp.mdx +11 -9
  12. package/site/content/docs/elements/clock/index.mdx +14 -25
  13. package/site/content/docs/elements/flow/http.mdx +42 -23
  14. package/site/content/docs/elements/flow/index.mdx +41 -30
  15. package/site/content/docs/elements/flow/routing.mdx +166 -122
  16. package/site/content/docs/elements/gate/rls.mdx +2 -2
  17. package/site/content/docs/elements/gate/tenancy.mdx +1 -1
  18. package/site/content/docs/elements/store/files.mdx +11 -5
  19. package/site/content/docs/elements/store/index.mdx +13 -6
  20. package/site/content/docs/elements/store/kv.mdx +8 -2
  21. package/site/content/docs/elements/store/search.mdx +5 -5
  22. package/site/content/docs/elements/store/sql.mdx +100 -39
  23. package/site/content/docs/elements/vault/index.mdx +14 -13
  24. package/site/content/docs/elements/vault/secrets.mdx +6 -3
  25. package/site/content/docs/index.mdx +1 -1
  26. package/site/content/docs/plugins/magic-link.mdx +4 -3
  27. package/site/content/docs/plugins/otp.mdx +4 -3
  28. package/site/content/docs/plugins/two-factor.mdx +4 -0
  29. package/site/content/docs/providers/index.mdx +1 -1
  30. package/site/content/docs/recipes/index.mdx +1 -1
  31. package/site/content/docs/recipes/rustfs.mdx +1 -1
  32. package/site/content/docs/reference/cli.mdx +7 -4
  33. package/site/content/docs/reference/configuration.mdx +8 -8
  34. package/site/content/docs/reference/errors.mdx +229 -55
  35. package/site/content/docs/reference/fx.mdx +22 -9
  36. package/site/content/docs/reference/i18n.mdx +4 -4
  37. package/site/content/docs/reference/plugins.mdx +4 -4
  38. package/site/content/docs/understand/the-architecture.mdx +2 -2
  39. package/site/content/docs/understand/try-it.mdx +759 -24
  40. package/src/cli/ai-setup/ai-setup.test.ts +40 -0
  41. package/src/cli/ai-setup/apply.ts +28 -46
  42. package/src/cli/build.test.ts +3 -3
  43. package/src/cli/build.ts +5 -5
  44. package/src/cli/db-auto-push.test.ts +11 -0
  45. package/src/cli/db-auto-push.ts +6 -2
  46. package/src/cli/db.test.ts +1 -1
  47. package/src/cli/db.ts +6 -6
  48. package/src/cli/dev-app-runner.ts +2 -1
  49. package/src/cli/dev-db-push.test.ts +6 -2
  50. package/src/cli/dev-schema-sync.ts +1 -1
  51. package/src/cli/dev.test.ts +10 -7
  52. package/src/cli/dev.ts +13 -11
  53. package/src/cli/ensure-drizzle-config.ts +4 -3
  54. package/src/cli/start.ts +2 -1
  55. package/src/client/create.ts +3 -3
  56. package/src/client/explain.test.ts +252 -0
  57. package/src/client/explain.ts +272 -0
  58. package/src/client/live.test.ts +44 -0
  59. package/src/client/notes-contract.test.ts +10 -0
  60. package/src/client/sse.ts +6 -2
  61. package/src/client/transport.test.ts +67 -0
  62. package/src/client/transport.ts +24 -40
  63. package/src/client/types.ts +17 -6
  64. package/src/client-react/browser.test.ts +23 -0
  65. package/src/client-react/live-resource.ts +6 -2
  66. package/src/client-react/use-live-query.ts +1 -1
  67. package/src/compiler/flow-path.test.ts +1 -0
  68. package/src/compiler/flow-path.ts +1 -1
  69. package/src/compiler/generate-adopt.test.ts +55 -1
  70. package/src/compiler/generate-adopt.ts +111 -21
  71. package/src/compiler/response.ts +17 -27
  72. package/src/config/index.ts +6 -4
  73. package/src/console/server/invoke-user-flow.test.ts +8 -2
  74. package/src/console/server/invoke-user-flow.ts +12 -18
  75. package/src/console/server/security.gate.test.ts +1 -1
  76. package/src/console/ui-next/dist/assets/{access-page-BoC83Ubl.js → access-page-tIbsiphz.js} +1 -1
  77. package/src/console/ui-next/dist/assets/{agent-disclosure-CKjAEOqA.js → agent-disclosure-CSKumwS2.js} +1 -1
  78. package/src/console/ui-next/dist/assets/{cache-glyph-Ceaq9pYh.js → cache-glyph-BCC-DxKT.js} +1 -1
  79. package/src/console/ui-next/dist/assets/{call-pii-button-C4lmY7ck.js → call-pii-button-jOmYISdc.js} +1 -1
  80. package/src/console/ui-next/dist/assets/{collapsible-82y257sL.js → collapsible-DC2xNaAb.js} +1 -1
  81. package/src/console/ui-next/dist/assets/{duration-tone-W63jaMZ8.js → duration-tone-CwoV56jn.js} +1 -1
  82. package/src/console/ui-next/dist/assets/{flows-page-KFTFj2rK.js → flows-page-gT1lsWQK.js} +1 -1
  83. package/src/console/ui-next/dist/assets/{highlighted-json-yE9zNTqC.js → highlighted-json-C2GZEJNI.js} +1 -1
  84. package/src/console/ui-next/dist/assets/{http-method-CwFeFroN.js → http-method-BdYjcIrD.js} +1 -1
  85. package/src/console/ui-next/dist/assets/{index-r7xXt_VV.js → index-Cul17AcV.js} +3 -3
  86. package/src/console/ui-next/dist/assets/{observability-page-BDiliMiC.js → observability-page-oY9vdYBk.js} +1 -1
  87. package/src/console/ui-next/dist/assets/{replica-lag-DYDzWUFT.js → replica-lag-C4QdAF7J.js} +1 -1
  88. package/src/console/ui-next/dist/assets/{request-meta-CzrOfgiz.js → request-meta-C43DHyld.js} +1 -1
  89. package/src/console/ui-next/dist/assets/{store-page-CZC2cwaw.js → store-page-CL0D9dOq.js} +1 -1
  90. package/src/console/ui-next/dist/assets/{trace-detail-sheet-DgeeejW7.js → trace-detail-sheet-Dcpo4Us_.js} +1 -1
  91. package/src/console/ui-next/dist/assets/{tree-expand-toggle-CzGIyOPY.js → tree-expand-toggle-CKJTuv43.js} +1 -1
  92. package/src/console/ui-next/dist/assets/{units-page-DBiDCLIB.js → units-page-1PlT19ft.js} +1 -1
  93. package/src/console/ui-next/dist/assets/{vault-page-CcHsthPe.js → vault-page-BwZ9YjTW.js} +1 -1
  94. package/src/console/ui-next/dist/index.html +1 -1
  95. package/src/docker/docker.test.ts +3 -3
  96. package/src/docker/images-config.test.ts +4 -4
  97. package/src/docker/stack-id.test.ts +1 -1
  98. package/src/drivers/clock-postgres.test.ts +10 -2
  99. package/src/drivers/clock-postgres.ts +18 -2
  100. package/src/drivers/vault-driver-removal.test.ts +2 -2
  101. package/src/elements/channel/declare.ts +66 -3
  102. package/src/elements/channel/runtime.ts +9 -11
  103. package/src/elements/channel.test.ts +42 -0
  104. package/src/elements/channel.ts +4 -2
  105. package/src/elements/clock/reconcile.ts +45 -24
  106. package/src/elements/clock.test.ts +33 -0
  107. package/src/elements/store/emit-drizzle.ts +285 -65
  108. package/src/elements/store/files-errors.test.ts +149 -0
  109. package/src/elements/store/files-errors.ts +189 -0
  110. package/src/elements/store/kv-errors.test.ts +98 -0
  111. package/src/elements/store/kv-errors.ts +139 -0
  112. package/src/elements/store/load-plugin-tables.ts +1 -1
  113. package/src/elements/store/prepare-row.test.ts +57 -4
  114. package/src/elements/store/resource.ts +11 -7
  115. package/src/elements/store/runtime.ts +18 -13
  116. package/src/elements/store/schema-decl.test.ts +178 -0
  117. package/src/elements/store/sql-errors.test.ts +197 -0
  118. package/src/elements/store/sql-errors.ts +294 -0
  119. package/src/elements/store/sql-session.test.ts +52 -0
  120. package/src/elements/store/sql-session.ts +50 -2
  121. package/src/elements/store/store-errors.ts +47 -0
  122. package/src/elements/store/table.ts +8 -6
  123. package/src/http.ts +9 -1
  124. package/src/i18n/catalogs/ar.ts +18 -0
  125. package/src/i18n/catalogs/en.ts +18 -0
  126. package/src/index.ts +9 -1
  127. package/src/kernel/adopt-barrel-fresh.test.ts +1 -1
  128. package/src/kernel/app.ts +40 -34
  129. package/src/kernel/auto-registry.test.ts +26 -1
  130. package/src/kernel/boot.ts +2 -2
  131. package/src/kernel/boundary-contract.ts +6 -1
  132. package/src/kernel/builtin-errors.test.ts +117 -0
  133. package/src/kernel/builtin-errors.ts +129 -0
  134. package/src/kernel/call.test.ts +182 -0
  135. package/src/kernel/errors-vault.ts +16 -0
  136. package/src/kernel/errors.registry.test.ts +7 -0
  137. package/src/kernel/errors.ts +97 -24
  138. package/src/kernel/fail-helpers.ts +34 -0
  139. package/src/kernel/flow-units.ts +3 -3
  140. package/src/kernel/fx.test.ts +8 -0
  141. package/src/kernel/fx.ts +24 -8
  142. package/src/kernel/index.ts +12 -1
  143. package/src/kernel/mutation-id.ts +8 -0
  144. package/src/kernel/plugin.ts +4 -3
  145. package/src/kernel/project-out.test.ts +176 -0
  146. package/src/kernel/project-out.ts +91 -0
  147. package/src/kernel/realtime-bind.ts +2 -3
  148. package/src/kernel/router/linear.ts +12 -6
  149. package/src/kernel/router.test.ts +13 -0
  150. package/src/plugins/magic-link.ts +25 -24
  151. package/src/plugins/otp.ts +35 -24
  152. package/src/plugins/two-factor.ts +15 -0
  153. package/src/runs/duckdb.test.ts +2 -2
  154. package/src/runtime/dev-request-log.ts +29 -11
  155. package/src/term.test.ts +76 -0
  156. package/src/term.ts +166 -3
@@ -6,16 +6,16 @@ source: "docs/spec/unified-theory.md"
6
6
  ---
7
7
 
8
8
  Channel is how your backend **reaches a person** — the order-confirmation email, the SMS
9
- sign-in code, a WhatsApp notice, or a device push. You declare a template on a medium, fill a
10
- `{{field}}` body catalog, and send from a Flow with `fx.send`.
9
+ sign-in code, a WhatsApp notice, or a device push. You declare a template on a medium with
10
+ per-locale `{{field}}` bodies, then send from a Flow with `fx.send`.
11
11
 
12
12
  For developers wiring Mailpit locally and Resend / Taqnyat / FCM in production — templates and
13
13
  drivers first, never vendor SDKs inside `do`.
14
14
 
15
15
  <Callout title="The one rule">
16
- Declare a template (`channel.email(…).template(…)`), then `fx.send(template, { to, data })`.
17
- Bodies live in the catalog (`subject` / `text` / `html`), not on the declare call. Consent and
18
- prior bounces suppress before any driver runs.
16
+ Declare a template (`channel.email(…).template({ catalog })`), then `fx.send(template, { to, data })`.
17
+ Bodies live on the declare (`subject` / `text` / `html` per locale). Consent and prior bounces
18
+ suppress before any driver runs.
19
19
  </Callout>
20
20
 
21
21
  <ChannelPhysics />
@@ -27,18 +27,28 @@ drivers first, never vendor SDKs inside `do`.
27
27
  <Step>
28
28
  ### Declare an email template
29
29
 
30
- ```typescript title="src/core/channel.ts"
30
+ ```typescript title="src/core/email.ts"
31
31
  import { channel } from "okengine";
32
32
  import { z } from "zod";
33
33
 
34
- const mail = channel.email({ from: "Notes <notes@localhost>" });
34
+ const mail = channel.email({ from: "Shorter <shorter@localhost>" });
35
35
 
36
- export const noteCreatedMail = mail.template("note-created", {
36
+ export const linkCreatedMail = mail.template("link-created", {
37
+ description: "Short URL delivered to the demo inbox",
37
38
  locales: ["en"],
38
39
  schema: z.object({
39
40
  id: z.string(),
40
- title: z.string(),
41
+ code: z.string(),
42
+ url: z.string(),
43
+ shortUrl: z.string(),
41
44
  }),
45
+ catalog: {
46
+ en: {
47
+ subject: "Short link {{code}}",
48
+ text: "{{shortUrl}} → {{url}}",
49
+ html: '<p><a href="{{shortUrl}}">{{code}}</a> → {{url}}</p>',
50
+ },
51
+ },
42
52
  });
43
53
  ```
44
54
 
@@ -50,26 +60,42 @@ Import this module before `oke()` so auto-registry adopts the template (or pass
50
60
  <Step>
51
61
  ### Send from a Flow
52
62
 
53
- ```typescript title="src/flows/notes/on-created.ts"
54
- import { on, flow } from "okengine";
55
- import { noteCreatedMail } from "@/core/channel";
56
- import { noteCreated } from "./signals";
63
+ ```typescript title="src/flows/links/signals.ts"
64
+ import { on, flow, signal } from "okengine";
65
+ import { z } from "zod";
66
+ import { linkCreatedMail } from "@/core";
67
+
68
+ export const linkCreated = signal.once("link-created", {
69
+ retries: 3,
70
+ deadLetter: true,
71
+ schema: z.object({
72
+ id: z.string(),
73
+ code: z.string(),
74
+ url: z.string(),
75
+ shortUrl: z.string(),
76
+ }),
77
+ });
57
78
 
58
79
  export const onCreated = on(
59
- noteCreated,
60
- flow("notes.onCreated", {
80
+ linkCreated,
81
+ flow("links.onCreated", {
61
82
  do: async (payload, fx) => {
62
- await fx.send(noteCreatedMail, {
83
+ await fx.send(linkCreatedMail, {
63
84
  to: "you@localhost",
64
- data: { id: payload.id, title: payload.title },
85
+ data: {
86
+ id: payload.id,
87
+ code: payload.code,
88
+ url: payload.url,
89
+ shortUrl: payload.shortUrl,
90
+ },
65
91
  });
66
92
  },
67
93
  }),
68
94
  );
69
95
  ```
70
96
 
71
- The compiler stamps `sends: ["note-created"]` on the Flow’s effects (template name — not
72
- `email:note-created`).
97
+ The compiler stamps `sends: ["link-created"]` on the Flow’s effects (template name — not
98
+ `email:link-created`). Declaration and subscriber share `signals.ts`.
73
99
 
74
100
  </Step>
75
101
 
@@ -77,8 +103,8 @@ The compiler stamps `sends: ["note-created"]` on the Flow’s effects (template
77
103
  ### See it locally
78
104
 
79
105
  With `drivers.channel.email.dev: "smtp"` and Mailpit pinned, open the Mailpit UI
80
- (`MAILPIT_UI_URL`). Missing catalog bodies fall back to `subject: note-created` and
81
- `text: JSON.stringify(data)`.
106
+ (`MAILPIT_UI_URL`). Subject and HTML come from the template `catalog`. Omit `catalog` and the
107
+ runtime falls back to `subject: link-created` and `text: JSON.stringify(data)`.
82
108
 
83
109
  </Step>
84
110
 
@@ -95,9 +121,14 @@ From a bare send to catalog bodies, locale, and same-medium failover:
95
121
  Template handle + recipient — catalog optional for local smoke tests:
96
122
 
97
123
  ```typescript
98
- await fx.send(noteCreatedMail, {
124
+ await fx.send(linkCreatedMail, {
99
125
  to: "alice@example.com",
100
- data: { id: "n1", title: "Hello" },
126
+ data: {
127
+ id: "lnk_1",
128
+ code: "ok",
129
+ url: "https://example.com",
130
+ shortUrl: "http://localhost:6530/ok",
131
+ },
101
132
  });
102
133
  ```
103
134
 
@@ -105,23 +136,22 @@ await fx.send(noteCreatedMail, {
105
136
 
106
137
  <Tab value="Catalog">
107
138
 
108
- Bodies are `{{field}}` strings per locale — not ICU, not React. Pass them on boot or via a
109
- plugin `.channelCatalog(…)`:
110
-
111
- ```typescript title="src/app.ts"
112
- import { oke } from "okengine";
113
-
114
- export const app = oke({
115
- name: "notes",
116
- channel: {
117
- catalog: {
118
- "note-created": {
119
- en: {
120
- subject: "Note created",
121
- text: "Your note {{title}} ({{id}}) is ready.",
122
- html: "<p>Your note <strong>{{title}}</strong> is ready.</p>",
123
- },
124
- },
139
+ Bodies are `{{field}}` strings per locale — not ICU, not React. Put them on the template.
140
+ `oke({ channel.catalog })` and plugin `.channelCatalog(…)` are overlays (later locale wins):
141
+
142
+ ```typescript title="src/core/email.ts"
143
+ export const linkCreatedMail = mail.template("link-created", {
144
+ locales: ["en"],
145
+ schema: z.object({
146
+ code: z.string(),
147
+ url: z.string(),
148
+ shortUrl: z.string(),
149
+ }),
150
+ catalog: {
151
+ en: {
152
+ subject: "Short link {{code}}",
153
+ text: "{{shortUrl}} → {{url}}",
154
+ html: '<p><a href="{{shortUrl}}">{{code}}</a> → {{url}}</p>',
125
155
  },
126
156
  },
127
157
  });
@@ -136,9 +166,14 @@ Precedence: explicit `locale` → `profileLocale` → `Accept-Language` →
136
166
  `fx.locale`.
137
167
 
138
168
  ```typescript
139
- await fx.send(noteCreatedMail, {
169
+ await fx.send(linkCreatedMail, {
140
170
  to: "alice@example.com",
141
- data: { id: "n1", title: "مرحبا" },
171
+ data: {
172
+ id: "lnk_1",
173
+ code: "ok",
174
+ url: "https://example.com",
175
+ shortUrl: "http://localhost:6530/ok",
176
+ },
142
177
  locale: "ar",
143
178
  });
144
179
  ```
@@ -154,9 +189,14 @@ Order same-medium drivers for failover. Provider / 5xx errors advance; permanent
154
189
  (400, invalid address) do **not**:
155
190
 
156
191
  ```typescript
157
- await fx.send(noteCreatedMail, {
192
+ await fx.send(linkCreatedMail, {
158
193
  to: "alice@example.com",
159
- data: { id: "n1", title: "Hello" },
194
+ data: {
195
+ id: "lnk_1",
196
+ code: "ok",
197
+ url: "https://example.com",
198
+ shortUrl: "http://localhost:6530/ok",
199
+ },
160
200
  via: ["smtp", "resend"],
161
201
  });
162
202
  ```
@@ -193,12 +233,14 @@ succeeded — every attempt is kept on the receipt.
193
233
  | `description` | `string` | name | Console / docs label |
194
234
  | `locales` | `string[]` | — | Declared locale tags for the template |
195
235
  | `schema` | Schema | — | Payload shape (Zod / Standard Schema) |
236
+ | `catalog` | object | — | Per-locale `subject` / `text` / `html` |
196
237
  | `from` | `string` | binder’s | Override sender on agnostic `channel.template` only |
197
238
  | `medium` | medium | `"email"` | Only on `channel.template()` |
198
239
 
199
240
  Empty name throws `TypeError: channel.template: name is required`.
200
241
 
201
- There is **no** `subject` / `body` / `html` on declare — those belong in the catalog.
242
+ `oke({ channel.catalog })` overlays bodies after template catalogs (tests / plugin copy
243
+ overrides). Manifest stays free of copy.
202
244
 
203
245
  ## `fx` surface
204
246
 
@@ -295,7 +337,7 @@ Env knobs: [Environment variables](/docs/reference/environment-variables). Local
295
337
 
296
338
  <Accordion title="OKE1004 — UNDECLARED_SEND">
297
339
  Cause: `Flow "{flow}" sends "{resource}" without declaring it.` Touch the template handle
298
- inside `do` (inference) or list `effects: { sends: ["note-created"] }`.
340
+ inside `do` (inference) or list `effects: { sends: ["link-created"] }`.
299
341
  </Accordion>
300
342
 
301
343
  <Accordion title="channel: no email transport in driver chain">
@@ -37,6 +37,9 @@ export const orderPush = push.template("order.status", {
37
37
  title: z.string(),
38
38
  body: z.string(),
39
39
  }),
40
+ catalog: {
41
+ en: { subject: "{{title}}", text: "{{body}}" },
42
+ },
40
43
  });
41
44
  ```
42
45
 
@@ -59,11 +62,6 @@ export const app = oke({
59
62
  privateKey: process.env.FCM_PRIVATE_KEY,
60
63
  }),
61
64
  ],
62
- catalog: {
63
- "order.status": {
64
- en: { subject: "{{title}}", text: "{{body}}" },
65
- },
66
- },
67
65
  },
68
66
  });
69
67
  ```
@@ -128,13 +126,13 @@ Web Push where the subscription is supplied on the runtime path.
128
126
  <Tab value="Catalog">
129
127
 
130
128
  ```typescript
131
- "order.status": {
129
+ catalog: {
132
130
  en: { subject: "{{title}}", text: "{{body}}" },
133
131
  }
134
132
  ```
135
133
 
136
- `subject` becomes the notification title on FCM; `text` is the body. Extra `data` fields pass
137
- through as FCM data payload when present.
134
+ Put that on `push.template({ catalog })`. `subject` becomes the notification title on FCM; `text`
135
+ is the body. Extra `data` fields pass through as FCM data payload when present.
138
136
 
139
137
  </Tab>
140
138
 
@@ -163,7 +161,7 @@ Config key `drivers.channel.push` exists for Manifest / tooling ids (`console`
163
161
 
164
162
  ### `.template(name, options?)`
165
163
 
166
- `description` · `locales` · `schema` — same as other mediums.
164
+ `description` · `locales` · `schema` · `catalog` — same as other mediums.
167
165
 
168
166
  ## Troubleshooting
169
167
 
@@ -52,6 +52,7 @@ const sms = channel.sms({ sender: "ACME" });
52
52
  export const orderShippedSms = sms.template("order.shipped", {
53
53
  locales: ["en"],
54
54
  schema: z.object({ tracking: z.string() }),
55
+ catalog: { en: { text: "Shipped — track {{tracking}}" } },
55
56
  });
56
57
  ```
57
58
 
@@ -62,7 +63,7 @@ await fx.send(orderShippedSms, {
62
63
  });
63
64
  ```
64
65
 
65
- Add a catalog body (`text: "Shipped — track {{tracking}}"`) or accept the JSON fallback.
66
+ Missing `catalog` falls back to `subject: templateName` and `text: JSON.stringify(data)`.
66
67
 
67
68
  </Step>
68
69
 
@@ -102,6 +103,7 @@ Transactional SMS through the same `fx.send` path as email:
102
103
  const sms = channel.sms({ from: "ACME" });
103
104
  export const alertSms = sms.template("ops.alert", {
104
105
  schema: z.object({ message: z.string() }),
106
+ catalog: { en: { text: "{{message}}" } },
105
107
  });
106
108
 
107
109
  await fx.send(alertSms, { to: phone, data: { message: "Disk 90%" } });
@@ -202,7 +204,7 @@ Same medium options as email: `from` / `sender`.
202
204
 
203
205
  ### `.template(name, options?)`
204
206
 
205
- `description` · `locales` · `schema` — bodies in the catalog (`text` is typical for SMS).
207
+ `description` · `locales` · `schema` · `catalog` — `text` is typical for SMS.
206
208
 
207
209
  ## Troubleshooting
208
210
 
@@ -229,8 +231,8 @@ Same medium options as email: `from` / `sender`.
229
231
  </Accordion>
230
232
 
231
233
  <Accordion title="channel: otp delivery failed on all channels">
232
- Every medium in the failover list failed. Check driver credentials, suppression, and template
233
- catalog entries.
234
+ Every medium in the failover list failed. Check driver credentials, suppression, and `catalog` on
235
+ each `.template()`.
234
236
  </Accordion>
235
237
 
236
238
  </Accordions>
@@ -53,6 +53,10 @@ const wa = channel.whatsapp();
53
53
  export const bookingReminder = wa.template("booking.reminder", {
54
54
  locales: ["en", "ar"],
55
55
  schema: z.object({ when: z.string(), place: z.string() }),
56
+ catalog: {
57
+ en: { text: "Reminder: {{when}} at {{place}}" },
58
+ ar: { text: "تذكير: {{when}} — {{place}}" },
59
+ },
56
60
  });
57
61
  ```
58
62
 
@@ -85,14 +89,12 @@ and SMS.
85
89
  Catalog typically supplies `text` (and optional `subject` where the transport uses it):
86
90
 
87
91
  ```typescript
88
- oke({
89
- channel: {
90
- catalog: {
91
- "booking.reminder": {
92
- en: { text: "Reminder: {{when}} at {{place}}" },
93
- ar: { text: "تذكير: {{when}} — {{place}}" },
94
- },
95
- },
92
+ export const bookingReminder = wa.template("booking.reminder", {
93
+ locales: ["en", "ar"],
94
+ schema: z.object({ when: z.string(), place: z.string() }),
95
+ catalog: {
96
+ en: { text: "Reminder: {{when}} at {{place}}" },
97
+ ar: { text: "تذكير: {{when}} — {{place}}" },
96
98
  },
97
99
  });
98
100
  ```
@@ -165,7 +167,7 @@ oke boot: unknown whatsapp channel driver "…"
165
167
 
166
168
  ### `.template(name, options?)`
167
169
 
168
- `description` · `locales` · `schema` — same as other mediums.
170
+ `description` · `locales` · `schema` · `catalog` — same as other mediums.
169
171
 
170
172
  ## Troubleshooting
171
173
 
@@ -21,32 +21,19 @@ For developers scheduling work on okengine — one handle shape; drivers swap by
21
21
  <Steps>
22
22
 
23
23
  <Step>
24
- ### Declare a named clock
24
+ ### Declare and bind in the unit
25
25
 
26
- ```typescript title="src/clocks/digest.ts"
27
- import { clock } from "okengine";
28
-
29
- export const digestClock = clock.every("notes.digest", "1d");
30
- ```
31
-
32
- </Step>
33
-
34
- <Step>
35
- ### Bind a Flow
26
+ ```typescript title="src/flows/links/expire.ts"
27
+ import { on, flow, clock } from "okengine/http";
36
28
 
37
- ```typescript title="src/flows/notes/digest.ts"
38
- import { on, flow } from "okengine";
39
- import { digestClock } from "@/clocks/digest";
40
- import { db, notes } from "@/schema";
41
- import { isNull } from "drizzle-orm";
29
+ const expireClock = clock.every("links.expire", "1h");
42
30
 
43
- export const digest = on(
44
- digestClock,
45
- flow("notes.digest", {
46
- plane: "operator",
47
- do: async (_, fx) => {
48
- const rows = await fx.store(db).select().from(notes).where(isNull(notes.archivedAt));
49
- return { active: rows.length, at: new Date(fx.clock.now()).toISOString() };
31
+ export const expire = on(
32
+ expireClock,
33
+ flow({
34
+ do: async (_input, fx) => {
35
+ const now = fx.clock.now();
36
+ return { archived: 0, at: new Date(now).toISOString() };
50
37
  },
51
38
  }),
52
39
  );
@@ -57,9 +44,11 @@ export const digest = on(
57
44
  <Step>
58
45
  ### See it tick
59
46
 
60
- With `oke dev`, the scheduler reconciles `notes.digest` into the Store and leader-elects
47
+ With `oke dev`, the scheduler reconciles `links.expire` into the Store and leader-elects
61
48
  before each fire. `do` receives no payload — read time with `fx.clock.now()` (epoch-ms).
62
- Map to ISO on the wire with `new Date(fx.clock.now()).toISOString()`.
49
+
50
+ Pass that number into SQL `timestamp` columns as-is — insert, update, and WHERE binds
51
+ coerce to `Date`. For a hand-built ISO reply, use `new Date(fx.clock.now()).toISOString()`.
63
52
 
64
53
  Under `drivers.clock.test = "frozen"`, advance time in tests instead of waiting a day.
65
54
 
@@ -110,7 +110,7 @@ export const create = on(
110
110
 
111
111
  <Tab value="Failures">
112
112
 
113
- Declare typed domain errors and return clean failure responses using `fx.fail`:
113
+ Declare typed domain errors on `errors:`. Built-in codes use helpers (`fx.fail.notFound`) with no bag:
114
114
 
115
115
  ```typescript title="src/flows/orders/[id]/get.ts"
116
116
  import { on, flow, http } from "okengine";
@@ -120,12 +120,11 @@ export const get = on(
120
120
  http.get({
121
121
  in: z.object({ id: z.string() }),
122
122
  out: z.object({ id: z.string(), sku: z.string(), qty: z.number() }),
123
- errors: { NotFound: z.object({ id: z.string() }) },
124
123
  }),
125
124
  flow({
126
125
  do: async ({ id }, fx) => {
127
126
  const [order] = await fx.store(db).select().from(orders).where(eq(orders.id, id));
128
- if (!order) return fx.fail("NotFound", { id });
127
+ if (!order) return fx.fail.notFound({ id });
129
128
  return order;
130
129
  },
131
130
  }),
@@ -287,24 +286,26 @@ export const get = on(
287
286
  http.get({
288
287
  in: z.object({ id: z.string() }),
289
288
  out: z.object({ id: z.string(), title: z.string() }),
290
- errors: { NotFound: z.object({ id: z.string() }) },
291
289
  }),
292
290
  flow({
293
291
  do: async ({ id }, fx) => {
294
292
  const [note] = await fx.store(db).select().from(notes).where(eq(notes.id, id));
295
- if (!note) return fx.fail("NotFound", { id });
293
+ if (!note) return fx.fail.notFound({ id });
296
294
  return note;
297
295
  },
298
296
  }),
299
297
  );
300
298
  ```
301
299
 
300
+ Declared `out` projects the row — return it as-is (Date or `fx.clock.now()` epoch-ms → ISO-8601; extra columns strip).
301
+
302
302
  </Tab>
303
303
 
304
304
  <Tab value="POST">
305
305
 
306
- Create a resource or run a command. `fx.json.create(value)` returns `201 Created` with
307
- `{ data: value, error: null }`:
306
+ Create a resource or run a command. `fx.json.create(row)` returns `201 Created` with
307
+ `{ data: value, error: null }`. Declared `out` projects the store row (Date / epoch-ms → ISO).
308
+ HTTP `in` timestamps are ISO (`z.iso.datetime()`); store writes coerce them to `Date`.
308
309
 
309
310
  ```typescript title="src/flows/notes/create.ts"
310
311
  import { on, flow, http } from "okengine";
@@ -319,8 +320,8 @@ export const create = on(
319
320
  flow({
320
321
  do: async ({ title }, fx) => {
321
322
  const id = fx.id();
322
- await fx.store(db).insert(notes).values({ id, title });
323
- return fx.json.create({ id, title });
323
+ const [row] = await fx.store(db).insert(notes).values({ id, title }).returning();
324
+ return fx.json.create(row);
324
325
  },
325
326
  }),
326
327
  );
@@ -375,7 +376,6 @@ export const update = on(
375
376
  title: z.string().min(1).optional(),
376
377
  }),
377
378
  out: z.object({ id: z.string(), title: z.string() }),
378
- errors: { NotFound: z.object({ id: z.string() }) },
379
379
  }),
380
380
  flow({
381
381
  do: async ({ id, title }, fx) => {
@@ -383,7 +383,7 @@ export const update = on(
383
383
  await fx.store(db).update(notes).set({ title }).where(eq(notes.id, id));
384
384
  }
385
385
  const [note] = await fx.store(db).select().from(notes).where(eq(notes.id, id));
386
- if (!note) return fx.fail("NotFound", { id });
386
+ if (!note) return fx.fail.notFound({ id });
387
387
  return note;
388
388
  },
389
389
  }),
@@ -462,7 +462,6 @@ import { db, notes } from "@/schema";
462
462
  export const head = on(
463
463
  http.head("/notes/:id", {
464
464
  in: z.object({ id: z.string() }),
465
- errors: { NotFound: z.object({ id: z.string() }) },
466
465
  }),
467
466
  flow({
468
467
  do: async ({ id }, fx) => {
@@ -471,7 +470,7 @@ export const head = on(
471
470
  .select({ id: notes.id })
472
471
  .from(notes)
473
472
  .where(eq(notes.id, id));
474
- if (!note) return fx.fail("NotFound", { id });
473
+ if (!note) return fx.fail.notFound({ id });
475
474
  return;
476
475
  },
477
476
  }),
@@ -574,7 +573,7 @@ pathless `http.resource()` — pass an explicit base path.
574
573
  | `out` | Schema | _(required)_ | Item shape (get / list / update return) |
575
574
  | `update` | Schema | `in` | Patch fields. Wire body is `{ id, ...patch }` |
576
575
  | `idSchema` | Schema | `update`/`in` + `{ id: string }` | Replaces the update Flow `in` when set (include the id key) |
577
- | `errors` | error map | `{ NotFound }` | Typed failures on get / update / remove |
576
+ | `errors` | error map | — | Extra domain codes. Built-in `NotFound` is always on |
578
577
  | `id` | column | table PK | Column bound to `:id` |
579
578
  | `list` | object | see List Options | List query grammar (`GET /notes`) |
580
579
  | `breaking` | `boolean` | `false` | Marks the five Flows `breaking: true` (handwritten → resource migration) |
@@ -934,8 +933,9 @@ Every HTTP flow returns the same envelope shape. You choose status and optional
934
933
  custom wrapper.
935
934
 
936
935
  <Callout title="Envelope is fixed">
937
- Success and failure always use `{ data, error }` (optional top-level `meta`). There is no API to
938
- replace that shape. Use `fx.json.*` for status codes and `meta`; use `fx.fail` for typed errors.
936
+ Success and failure always use `{ data, error }` (optional top-level `meta`). Use `fx.json.*` for
937
+ status and `meta`; use `fx.fail` for typed errors. A raw `Response` from `do` is the exception —
938
+ `302` + `Location` for redirects.
939
939
  </Callout>
940
940
 
941
941
  **Success** — returning a value from `do` produces `200 OK`:
@@ -946,18 +946,26 @@ custom wrapper.
946
946
 
947
947
  Returning `undefined` produces a `204 No Content` response with an empty body.
948
948
 
949
- **Custom status** — `fx.json.create` for `201 Created`, or `fx.json.ok` with optional `meta`:
949
+ **Custom status** — `fx.json.create` for `201 Created`, or `fx.json.ok` with optional `meta`.
950
+ When `out` is set, pass the store row — the kernel projects it (Date → ISO-8601; extra keys strip):
950
951
 
951
952
  ```typescript
952
- return fx.json.create({ id: "ord_1" });
953
+ return fx.json.create(row);
953
954
  // or
954
955
  return fx.json.ok({ id: "ord_1" }, { meta: { traceId: fx.runId } });
955
956
  ```
956
957
 
957
- **Typed failures** — `fx.fail(code, data)` formats the error envelope and maps status:
958
+ **Redirects** — return a raw `Response` so the kernel does not wrap `{ data, error }`:
958
959
 
959
960
  ```typescript
960
- return fx.fail("NotFound", { id: "123" });
961
+ return new Response(null, { status: 302, headers: { Location: url } });
962
+ ```
963
+
964
+ **Typed failures** — built-in helpers need no `errors:` bag. Domain codes still go on `errors:`:
965
+
966
+ ```typescript
967
+ return fx.fail.notFound({ id: "123" });
968
+ return fx.fail("OutOfStock", { available: 0 });
961
969
  ```
962
970
 
963
971
  ```json
@@ -965,7 +973,7 @@ return fx.fail("NotFound", { id: "123" });
965
973
  "data": null,
966
974
  "error": {
967
975
  "code": "NotFound",
968
- "message": "Resource not found",
976
+ "message": "The requested resource was not found.",
969
977
  "data": { "id": "123" }
970
978
  }
971
979
  }
@@ -976,8 +984,14 @@ Standard status code mappings:
976
984
  - `ValidationError` → `422 Unprocessable Entity`
977
985
  - `Unauthorized` → `401 Unauthorized`
978
986
  - `Forbidden` → `403 Forbidden`
979
- - `RateLimited` → `429 Too Many Requests`
980
- - Custom error codes → `400 Bad Request`
987
+ - `NotFound` → `404 Not Found` (JSON envelope — router miss is plain-text `404`)
988
+ - `Conflict` / `ForeignKey` → `409 Conflict`
989
+ - `UnsupportedMediaType` → `415 Unsupported Media Type`
990
+ - `RateLimited` / `AuthRateLimited` → `429 Too Many Requests`
991
+ - `DatabaseError` → `422` (`not_null` / `check` / `invalid` / `too_long` / `out_of_range`), `503` (`retryable`), else `500`
992
+ - `ServiceUnavailable` → `503 Service Unavailable`
993
+ - `InternalError` → `500 Internal Server Error` (catalog message only)
994
+ - Domain codes (`OutOfStock`, `FlightFull`, `Duplicate`) → `400 Bad Request`
981
995
 
982
996
  ## Troubleshooting
983
997
 
@@ -1004,6 +1018,11 @@ Standard status code mappings:
1004
1018
  `error.data.issues` array for the specific field validation failure.
1005
1019
  </Accordion>
1006
1020
 
1021
+ <Accordion title="I mapped Date columns to ISO by hand">
1022
+ Declared `out` already projects store rows. `fx.json.create(row)`, `return row`, and
1023
+ `fx.json.withQuery(rows, input)` are enough — extra columns strip. A miss is not a client error.
1024
+ </Accordion>
1025
+
1007
1026
  <Accordion title="Browser blocked by CORS / missing Access-Control-*">
1008
1027
  Cross-origin access is closed until you plug the [`cors`](/docs/plugins/cors) plugin with an
1009
1028
  explicit `origin`. Same-origin calls need no CORS headers. Preflight `OPTIONS` is answered by the