okengine 0.19.8 → 0.20.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 (108) hide show
  1. package/AGENTS.md +1 -1
  2. package/package.json +1 -1
  3. package/site/content/docs/client/auth.mdx +1 -2
  4. package/site/content/docs/client/calling.mdx +8 -8
  5. package/site/content/docs/client/index.mdx +4 -4
  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 +24 -8
  14. package/site/content/docs/elements/flow/index.mdx +15 -12
  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/store/index.mdx +11 -3
  18. package/site/content/docs/elements/store/search.mdx +5 -5
  19. package/site/content/docs/elements/store/sql.mdx +91 -33
  20. package/site/content/docs/elements/vault/index.mdx +3 -5
  21. package/site/content/docs/plugins/magic-link.mdx +4 -3
  22. package/site/content/docs/plugins/otp.mdx +4 -3
  23. package/site/content/docs/plugins/two-factor.mdx +4 -0
  24. package/site/content/docs/providers/index.mdx +1 -1
  25. package/site/content/docs/recipes/index.mdx +1 -1
  26. package/site/content/docs/reference/cli.mdx +13 -4
  27. package/site/content/docs/reference/configuration.mdx +7 -7
  28. package/site/content/docs/reference/errors.mdx +30 -30
  29. package/site/content/docs/reference/fx.mdx +16 -8
  30. package/site/content/docs/reference/i18n.mdx +4 -4
  31. package/site/content/docs/reference/plugins.mdx +4 -4
  32. package/site/content/docs/understand/try-it.mdx +2 -0
  33. package/src/cli/ai-setup/ai-setup.test.ts +40 -0
  34. package/src/cli/ai-setup/apply.ts +28 -46
  35. package/src/cli/ask-vault-gaps.test.ts +60 -4
  36. package/src/cli/ask-vault-gaps.ts +59 -3
  37. package/src/cli/build.test.ts +3 -3
  38. package/src/cli/build.ts +5 -5
  39. package/src/cli/db-auto-push.test.ts +11 -0
  40. package/src/cli/db-auto-push.ts +6 -2
  41. package/src/cli/db.test.ts +1 -1
  42. package/src/cli/db.ts +6 -6
  43. package/src/cli/dev-app-runner.ts +12 -4
  44. package/src/cli/dev-db-push.test.ts +6 -2
  45. package/src/cli/dev-schema-sync.ts +1 -1
  46. package/src/cli/dev.test.ts +10 -7
  47. package/src/cli/dev.ts +18 -12
  48. package/src/cli/ensure-drizzle-config.ts +4 -3
  49. package/src/client-react/browser.test.ts +23 -0
  50. package/src/client-react/use-live-query.ts +1 -1
  51. package/src/compiler/flow-path.test.ts +1 -0
  52. package/src/compiler/flow-path.ts +1 -1
  53. package/src/compiler/generate-adopt.test.ts +55 -1
  54. package/src/compiler/generate-adopt.ts +111 -21
  55. package/src/config/index.ts +6 -4
  56. package/src/console/ui-next/dist/assets/{access-page-C5yG4aS2.js → access-page-DFLu0wTA.js} +1 -1
  57. package/src/console/ui-next/dist/assets/{agent-disclosure-D2ToVK86.js → agent-disclosure-DGscxaF5.js} +1 -1
  58. package/src/console/ui-next/dist/assets/{cache-glyph-7HUR0kCz.js → cache-glyph-BGmRZk7d.js} +1 -1
  59. package/src/console/ui-next/dist/assets/{call-pii-button-D0ky3aXt.js → call-pii-button--feUYxvG.js} +1 -1
  60. package/src/console/ui-next/dist/assets/{collapsible-BLUHH2dB.js → collapsible-JWvpaiGY.js} +1 -1
  61. package/src/console/ui-next/dist/assets/{duration-tone-BgHEFMtm.js → duration-tone-D9yCJG4n.js} +1 -1
  62. package/src/console/ui-next/dist/assets/{flows-page-DDybqshQ.js → flows-page-Bs6MD9GB.js} +1 -1
  63. package/src/console/ui-next/dist/assets/{highlighted-json-BFiJKYV4.js → highlighted-json-xH8MrEnv.js} +1 -1
  64. package/src/console/ui-next/dist/assets/{http-method-BVAcB0bI.js → http-method-C4vB6ZIw.js} +1 -1
  65. package/src/console/ui-next/dist/assets/{index-BID6LSYI.js → index-yTCY4AcS.js} +3 -3
  66. package/src/console/ui-next/dist/assets/{observability-page-CANo_mMK.js → observability-page-BxJ3R6dU.js} +1 -1
  67. package/src/console/ui-next/dist/assets/{replica-lag-CqQGCshp.js → replica-lag-QRKB_IE8.js} +1 -1
  68. package/src/console/ui-next/dist/assets/{request-meta-DyUDkEt4.js → request-meta-DqZ-fMu5.js} +1 -1
  69. package/src/console/ui-next/dist/assets/{store-page-CA0rJ_Ow.js → store-page-Dixb6L7a.js} +1 -1
  70. package/src/console/ui-next/dist/assets/{trace-detail-sheet-DjH_fMzI.js → trace-detail-sheet-CazhjtiU.js} +1 -1
  71. package/src/console/ui-next/dist/assets/{tree-expand-toggle-CWSomIeQ.js → tree-expand-toggle-DlnqYKfr.js} +1 -1
  72. package/src/console/ui-next/dist/assets/{units-page-Cz9TIViw.js → units-page-BXTLjU2-.js} +1 -1
  73. package/src/console/ui-next/dist/assets/{vault-page-Bb1o1O9p.js → vault-page-39KR__bc.js} +1 -1
  74. package/src/console/ui-next/dist/index.html +1 -1
  75. package/src/drivers/clock-postgres.test.ts +10 -2
  76. package/src/drivers/clock-postgres.ts +18 -2
  77. package/src/drivers/vault-driver-removal.test.ts +2 -2
  78. package/src/elements/channel/declare.ts +66 -3
  79. package/src/elements/channel/runtime.ts +9 -11
  80. package/src/elements/channel.test.ts +42 -0
  81. package/src/elements/channel.ts +4 -2
  82. package/src/elements/clock/reconcile.ts +45 -24
  83. package/src/elements/clock.test.ts +33 -0
  84. package/src/elements/store/emit-drizzle.ts +285 -65
  85. package/src/elements/store/load-plugin-tables.ts +1 -1
  86. package/src/elements/store/prepare-row.test.ts +57 -4
  87. package/src/elements/store/schema-decl.test.ts +178 -0
  88. package/src/elements/store/sql-session.ts +44 -2
  89. package/src/elements/store/table.ts +8 -6
  90. package/src/kernel/adopt-barrel-fresh.test.ts +1 -1
  91. package/src/kernel/app.ts +26 -32
  92. package/src/kernel/auto-registry.test.ts +26 -1
  93. package/src/kernel/boot.ts +2 -2
  94. package/src/kernel/boundary-contract.ts +6 -1
  95. package/src/kernel/errors.ts +3 -3
  96. package/src/kernel/flow-units.ts +3 -3
  97. package/src/kernel/fx.ts +12 -2
  98. package/src/kernel/mutation-id.ts +8 -0
  99. package/src/kernel/plugin.ts +4 -3
  100. package/src/kernel/project-out.test.ts +176 -0
  101. package/src/kernel/project-out.ts +91 -0
  102. package/src/kernel/realtime-bind.ts +2 -3
  103. package/src/kernel/router/linear.ts +12 -6
  104. package/src/kernel/router.test.ts +13 -0
  105. package/src/plugins/magic-link.ts +25 -24
  106. package/src/plugins/otp.ts +35 -24
  107. package/src/plugins/two-factor.ts +15 -0
  108. package/src/runs/duckdb.test.ts +2 -2
package/AGENTS.md CHANGED
@@ -84,7 +84,7 @@ After changing `src/kernel/`, `src/client/`, `src/compiler/`, `src/validation/`,
84
84
  Published packages:
85
85
 
86
86
  - `okengine` — framework. Subpath exports: `.`, `./client`, `./test`, `./config`, `./auth`, `./plugins`, `./drivers/*`. `"sideEffects": false`. CLI binary: `oke`.
87
- - `create-oke` — scaffold CLI (`bunx create-oke@latest <name>`). Lives in `packages/create-oke` and ships Notes starters from `packages/create-oke/templates/{standard,advanced}`.
87
+ - `create-oke` — scaffold CLI (`bunx create-oke@latest <name>`). Lives in `packages/create-oke` and ships starters from `packages/create-oke/templates/{blank,shorter}`.
88
88
 
89
89
  Engine: Bun `>=1.4.2`.
90
90
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okengine",
3
- "version": "0.19.8",
3
+ "version": "0.20.0",
4
4
  "description": "One law. Eight elements. One contract. The backend model stays small; operational surfaces are derived from it instead of maintained separately.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -171,8 +171,7 @@ auth.bind(api);
171
171
  | `Forbidden` | 403 | Wrong scopes / CSRF — read `error.data.reason` |
172
172
  | `RateLimited` | 429 | Wait `retryAfterMs` |
173
173
 
174
- Also see [CORS](/docs/plugins/cors) and [CSRF](/docs/plugins/csrf). Advanced create-oke starter
175
- demos cookie + passkey + `<Can>`.
174
+ Also see [CORS](/docs/plugins/cors) and [CSRF](/docs/plugins/csrf).
176
175
 
177
176
  ## Troubleshooting
178
177
 
@@ -81,11 +81,11 @@ export const publicApiUrl = vault.config("PUBLIC_API_URL", {
81
81
  });
82
82
  ```
83
83
 
84
- | Runtime | Base URL for `createClient` |
85
- | ------------------------------------------ | --------------------------------------------------------------- |
86
- | Browser / Bun (create-oke web) | `vault.env("PUBLIC_API_URL") ?? ""` — empty = same-origin proxy |
87
- | Node / worker / CLI | `vault.env.required("PUBLIC_API_URL")` |
88
- | Separate storefront after `oke client add` | Same `vault.env("PUBLIC_API_URL") ?? ""` |
84
+ | Runtime | Base URL for `createClient` |
85
+ | ------------------------------------------ | --------------------------------------------------------- |
86
+ | Browser / Bun | `vault.env("PUBLIC_API_URL") ?? ""` — empty = same origin |
87
+ | Node / worker / CLI | `vault.env.required("PUBLIC_API_URL")` |
88
+ | Separate storefront after `oke client add` | Same `vault.env("PUBLIC_API_URL") ?? ""` |
89
89
 
90
90
  Set `PUBLIC_API_URL` via environment or `oke vault set`. Prefer `vault.env` over raw
91
91
  `process.env` — same helpers as the rest of the measure. See
@@ -362,8 +362,8 @@ const api = createClient(import.meta.env.PUBLIC_API_URL ?? "", { $routes: routes
362
362
 
363
363
  Ambient `.d.ts` types `in` / `out` / live / stream stamps. The routes module supplies wire REST.
364
364
 
365
- create-oke starters ship `web/`: Vite proxies Flow paths plus `/auth` and `/_oke` to the app.
366
- Leave `PUBLIC_API_URL` unset in local web so `createClient("")` stays same-origin.
365
+ Point `createClient` at your app origin (`PUBLIC_API_URL`), or leave it empty when the
366
+ browser page is same-origin with the API.
367
367
 
368
368
  CLI details: [CLI Reference](/docs/reference/cli).
369
369
 
@@ -387,7 +387,7 @@ Budget: the `./client` export stays under the measured client-runtime cap (hard
387
387
  <Accordions>
388
388
 
389
389
  <Accordion title="api.bookings.get is not a function / type error">
390
- Confirm the Flow is `export`ed from a generated unit (`import "@/flows/generated"` then
390
+ Confirm the Flow is `export`ed from a generated unit (`import "@/flows"` then
391
391
  `oke({ name })`), or from a module you still `.adopt({ bookings })`.
392
392
 
393
393
  Type `createClient` with that `App` (or ambient `Register` after `oke-client.d.ts` regenerates).
@@ -27,7 +27,7 @@ the envelope.
27
27
 
28
28
  ```typescript title="src/app.ts"
29
29
  import "@/core";
30
- import "@/flows/generated";
30
+ import "@/flows";
31
31
  import { oke } from "okengine/http";
32
32
 
33
33
  export const app = oke({ name: "commerce" });
@@ -46,7 +46,7 @@ import { createClient } from "okengine/client";
46
46
  import { vault } from "okengine/vault";
47
47
  import { app } from "../../src/app";
48
48
 
49
- // Base URL via vault.env (empty = same-origin proxy in create-oke web)
49
+ // Base URL via vault.env (empty = same origin as the page)
50
50
  const api = createClient(app, vault.env("PUBLIC_API_URL") ?? "");
51
51
  const { data, error } = await api.bookings.get({ id: "bkg_7f3a" });
52
52
 
@@ -128,8 +128,8 @@ const api = createClient(vault.env("PUBLIC_API_URL") ?? "");
128
128
  ```
129
129
 
130
130
  Declare the origin on the server with [Vault · Config](/docs/elements/vault/config)
131
- (`PUBLIC_API_URL`), then set it via env or `oke vault set`. Leave it unset in local web
132
- dev so `createClient("")` stays same-origin behind the Vite proxy.
131
+ (`PUBLIC_API_URL`), then set it via env or `oke vault set`. Leave it unset in local frontend
132
+ dev so `createClient("")` stays same-origin behind your proxy.
133
133
 
134
134
  </Tab>
135
135
 
@@ -221,6 +221,11 @@ Prefer [`createAuthClient`](/docs/client/auth) for cookie/Bearer sessions and me
221
221
  Without a token, status becomes `"unauthenticated"`.
222
222
  </Accordion>
223
223
 
224
+ <Accordion title="Vite SPA is a blank page">
225
+ The console shows `node:async_hooks` / `AsyncLocalStorage` from `okengine/client-react`. Upgrade
226
+ okengine — `Can` / `useLiveQuery` must not import the server realtime binder.
227
+ </Accordion>
228
+
224
229
  <Accordion title="useLiveQuery never connects">
225
230
  Pass `live: { method, path }` from `app.$routes` for the resource’s `/live` route. Set
226
231
  `enabled: true` (or omit). See [Live](/docs/client/live) for exposure and resume errors.
@@ -6,14 +6,14 @@ source: "docs/spec/unified-theory.md"
6
6
  ---
7
7
 
8
8
  Email (`channel.email`) is the default Channel medium. Declare a binder with a From address,
9
- register templates, put bodies in the catalog, and send with `fx.send`.
9
+ put `{{field}}` bodies on `.template({ catalog })`, and send with `fx.send`.
10
10
 
11
11
  For developers who need local catchers and production HTTP MTAs — same `fx.send` path, swap the
12
12
  driver.
13
13
 
14
14
  <Callout title="The one rule">
15
- Use `channel.email({ from }).template(name, { schema, locales })` — never
16
- `channel.email("name", { subject, body })`. Subject and body live in the catalog.
15
+ Use `channel.email({ from }).template(name, { schema, locales, catalog })`. Subject and body
16
+ live on the template — not on `oke()`.
17
17
  </Callout>
18
18
 
19
19
  ## Smallest Example
@@ -33,32 +33,20 @@ export const passwordReset = mail.template("auth.resetPassword", {
33
33
  description: "Password reset link",
34
34
  locales: ["en"],
35
35
  schema: z.object({ resetLink: z.string().url() }),
36
+ catalog: {
37
+ en: {
38
+ subject: "Reset your password",
39
+ text: "Open {{resetLink}} to choose a new password.",
40
+ html: '<p><a href="{{resetLink}}">Reset your password</a></p>',
41
+ },
42
+ },
36
43
  });
37
44
  ```
38
45
 
39
46
  </Step>
40
47
 
41
48
  <Step>
42
- ### Catalog + send
43
-
44
- ```typescript title="src/app.ts"
45
- import { oke } from "okengine";
46
-
47
- oke({
48
- name: "app",
49
- channel: {
50
- catalog: {
51
- "auth.resetPassword": {
52
- en: {
53
- subject: "Reset your password",
54
- text: "Open {{resetLink}} to choose a new password.",
55
- html: '<p><a href="{{resetLink}}">Reset your password</a></p>',
56
- },
57
- },
58
- },
59
- },
60
- });
61
- ```
49
+ ### Send from a Flow
62
50
 
63
51
  ```typescript title="src/flows/auth/reset.ts"
64
52
  import { on, flow, http } from "okengine";
@@ -118,13 +106,13 @@ Per-locale `subject` / `text` / `html` with `{{field}}` interpolation from `data
118
106
 
119
107
  ```typescript
120
108
  catalog: {
121
- "auth.resetPassword": {
122
- en: { subject: "Reset", text: "{{resetLink}}", html: "<a href=\"{{resetLink}}\">Reset</a>" },
123
- ar: { subject: "إعادة التعيين", text: "{{resetLink}}" },
124
- },
109
+ en: { subject: "Reset", text: "{{resetLink}}", html: "<a href=\"{{resetLink}}\">Reset</a>" },
110
+ ar: { subject: "إعادة التعيين", text: "{{resetLink}}" },
125
111
  }
126
112
  ```
127
113
 
114
+ Put that object on `.template({ catalog })`. Do not nest the template name again.
115
+
128
116
  </Tab>
129
117
 
130
118
  <Tab value="Failover">
@@ -146,8 +134,8 @@ Receipt status `fallback` means an earlier attempt failed and a later one succee
146
134
 
147
135
  <Tab value="Plugin catalog">
148
136
 
149
- Official plugins contribute catalogs with `.channelCatalog(…)` (e.g. OTP, magic link). App
150
- `channel.catalog` merges with plugin contributions at boot.
137
+ Official plugins put copy on `.template({ catalog })` (OTP, magic link, two-factor). App
138
+ `oke({ channel.catalog })` and plugin `.channelCatalog(…)` overlay those bodies at boot.
151
139
 
152
140
  </Tab>
153
141
 
@@ -164,11 +152,12 @@ Official plugins contribute catalogs with `.channelCatalog(…)` (e.g. OTP, magi
164
152
 
165
153
  ### `.template(name, options?)`
166
154
 
167
- | Option | Type | Meaning |
168
- | ------------- | ---------- | --------------------------- |
169
- | `description` | `string` | Console label |
170
- | `locales` | `string[]` | Declared locales |
171
- | `schema` | Schema | Payload contract for `data` |
155
+ | Option | Type | Meaning |
156
+ | ------------- | ---------- | -------------------------------------- |
157
+ | `description` | `string` | Console label |
158
+ | `locales` | `string[]` | Declared locales |
159
+ | `schema` | Schema | Payload contract for `data` |
160
+ | `catalog` | object | Per-locale `subject` / `text` / `html` |
172
161
 
173
162
  Default From when neither binder nor template sets one: `"oke@localhost.test"`.
174
163
 
@@ -229,8 +218,8 @@ Channel catalogs are **not** ICU — do not use `fx.t` for email bodies ([i18n](
229
218
  </Accordion>
230
219
 
231
220
  <Accordion title="Message in Mailpit has JSON body / template name as subject">
232
- No catalog entry for that template + locale. Add `channel.catalog` (or a plugin catalog) with
233
- `subject` / `text` / `html`.
221
+ No catalog entry for that template + locale. Add `catalog` on `.template(…)` (or overlay with
222
+ `oke({channel.catalog})` / plugin `.channelCatalog`) with `subject` / `text` / `html`.
234
223
  </Accordion>
235
224
 
236
225
  <Accordion title="suppressed/opted-out or prior-bounce — no provider call">
@@ -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