okengine 0.19.0 → 0.19.2

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 (110) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/ai/meta.json +1 -1
  3. package/site/content/docs/ai/skills.mdx +1 -1
  4. package/site/content/docs/client/index.mdx +1 -2
  5. package/site/content/docs/elements/ai/agents.mdx +5 -2
  6. package/site/content/docs/elements/ai/index.mdx +4 -3
  7. package/site/content/docs/elements/ai/prompts.mdx +3 -2
  8. package/site/content/docs/elements/channel/email.mdx +5 -2
  9. package/site/content/docs/elements/channel/index.mdx +1 -1
  10. package/site/content/docs/elements/clock/index.mdx +23 -12
  11. package/site/content/docs/elements/clock/schedules.mdx +6 -3
  12. package/site/content/docs/elements/clock/sleep.mdx +10 -7
  13. package/site/content/docs/elements/flow/consumers.mdx +96 -58
  14. package/site/content/docs/elements/flow/index.mdx +15 -15
  15. package/site/content/docs/elements/flow/routing.mdx +11 -2
  16. package/site/content/docs/elements/gate/tenancy.mdx +5 -2
  17. package/site/content/docs/elements/signal/broadcast.mdx +6 -4
  18. package/site/content/docs/elements/signal/index.mdx +14 -1
  19. package/site/content/docs/elements/signal/live.mdx +3 -2
  20. package/site/content/docs/elements/signal/once.mdx +3 -2
  21. package/site/content/docs/elements/store/files.mdx +24 -16
  22. package/site/content/docs/elements/store/index.mdx +11 -8
  23. package/site/content/docs/elements/store/kv.mdx +24 -16
  24. package/site/content/docs/elements/store/search.mdx +18 -14
  25. package/site/content/docs/elements/store/sql.mdx +16 -12
  26. package/site/content/docs/elements/vault/config.mdx +5 -2
  27. package/site/content/docs/elements/vault/rotation.mdx +5 -2
  28. package/site/content/docs/elements/vault/secrets.mdx +5 -2
  29. package/site/content/docs/index.mdx +4 -19
  30. package/site/content/docs/plugins/anonymous.mdx +1 -1
  31. package/site/content/docs/plugins/cors.mdx +1 -1
  32. package/site/content/docs/plugins/csrf.mdx +1 -1
  33. package/site/content/docs/plugins/headers.mdx +1 -1
  34. package/site/content/docs/plugins/ip-allowlist.mdx +1 -1
  35. package/site/content/docs/plugins/maintenance-mode.mdx +1 -1
  36. package/site/content/docs/reference/cli.mdx +1 -1
  37. package/site/content/docs/reference/errors.mdx +1 -0
  38. package/site/content/docs/reference/fx.mdx +9 -8
  39. package/site/content/docs/reference/okid.mdx +1 -1
  40. package/site/content/docs/reference/plugins.mdx +1 -1
  41. package/site/content/docs/reference/security.mdx +2 -2
  42. package/site/content/docs/understand/meta.json +1 -1
  43. package/site/content/docs/understand/the-architecture.mdx +264 -0
  44. package/src/bench/REPORT.md +48 -17
  45. package/src/bench/g17-hybrid-search.bench.ts +13 -13
  46. package/src/compiler/extract.test.ts +76 -1
  47. package/src/compiler/extract.ts +106 -7
  48. package/src/compiler/search-writer-isolation.test.ts +0 -1
  49. package/src/console/ui-next/dist/assets/{access-page-DFeymU07.js → access-page-DceEWH9u.js} +1 -1
  50. package/src/console/ui-next/dist/assets/{agent-disclosure-U1rdfblp.js → agent-disclosure-tC9s2VFd.js} +1 -1
  51. package/src/console/ui-next/dist/assets/{cache-glyph-BeFJeqBG.js → cache-glyph-C-naNQSR.js} +1 -1
  52. package/src/console/ui-next/dist/assets/{call-pii-button-CVAONPii.js → call-pii-button-DnZ_MlDn.js} +1 -1
  53. package/src/console/ui-next/dist/assets/{collapsible-D2A6NJ-3.js → collapsible-RekgR6Qz.js} +1 -1
  54. package/src/console/ui-next/dist/assets/{duration-tone-Cgk_h5ja.js → duration-tone-DugtWBS0.js} +1 -1
  55. package/src/console/ui-next/dist/assets/flows-page-DVmn1ZuQ.js +1 -0
  56. package/src/console/ui-next/dist/assets/{highlighted-json-MYZQtRnw.js → highlighted-json-CvBGaiSD.js} +1 -1
  57. package/src/console/ui-next/dist/assets/{http-method-Jrh39p7A.js → http-method-D7_OXbdC.js} +1 -1
  58. package/src/console/ui-next/dist/assets/{index-DXP2dBIF.js → index-DH0K2f6N.js} +3 -3
  59. package/src/console/ui-next/dist/assets/{observability-page-HvolXxTI.js → observability-page-qJzF2nSN.js} +1 -1
  60. package/src/console/ui-next/dist/assets/{replica-lag-CSh2dzrb.js → replica-lag-Vqk0pUBA.js} +1 -1
  61. package/src/console/ui-next/dist/assets/request-meta-BtShi4sG.js +1 -0
  62. package/src/console/ui-next/dist/assets/{store-page-eiKiHnNe.js → store-page-CMsYH_vH.js} +1 -1
  63. package/src/console/ui-next/dist/assets/{trace-detail-sheet-CZkMeKS-.js → trace-detail-sheet-ycFB2uua.js} +1 -1
  64. package/src/console/ui-next/dist/assets/{tree-expand-toggle-CW8y5A2h.js → tree-expand-toggle-iG1jcWgU.js} +1 -1
  65. package/src/console/ui-next/dist/assets/{units-page-B_RWJrEO.js → units-page-Bavchke4.js} +1 -1
  66. package/src/console/ui-next/dist/assets/{vault-page-CWrg-A68.js → vault-page-DxUFhiZI.js} +1 -1
  67. package/src/console/ui-next/dist/index.html +1 -1
  68. package/src/console/ui-next/src/features/flows/graph/element-map.test.ts +1 -1
  69. package/src/console/ui-next/src/features/flows/graph/element-map.ts +3 -3
  70. package/src/console/ui-next/src/features/flows/traces/trace-detail.test.ts +4 -5
  71. package/src/console/ui-next/src/features/flows/traces/trace-gates.ts +3 -6
  72. package/src/elements/gate/declare.ts +1 -1
  73. package/src/elements/gate.ts +1 -1
  74. package/src/elements/store/live-default.test.ts +8 -0
  75. package/src/elements/store/search-embed-flow.ts +2 -2
  76. package/src/elements/store/search-lsh.ts +35 -0
  77. package/src/elements/store/search-runtime.pglite.test.ts +186 -0
  78. package/src/elements/store/search-runtime.ts +30 -16
  79. package/src/elements/store/search.test.ts +83 -1
  80. package/src/elements/store.ts +2 -0
  81. package/src/full.ts +3 -0
  82. package/src/http.ts +3 -0
  83. package/src/index.ts +3 -0
  84. package/src/kernel/app.ts +30 -10
  85. package/src/kernel/boot.ts +1 -1
  86. package/src/kernel/cdc-payload.test.ts +224 -0
  87. package/src/kernel/cdc-payload.ts +146 -0
  88. package/src/kernel/errors-flow-name.ts +17 -0
  89. package/src/kernel/errors.registry.test.ts +6 -4
  90. package/src/kernel/flow-name.test.ts +104 -0
  91. package/src/kernel/flow.ts +3 -2
  92. package/src/kernel/fx-emit-types.test.ts +31 -0
  93. package/src/kernel/fx.test.ts +2 -1
  94. package/src/kernel/fx.ts +13 -3
  95. package/src/kernel/index.ts +2 -0
  96. package/src/kernel/on.ts +5 -0
  97. package/src/kernel/stamp-http.test.ts +13 -0
  98. package/src/kernel/stamp-http.ts +21 -3
  99. package/src/kernel/unit.ts +4 -2
  100. package/src/kernel-entry.ts +3 -0
  101. package/src/mcp/docs-index.ts +3 -3
  102. package/src/mcp/docs-mcp.test.ts +2 -2
  103. package/src/mcp/docs-tools.ts +2 -1
  104. package/site/content/docs/understand/the-anatomy.mdx +0 -132
  105. package/site/content/docs/understand/the-model.mdx +0 -32
  106. package/site/content/docs/understand/the-problem.mdx +0 -74
  107. package/site/content/docs/understand/the-vocabulary.mdx +0 -26
  108. package/src/console/ui-next/dist/assets/flows-page-Dss7941e.js +0 -1
  109. package/src/console/ui-next/dist/assets/request-meta-BatF8KrK.js +0 -1
  110. /package/site/content/docs/{ai → understand}/try-it.mdx +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okengine",
3
- "version": "0.19.0",
3
+ "version": "0.19.2",
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": {
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "title": "AI Resources",
3
3
  "icon": "Bot",
4
- "pages": ["try-it", "index", "mcp", "skills", "llms-txt"]
4
+ "pages": ["index", "mcp", "skills", "llms-txt"]
5
5
  }
@@ -46,7 +46,7 @@ Contracts teach the _vocabulary_; [MCP](/docs/ai/mcp) grants the _hands_. An age
46
46
  ## Learn more
47
47
 
48
48
  - [MCP](/docs/ai/mcp) — the runtime surface these contracts pair with
49
- - [The Model](/docs/understand/the-model) — the eight elements in human terms
49
+ - [The Architecture](/docs/understand/the-architecture) — the eight elements in human terms
50
50
  - [llms.txt](/docs/ai/llms-txt) — `/llms.txt`, `/llms.json`, `/llms/agents`, per-page markdown
51
51
 
52
52
  ## Next
@@ -184,8 +184,7 @@ dev so `createClient("")` stays same-origin behind the Vite proxy.
184
184
 
185
185
  - [Routing](/docs/elements/flow/routing) — folders are the URL; `$routes` without `.adopt()`
186
186
  - [HTTP](/docs/elements/flow/http) — verbs, `http.resource`, live SSE
187
- - [The Anatomy](/docs/understand/the-anatomy) — generated barrel → client → test loop
188
- - [The Model](/docs/understand/the-model) — same contract for Client, Console, MCP
187
+ - [The Architecture](/docs/understand/the-architecture) — generated barrel → client → test loop; same contract for Client, Console, MCP
189
188
  - [Errors](/docs/reference/errors) — framework codes vs failure values
190
189
 
191
190
  ## Next
@@ -54,9 +54,12 @@ import { supportAgent } from "@/core/ai";
54
54
  import { member } from "@/core/gate";
55
55
 
56
56
  export const assist = on(
57
- http.post().gate(member),
57
+ http
58
+ .post({
59
+ in: z.object({ query: z.string().min(1) }),
60
+ })
61
+ .gate(member),
58
62
  flow({
59
- in: z.object({ query: z.string().min(1) }),
60
63
  do: async ({ query }, fx) => {
61
64
  return await fx.run(supportAgent, {
62
65
  message: `Help the member: ${query}`,
@@ -60,9 +60,10 @@ import { z } from "zod";
60
60
  import { triage } from "@/core/ai";
61
61
 
62
62
  export const classify = on(
63
- http.post(),
64
- flow({
63
+ http.post({
65
64
  in: z.object({ message: z.string().min(1) }),
65
+ }),
66
+ flow({
66
67
  do: async ({ message }, fx) => {
67
68
  return await fx.ask(triage, { message });
68
69
  },
@@ -336,6 +337,6 @@ Compose does not pin inference — BYO URL + keys, or [OpenRouter](/docs/recipes
336
337
  <Card
337
338
  title="The Model"
338
339
  description="Eight elements overview."
339
- href="/docs/understand/the-model"
340
+ href="/docs/understand/the-architecture"
340
341
  />
341
342
  </Cards>
@@ -56,9 +56,10 @@ import { z } from "zod";
56
56
  import { classifyTicket } from "@/core/ai";
57
57
 
58
58
  export const classify = on(
59
- http.post(),
60
- flow({
59
+ http.post({
61
60
  in: z.object({ message: z.string().min(1) }),
61
+ }),
62
+ flow({
62
63
  do: async ({ message }, fx) => {
63
64
  return await fx.ask(classifyTicket, { message });
64
65
  },
@@ -66,9 +66,12 @@ import { z } from "zod";
66
66
  import { passwordReset } from "@/core/channel";
67
67
 
68
68
  export const reset = on(
69
- http.post().public(),
69
+ http
70
+ .post({
71
+ in: z.object({ email: z.string().email() }),
72
+ })
73
+ .public(),
70
74
  flow({
71
- in: z.object({ email: z.string().email() }),
72
75
  do: async ({ email }, fx) => {
73
76
  await fx.send(passwordReset, {
74
77
  to: email,
@@ -339,6 +339,6 @@ Env knobs: [Environment variables](/docs/reference/environment-variables). Local
339
339
  <Card
340
340
  title="The Model"
341
341
  description="Eight elements overview."
342
- href="/docs/understand/the-model"
342
+ href="/docs/understand/the-architecture"
343
343
  />
344
344
  </Cards>
@@ -26,7 +26,7 @@ For developers scheduling work on okengine — one handle shape; drivers swap by
26
26
  ```typescript title="src/clocks/digest.ts"
27
27
  import { clock } from "okengine";
28
28
 
29
- export const digestClock = clock("notes.digest", { every: "1d" });
29
+ export const digestClock = clock.every("notes.digest", "1d");
30
30
  ```
31
31
 
32
32
  </Step>
@@ -73,6 +73,16 @@ Under `drivers.clock.test = "frozen"`, advance time in tests instead of waiting
73
73
  separate job runner — see [Consumers · Clock Jobs](/docs/elements/flow/consumers#clock-jobs).
74
74
  </Callout>
75
75
 
76
+ ## Inline or named export
77
+
78
+ | Style | When |
79
+ | --------------------------------------------- | ----------------------------------------------------------------- |
80
+ | `on(clock.every("name", "1h"), flow({ do }))` | Self-contained — nothing else needs the Clock handle |
81
+ | `export const x = clock.every("name", "1h")` | Another file (or a second `on()`) must reuse the same declaration |
82
+
83
+ Nameless `flow({ do })` inherits the Clock's declared name. File-tree `unit.export` and explicit
84
+ `flow("…")` win. Two nameless Flows inheriting the same name fail **OKE1070**.
85
+
76
86
  ## Progressive Patterns
77
87
 
78
88
  Same `clock` + `fx.clock` from a calendar cron to an interval, a durable pause, and typed offsets:
@@ -114,7 +124,7 @@ Human durations — integer + unit, no weeks: `"200ms"` · `"30s"` · `"5m"` ·
114
124
  ```typescript title="src/flows/health/ping.ts"
115
125
  import { on, flow, clock } from "okengine";
116
126
 
117
- export const pingClock = clock("health.pingExternal", { every: "30s" });
127
+ export const pingClock = clock.every("health.pingExternal", "30s");
118
128
 
119
129
  export const pingExternal = on(
120
130
  pingClock,
@@ -140,10 +150,11 @@ import { on, flow, http } from "okengine";
140
150
  import { z } from "zod";
141
151
 
142
152
  export const start = on(
143
- http.post(),
153
+ http.post({
154
+ in: z.object({ email: z.string().email() }),
155
+ }),
144
156
  flow({
145
157
  durable: true,
146
- in: z.object({ email: z.string().email() }),
147
158
  do: async ({ email }, fx) => {
148
159
  await fx.step("mark-trial", async () => {
149
160
  await fx.call(startTrial, { email });
@@ -182,14 +193,14 @@ calendar day. Unknown strings parse as `0`.
182
193
 
183
194
  ## Capability Reference
184
195
 
185
- | Surface | Signature | Purpose | `do` input |
186
- | ------------- | -------------------------------------------------------- | ------------------------------- | ---------- |
187
- | Cron schedule | `clock(name, { cron, timezone? })` | Calendar fires in an IANA zone | none (`_`) |
188
- | Helpers | `clock.daily` · `hourly` · `weekly` · `monthly` · `cron` | Same decl; presets + field bags | none (`_`) |
189
- | Interval | `clock(name, { every })` / `clock.every` | Fixed duration loop | none (`_`) |
190
- | Per-tenant | `clock.perTenant(name, opts)` | One Store row per tenant | none (`_`) |
191
- | Now / offsets | `fx.clock.now` · `ago` · `fromNow` · `duration` | Injectable time math | — |
192
- | Durable sleep | `fx.clock.sleep(label, duration)` | Park a durable Flow until wake | — |
196
+ | Surface | Signature | Purpose | `do` input |
197
+ | ------------- | -------------------------------------------------------- | ------------------------------ | ---------- |
198
+ | Helpers | `clock.daily` · `hourly` · `weekly` · `monthly` · `cron` | Calendar presets + field bags | none (`_`) |
199
+ | Interval | `clock.every(name, duration, opts?)` | Fixed duration loop | none (`_`) |
200
+ | Per-tenant | `clock.perTenant(name, opts)` | One Store row per tenant | none (`_`) |
201
+ | Bare callable | `clock(name, { cron? \| every?, … })` | Same decl; lower-level form | none (`_`) |
202
+ | Now / offsets | `fx.clock.now` · `ago` · `fromNow` · `duration` | Injectable time math | — |
203
+ | Durable sleep | `fx.clock.sleep(label, duration)` | Park a durable Flow until wake | — |
193
204
 
194
205
  At least one of `cron` or `every` is required on every declaration.
195
206
 
@@ -99,8 +99,10 @@ export const close = clock.monthly("billing.close", { on: [1, 15], at: "00:00" }
99
99
  export const ping = clock.every("health.ping", "30s");
100
100
  ```
101
101
 
102
- Raw strings still work: `clock("x", { cron: "0 6 * * *" })`, Bun nicknames (`@hourly`),
103
- and `clock("x", { every: "30s" })`.
102
+ Prefer the named helpers above for fixed schedules. The bare callable remains fully
103
+ supported for the same `ClockDecl` shape when you need a raw string or a schedule
104
+ chosen programmatically: `clock("x", { cron: "0 6 * * *" })`, Bun nicknames
105
+ (`@hourly`), and `clock("x", { every: "30s" })`.
104
106
 
105
107
  **Consequence:** Manifest / Store still see `cron: "0 6 * * *"` — helpers are declare-time sugar.
106
108
 
@@ -159,7 +161,8 @@ export const runInvoices = on(
159
161
  );
160
162
  ```
161
163
 
162
- Equivalent: `clock("invoices", { every: "1h", perTenant: true })`.
164
+ Equivalent bare form (same decl; prefer `clock.perTenant` above):
165
+ `clock("invoices", { every: "1h", perTenant: true })`.
163
166
 
164
167
  **Consequence:** ten tenants → ten leader-elected rows. Catch-up still fires **once per
165
168
  row** after downtime — not a burst of missed hours per tenant.
@@ -30,10 +30,11 @@ import { on, flow, http } from "okengine";
30
30
  import { z } from "zod";
31
31
 
32
32
  export const start = on(
33
- http.post(),
33
+ http.post({
34
+ in: z.object({ email: z.string().email() }),
35
+ }),
34
36
  flow({
35
37
  durable: true,
36
- in: z.object({ email: z.string().email() }),
37
38
  do: async ({ email }, fx) => {
38
39
  await fx.step("mark-trial", async () => {
39
40
  await fx.call(startTrial, { email });
@@ -101,10 +102,11 @@ import { on, flow, http } from "okengine";
101
102
  import { z } from "zod";
102
103
 
103
104
  export const create = on(
104
- http.post(),
105
+ http.post({
106
+ in: z.object({ email: z.string().email() }),
107
+ }),
105
108
  flow({
106
109
  durable: true,
107
- in: z.object({ email: z.string().email() }),
108
110
  do: async ({ email }, fx) => {
109
111
  await fx.step("create", async () => {
110
112
  await fx.call(createWorkspace, { email });
@@ -256,11 +258,12 @@ import { on, flow, http } from "okengine";
256
258
  import { z } from "zod";
257
259
 
258
260
  export const create = on(
259
- http.post(),
260
- flow({
261
- durable: true,
261
+ http.post({
262
262
  in: z.object({ email: z.string().email() }),
263
263
  out: z.object({ trialId: z.string() }),
264
+ }),
265
+ flow({
266
+ durable: true,
264
267
  do: async ({ email }, fx) => {
265
268
  const trialId = await fx.step("create", async () => {
266
269
  return await fx.call(createTrial, { email });
@@ -11,8 +11,8 @@ For developers wiring background work on okengine — bind the trigger, keep `do
11
11
 
12
12
  <Callout title="The one rule">
13
13
  Bind with `on(signal)`, `on(clockDecl)`, or `on(db.table(…).changed())`. Delivery physics live on
14
- the Signal; `cron` / `every` live on the Clock; CDC input is always `{ before, after }`. World
15
- access still goes through `fx`.
14
+ the Signal; `cron` / `every` live on the Clock; CDC input is `{ before, after }` plus `table` /
15
+ `action` / `id`. World access still goes through `fx`.
16
16
  </Callout>
17
17
 
18
18
  ## Smallest Example
@@ -108,7 +108,7 @@ import { on, flow, clock } from "okengine";
108
108
  import { lt } from "drizzle-orm";
109
109
  import { db, metricLogs } from "@/schema";
110
110
 
111
- export const cleanupClock = clock("metrics.cleanup", { every: "1h" });
111
+ export const cleanupClock = clock.every("metrics.cleanup", "1h");
112
112
 
113
113
  export const cleanupMetrics = on(
114
114
  cleanupClock,
@@ -127,7 +127,8 @@ export const cleanupMetrics = on(
127
127
 
128
128
  <Tab value="CDC">
129
129
 
130
- `db.table(handle).changed()` fires on insert, update, and delete. Input is `{ before, after }`:
130
+ `db.table(handle).changed()` fires on insert, update, and delete. Input is `{ before, after }`
131
+ plus `table`, `action`, and `id`:
131
132
 
132
133
  ```typescript title="src/flows/audit/users.ts"
133
134
  import { on, flow } from "okengine";
@@ -137,14 +138,15 @@ import { users, auditLogs } from "@/schema";
137
138
  export const onUserWrite = on(
138
139
  db.table(users).changed(),
139
140
  flow("audit.users", {
140
- do: async ({ before, after }, fx) => {
141
- const action = !before ? "created" : !after ? "deleted" : "updated";
142
- const recordId = String(after?.id ?? before?.id ?? "");
143
- await fx.store(db).insert(auditLogs).values({
144
- table: "users",
145
- recordId,
146
- action,
147
- });
141
+ do: async ({ table, action, id }, fx) => {
142
+ await fx
143
+ .store(db)
144
+ .insert(auditLogs)
145
+ .values({
146
+ table,
147
+ recordId: String(id),
148
+ action,
149
+ });
148
150
  },
149
151
  }),
150
152
  );
@@ -183,16 +185,19 @@ Omit `key` for competing consumers with no ordering.
183
185
 
184
186
  ## Trigger Reference
185
187
 
186
- | Trigger | Signature | Purpose | `do` input |
187
- | ---------- | -------------------------------------- | ------------------------------------------- | ------------------- |
188
- | Signal | `on(signalHandle, flow)` | Queue (`once`) or fan-out (`broadcast`) | Payload (`schema`) |
189
- | Clock | `on(clockDecl, flow)` | Interval or cron tick | none (`_`) |
190
- | CDC any | `on(db.table(t).changed(), flow)` | Every insert / update / delete | `{ before, after }` |
191
- | CDC column | `on(db.table(t).changed("col"), flow)` | Same writes; column stamped on the Manifest | `{ before, after }` |
188
+ | Trigger | Signature | Purpose | `do` input |
189
+ | ---------- | --------------------------------------------------- | ------------------------------------------- | -------------------------------------- |
190
+ | Signal | `on(signalHandle, flow)` or inline `signal.once(…)` | Queue (`once`) or fan-out (`broadcast`) | Payload (`schema`) |
191
+ | Clock | `on(clockDecl, flow)` or inline `clock.every(…)` | Interval or cron tick | none (`_`) |
192
+ | CDC any | `on(db.table(t).changed(), flow)` | Every insert / update / delete | `{ before, after, table, action, id }` |
193
+ | CDC column | `on(db.table(t).changed("col"), flow)` | Same writes; column stamped on the Manifest | `{ before, after, table, action, id }` |
192
194
 
193
195
  `signal.live` is an HTTP SSE tape — bind it with [`http.live`](/docs/elements/flow/http#live-streams),
194
196
  not as a worker. A Flow with no trigger is [call-only](/docs/elements/flow).
195
197
 
198
+ Inline `on(signal.once("…"), flow({ do }))` is supported; export the handle for typed `fx.emit`.
199
+ Nameless `flow({ do })` inherits the trigger name; a second nameless inherit fails **OKE1070**.
200
+
196
201
  ## Signal Consumers
197
202
 
198
203
  <Callout title="Detailed section">
@@ -342,9 +347,11 @@ Broadcast does not use the `once` lease / DLQ path. Live uses the retained tape,
342
347
  ## Clock Jobs
343
348
 
344
349
  <Callout title="Detailed section">
345
- If you only need an interval, jump to the example below. `clock(name)` requires `cron` or `every`
346
- — missing both throws `clock("name"): require cron or every`. Bind the returned handle with
347
- `on(clockDecl, flow)`.
350
+ Prefer `clock.every` / `clock.daily` / `clock.cron` (and the other named helpers) for fixed
351
+ schedules — they compile to the same decl as the bare callable. The lower-level bare `clock(name,
352
+ options)` form still works when you need a raw expression or a schedule chosen programmatically;
353
+ missing both `cron` and `every` throws `clock("name"): require cron or every`. Bind the returned
354
+ handle with `on(clockDecl, flow)`.
348
355
  </Callout>
349
356
 
350
357
  Named clocks reconcile into the Store at boot. The scheduler leader-elects so N instances do not
@@ -359,7 +366,7 @@ Human durations: `"200ms"` · `"30s"` · `"5m"` · `"1h"` · `"7d"` (integer + u
359
366
  ```typescript title="src/flows/health/ping.ts"
360
367
  import { on, flow, clock } from "okengine";
361
368
 
362
- export const pingClock = clock("health.pingExternal", { every: "30s" });
369
+ export const pingClock = clock.every("health.pingExternal", "30s");
363
370
 
364
371
  export const pingExternal = on(
365
372
  pingClock,
@@ -381,8 +388,8 @@ Five-field cron (`m h dom mon dow`) plus an IANA `timezone` (default `"UTC"`):
381
388
  ```typescript title="src/flows/reports/daily.ts"
382
389
  import { on, flow, clock } from "okengine";
383
390
 
384
- export const dailyReportClock = clock("reports.daily", {
385
- cron: "0 6 * * *",
391
+ export const dailyReportClock = clock.daily("reports.daily", {
392
+ at: "06:00",
386
393
  timezone: "Asia/Riyadh",
387
394
  });
388
395
 
@@ -423,7 +430,8 @@ export const runInvoices = on(
423
430
  );
424
431
  ```
425
432
 
426
- Equivalent: `clock("invoices", { every: "1h", perTenant: true })`.
433
+ Equivalent bare form (same decl; prefer `clock.perTenant` above):
434
+ `clock("invoices", { every: "1h", perTenant: true })`.
427
435
 
428
436
  </Tab>
429
437
 
@@ -487,13 +495,43 @@ do not fire.
487
495
  </Callout>
488
496
 
489
497
  SQL writes through `fx.store` notify CDC after commit. The Flow input is always
490
- `{ before, after }` (`CdcPayload`).
498
+ `{ before, after }` plus `table`, `action`, and `id` (`CdcPayload`). Destructure
499
+ only `{ before, after }` when that's all you need.
500
+
501
+ <Tabs items={["Audit log", "Bare", "Images"]}>
502
+
503
+ <Tab value="Audit log">
504
+
505
+ `table` is the real table name. `action` is `"created"` / `"updated"` / `"deleted"`.
506
+ `id` is the declared primary-key value — not a hardcoded `"id"` column:
507
+
508
+ ```typescript title="src/flows/audit/users.ts"
509
+ import { on, flow } from "okengine";
510
+ import { db } from "@/core";
511
+ import { users, auditLogs } from "@/schema";
512
+
513
+ export const onUserWrite = on(
514
+ db.table(users).changed(),
515
+ flow("audit.users", {
516
+ do: async ({ table, action, id, before, after }, fx) => {
517
+ await fx
518
+ .store(db)
519
+ .insert(auditLogs)
520
+ .values({
521
+ table,
522
+ recordId: String(id),
523
+ action,
524
+ });
525
+ },
526
+ }),
527
+ );
528
+ ```
491
529
 
492
- <Tabs items={["Any write", "Column", "Images"]}>
530
+ </Tab>
493
531
 
494
- <Tab value="Any write">
532
+ <Tab value="Bare">
495
533
 
496
- Omit the argument to react to every insert, update, and delete on the table:
534
+ Omit `table` / `action` / `id` when you only need the row images:
497
535
 
498
536
  ```typescript title="src/flows/search/reindex.ts"
499
537
  import { on, flow } from "okengine";
@@ -505,24 +543,18 @@ export const reindexNotes = on(
505
543
  flow("search.reindexNotes", {
506
544
  plane: "operator",
507
545
  do: async ({ before, after }, fx) => {
508
- const id = String(after?.id ?? before?.id ?? "");
509
- if (!id) return;
510
546
  if (!after) {
511
- await fx.call(dropNoteIndex, { id });
547
+ await fx.call(dropNoteIndex, { id: String(before?.id ?? "") });
512
548
  return;
513
549
  }
514
- await fx.call(upsertNoteIndex, { id });
550
+ await fx.call(upsertNoteIndex, { id: String(after.id) });
515
551
  },
516
552
  }),
517
553
  );
518
554
  ```
519
555
 
520
- </Tab>
521
-
522
- <Tab value="Column">
523
-
524
- `changed("status")` stamps `trigger.cdc.column` on the Manifest (Console, extract). Still
525
- receive `{ before, after }` — filter in `do` when you only care about that field:
556
+ `changed("status")` stamps `trigger.cdc.column` on the Manifest. Still receive the
557
+ same payload — filter in `do` when you only care about that field:
526
558
 
527
559
  ```typescript title="src/flows/tasks/on-status.ts"
528
560
  import { on, flow } from "okengine";
@@ -533,10 +565,8 @@ export const onStatus = on(
533
565
  db.table(tasks).changed("status"),
534
566
  flow("tasks.onStatus", {
535
567
  plane: "operator",
536
- do: async ({ before, after }, fx) => {
568
+ do: async ({ before, after, id }, fx) => {
537
569
  if (before?.status === after?.status) return;
538
- const id = String(after?.id ?? before?.id ?? "");
539
- if (!id) return;
540
570
  await fx.emit(taskStatusChanged, {
541
571
  id,
542
572
  from: before?.status ?? null,
@@ -551,21 +581,21 @@ export const onStatus = on(
551
581
 
552
582
  <Tab value="Images">
553
583
 
554
- Op is inferred from which image is null:
584
+ Op is inferred from which image is null — the same derivation as `action`:
555
585
 
556
- | Write | `before` | `after` |
557
- | ------ | ------------ | ------- |
558
- | Insert | `null` | new row |
559
- | Update | previous row | new row |
560
- | Delete | previous row | `null` |
586
+ | Write | `before` | `after` | `action` |
587
+ | ------ | ------------ | ------- | ----------- |
588
+ | Insert | `null` | new row | `"created"` |
589
+ | Update | previous row | new row | `"updated"` |
590
+ | Delete | previous row | `null` | `"deleted"` |
561
591
 
562
592
  ```typescript
563
- do: async ({ before, after }, fx) => {
564
- if (!before && after) {
593
+ do: async ({ before, after, action }, fx) => {
594
+ if (action === "created") {
565
595
  /* insert */
566
- } else if (before && after) {
596
+ } else if (action === "updated") {
567
597
  /* update */
568
- } else if (before && !after) {
598
+ } else {
569
599
  /* delete */
570
600
  }
571
601
  };
@@ -578,8 +608,9 @@ do: async ({ before, after }, fx) => {
578
608
  <Accordions>
579
609
 
580
610
  <Accordion title="CDC payload">
581
- Kernel input is `{ before, after }` — not `{ record }`, not `{ op }`. Read the primary key from
582
- `after?.id ?? before?.id`.
611
+ `{ before, after }` are always present. `{ table, action, id }` are always populated — `id` is
612
+ the table's declared primary-key value, not a column assumed to be named `"id"`. There is no
613
+ `record` field and no `{ op }` (that stays on the live-query / outbox path).
583
614
 
584
615
  Writes must go through `fx.store`. A raw SQL client bypasses the sink, so no consumer runs.
585
616
 
@@ -625,7 +656,13 @@ Clock drivers: **postgres** in `dev`/`prod`, **frozen** in `test`. Signal driver
625
656
  <Accordion title="TypeError: on() expected a trigger or signal handle">
626
657
  The first argument must be a Signal handle, a Clock handle, `db.table(…).changed()`, an HTTP
627
658
  trigger, `internal`, or `mcp.tool(…)`. A bare interval string is not a trigger — wrap it in
628
- `clock("name", { every: "1h" })`.
659
+ `clock.every("name", "1h")`.
660
+ </Accordion>
661
+
662
+ <Accordion title="OKE1070 — flow name defined twice">
663
+ Cause: `Flow "{flow}" is defined twice.` Two nameless `flow({ do })` bindings inherited the same
664
+ Signal or Clock name, or two explicit `flow("…")` calls share a name. Give at least one an
665
+ explicit `flow("…")` or a distinct tree export.
629
666
  </Accordion>
630
667
 
631
668
  <Accordion title="OKE1240 — emit with no subscriber">
@@ -644,8 +681,9 @@ Clock drivers: **postgres** in `dev`/`prod`, **frozen** in `test`. Signal driver
644
681
  </Accordion>
645
682
 
646
683
  <Accordion title="CDC do never sees record / op">
647
- Input is `{ before, after }`. There is no `record` field. Infer insert / update / delete from
648
- which image is null.
684
+ Input is `{ before, after, table, action, id }`. There is no `record` field. `action` is
685
+ `"created"` / `"updated"` / `"deleted"` from which image is null. `{ op }` is live-query /
686
+ outbox only.
649
687
  </Accordion>
650
688
 
651
689
  <Accordion title="Cron fired 24 times after overnight downtime">
@@ -678,7 +716,7 @@ Clock drivers: **postgres** in `dev`/`prod`, **frozen** in `test`. Signal driver
678
716
  - [Store · SQL](/docs/elements/store/sql) — tables CDC watches
679
717
  - [HTTP · Live Streams](/docs/elements/flow/http#live-streams) — `signal.live` SSE
680
718
  - [fx](/docs/reference/fx) — `fx.emit`, `fx.deadLetters`, `fx.clock`
681
- - [Errors](/docs/reference/errors) — OKE1240 · OKE1250
719
+ - [Errors](/docs/reference/errors) — OKE1070 · OKE1240 · OKE1250
682
720
  - [Workflows](/docs/elements/flow/workflows) — `durable: true` + `fx.step` on a consumer
683
721
 
684
722
  ## Next
@@ -179,14 +179,14 @@ const { chargeId } = await fx.call(chargeCard, { amount: 50 });
179
179
 
180
180
  <FlowTriggers />
181
181
 
182
- | Trigger | Bind | Starts when | `do` input |
183
- | --------- | ---------------------------------------------- | ------------------- | -------------------------- |
184
- | HTTP | `on(http.get(), flow)` | A request | Merged path / query / body |
185
- | Signal | `on(signalHandle, flow)` | `fx.emit` | Payload (`schema`) |
186
- | Clock | `on(clockDecl, flow)` | Scheduler tick | none (`_`) |
187
- | CDC | `on(db.table(t).changed(), flow)` | Committed SQL write | `{ before, after }` |
188
- | Call-only | `call("name", { in, out, do, … })` | `fx.call` | Callee `in` |
189
- | MCP | `on(mcp.tool("x", { in, out }).gate(…), flow)` | MCP `tools/call` | Tool args |
182
+ | Trigger | Bind | Starts when | `do` input |
183
+ | --------- | ---------------------------------------------- | ------------------- | -------------------------------------- |
184
+ | HTTP | `on(http.get(), flow)` | A request | Merged path / query / body |
185
+ | Signal | `on(signalHandle, flow)` | `fx.emit` | Payload (`schema`) |
186
+ | Clock | `on(clockDecl, flow)` | Scheduler tick | none (`_`) |
187
+ | CDC | `on(db.table(t).changed(), flow)` | Committed SQL write | `{ before, after, table, action, id }` |
188
+ | Call-only | `call("name", { in, out, do, … })` | `fx.call` | Callee `in` |
189
+ | MCP | `on(mcp.tool("x", { in, out }).gate(…), flow)` | MCP `tools/call` | Tool args |
190
190
 
191
191
  `signal.live` is an HTTP SSE tape — bind it with [`http.live`](/docs/elements/flow/http#live-streams), not as a worker.
192
192
 
@@ -366,7 +366,7 @@ A bare `404` with body `Not Found` means **no route matched** — not `fx.fail("
366
366
  | `fx.ask(prompt, opts)` | `asks` | AI prompt |
367
367
  | `fx.vault.get(secret)` | `secrets` | Declared secret (never a raw value in source) |
368
368
  | `fx.call(flow, input?)` | `calls` | Another Flow — waits for return |
369
- | `fx.id()` | — | UUID |
369
+ | `fx.id()` | — | OKID — 21-char native id from `okengine/okid` |
370
370
  | `fx.clock.now()` | — | Deterministic time |
371
371
  | `fx.fail(code, data)` | — | Typed failure value |
372
372
  | `fx.step(name, fn)` | journal | Durable checkpoint |
@@ -524,9 +524,9 @@ Default is `true` once `gate.auth.tenant` is on.
524
524
  </Accordion>
525
525
 
526
526
  <Accordion title="TypeError: on() expected a trigger or signal handle">
527
- First argument must be an HTTP trigger, Signal handle, Clock handle,
528
- `db.table(…).changed()`, `internal`, or `mcp.tool(…)`. A bare interval string is not
529
- a trigger — wrap it in `clock("name", { every: "1h" })`.
527
+ First argument must be an HTTP trigger, Signal handle, Clock handle, `db.table(…).changed()`,
528
+ `internal`, or `mcp.tool(…)`. A bare interval string is not a trigger — wrap it in
529
+ `clock.every("name", "1h")`.
530
530
  </Accordion>
531
531
 
532
532
  <Accordion title="TypeError: on() expected a flow() definition as the second argument">
@@ -582,7 +582,7 @@ Default is `true` once `gate.auth.tenant` is on.
582
582
 
583
583
  ## Learn more
584
584
 
585
- - [The Anatomy](/docs/understand/the-anatomy) — `on`, trigger, `flow`, `do`, `fx`
585
+ - [The Architecture](/docs/understand/the-architecture) — `on`, trigger, `flow`, `do`, `fx`
586
586
  - [HTTP](/docs/elements/flow/http) — verbs, envelopes, resources, live SSE
587
587
  - [Routing](/docs/elements/flow/routing) — file-tree stamps, barrels, OKE1030 · OKE1040–1045
588
588
  - [Consumers](/docs/elements/flow/consumers) — Signal / Clock / CDC
@@ -611,8 +611,8 @@ Default is `true` once `gate.auth.tenant` is on.
611
611
  href="/docs/elements/flow/workflows"
612
612
  />
613
613
  <Card
614
- title="The Anatomy"
614
+ title="The Architecture"
615
615
  description="Five pieces behind on(trigger, flow)."
616
- href="/docs/understand/the-anatomy"
616
+ href="/docs/understand/the-architecture"
617
617
  />
618
618
  </Cards>
@@ -455,6 +455,10 @@ prefix). Wrong-unit prefixes fail generate.
455
455
  Non-HTTP files still join the unit. A signal consumer in `notes/on-created.ts`
456
456
  is `api.notes.onCreated` over RPC (`POST /_oke/notes/onCreated`), not HTTP.
457
457
 
458
+ Outside the tree, nameless `flow({ do })` inherits a named Signal or Clock trigger.
459
+ File-tree `unit.export` still wins. Two nameless inheritances fail **OKE1070**. HTTP has no
460
+ inherent trigger name — those Flows stay on the tree stamp or **OKE1045**.
461
+
458
462
  HTTP flows that remain nameless after adopt fail **OKE1045**.
459
463
 
460
464
  ## Runtime Matching
@@ -512,6 +516,11 @@ plus a handwritten `http.get("/notes")`.
512
516
  file so the tree can stamp `unit.export`. `export default` is not picked up.
513
517
  </Accordion>
514
518
 
519
+ <Accordion title="OKE1070 — flow name defined twice">
520
+ Cause: `Flow "{flow}" is defined twice.` Two nameless Signal/Clock consumers inherited the same
521
+ name, or two explicit `flow("…")` calls collide. Give at least one a distinct name or tree export.
522
+ </Accordion>
523
+
515
524
  <Accordion title="422 — path param missing from in">
516
525
  `[id]` stamps `:id`. `in` must declare `id` (same key). A schema that expects `userId` while the
517
526
  path is `:id` fails validation before `do`.
@@ -534,8 +543,8 @@ plus a handwritten `http.get("/notes")`.
534
543
 
535
544
  - [HTTP](/docs/elements/flow/http) — verbs, envelopes, `http.resource`, live SSE
536
545
  - [Client](/docs/client/calling) — `api.notes.get`, REST vs RPC, `$routes`
537
- - [Errors](/docs/reference/errors) — OKE1040 · OKE1030 · OKE1041 · OKE1045
538
- - [The Model](/docs/understand/the-model) — derived routes, no hand-written table
546
+ - [Errors](/docs/reference/errors) — OKE1040 · OKE1030 · OKE1041 · OKE1045 · OKE1070
547
+ - [The Architecture](/docs/understand/the-architecture) — derived routes, no hand-written table
539
548
  - [Gate](/docs/elements/gate) — `.gate(...)` / `.public()` on the same trigger
540
549
 
541
550
  ## Next