okengine 0.19.1 → 0.19.3

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 (68) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/elements/clock/index.mdx +162 -11
  3. package/site/content/docs/elements/clock/schedules.mdx +6 -3
  4. package/site/content/docs/elements/flow/consumers.mdx +137 -79
  5. package/site/content/docs/elements/flow/index.mdx +14 -14
  6. package/site/content/docs/elements/flow/routing.mdx +18 -2
  7. package/site/content/docs/elements/signal/broadcast.mdx +9 -13
  8. package/site/content/docs/elements/signal/index.mdx +245 -13
  9. package/site/content/docs/elements/signal/live.mdx +5 -13
  10. package/site/content/docs/elements/signal/once.mdx +83 -28
  11. package/site/content/docs/elements/store/sql.mdx +2 -2
  12. package/site/content/docs/plugins/anonymous.mdx +1 -1
  13. package/site/content/docs/plugins/cors.mdx +1 -1
  14. package/site/content/docs/plugins/csrf.mdx +1 -1
  15. package/site/content/docs/plugins/headers.mdx +1 -1
  16. package/site/content/docs/plugins/ip-allowlist.mdx +1 -1
  17. package/site/content/docs/plugins/maintenance-mode.mdx +1 -1
  18. package/site/content/docs/reference/errors.mdx +34 -26
  19. package/site/content/docs/reference/fx.mdx +6 -6
  20. package/site/content/docs/reference/okid.mdx +1 -1
  21. package/site/content/docs/reference/plugins.mdx +1 -1
  22. package/site/content/docs/understand/the-architecture.mdx +1 -1
  23. package/src/compiler/extract.test.ts +123 -1
  24. package/src/compiler/extract.ts +134 -7
  25. package/src/compiler/search-writer-isolation.test.ts +0 -1
  26. package/src/console/ui-next/dist/assets/{access-page-C_qLDhTq.js → access-page-BpugjHHY.js} +1 -1
  27. package/src/console/ui-next/dist/assets/{agent-disclosure-BHVqr3TN.js → agent-disclosure-qnsmtJhf.js} +1 -1
  28. package/src/console/ui-next/dist/assets/{cache-glyph-CKe92lRQ.js → cache-glyph-BcpWUM98.js} +1 -1
  29. package/src/console/ui-next/dist/assets/{call-pii-button-DEwTl8ZX.js → call-pii-button-Bz5xiNmR.js} +1 -1
  30. package/src/console/ui-next/dist/assets/{collapsible-BCBtDrCt.js → collapsible-Cuxn2WH8.js} +1 -1
  31. package/src/console/ui-next/dist/assets/{duration-tone-JroqeuCp.js → duration-tone-Bjnl3EaM.js} +1 -1
  32. package/src/console/ui-next/dist/assets/{flows-page-CVHa0RTt.js → flows-page-DVujp-T2.js} +1 -1
  33. package/src/console/ui-next/dist/assets/{highlighted-json-DjJW6hqe.js → highlighted-json-Bql0qlqW.js} +1 -1
  34. package/src/console/ui-next/dist/assets/{http-method-DC5HBdLU.js → http-method-BljvrfRg.js} +1 -1
  35. package/src/console/ui-next/dist/assets/{index-CYjiZ3WO.js → index-D1vE656k.js} +3 -3
  36. package/src/console/ui-next/dist/assets/{observability-page-CAYMyKb3.js → observability-page-Bdi0bLJl.js} +1 -1
  37. package/src/console/ui-next/dist/assets/{replica-lag-yAQYLv75.js → replica-lag-CpkPfITG.js} +1 -1
  38. package/src/console/ui-next/dist/assets/{request-meta-D0yusGxJ.js → request-meta-C4ZVNFVt.js} +1 -1
  39. package/src/console/ui-next/dist/assets/{store-page-BTKJeJ02.js → store-page-DAHtnesC.js} +1 -1
  40. package/src/console/ui-next/dist/assets/{trace-detail-sheet-Bp-Yygs5.js → trace-detail-sheet-B2c9QRBw.js} +1 -1
  41. package/src/console/ui-next/dist/assets/{tree-expand-toggle-DoaVDfAM.js → tree-expand-toggle-BP4tRC98.js} +1 -1
  42. package/src/console/ui-next/dist/assets/{units-page-BRz7xyYL.js → units-page-Oi6--n09.js} +1 -1
  43. package/src/console/ui-next/dist/assets/{vault-page-3jQt-bOJ.js → vault-page-Dt-cPkiU.js} +1 -1
  44. package/src/console/ui-next/dist/index.html +1 -1
  45. package/src/elements/store/live-default.test.ts +8 -0
  46. package/src/elements/store/search-embed-flow.ts +2 -2
  47. package/src/full.ts +3 -0
  48. package/src/http.ts +3 -0
  49. package/src/index.ts +3 -0
  50. package/src/kernel/app.ts +94 -10
  51. package/src/kernel/boot.ts +1 -1
  52. package/src/kernel/cdc-payload.test.ts +224 -0
  53. package/src/kernel/cdc-payload.ts +146 -0
  54. package/src/kernel/errors-flow-name.ts +17 -0
  55. package/src/kernel/errors-once-signal.ts +25 -0
  56. package/src/kernel/errors.registry.test.ts +14 -4
  57. package/src/kernel/flow-name.test.ts +104 -0
  58. package/src/kernel/flow.ts +3 -2
  59. package/src/kernel/fx-emit-types.test.ts +31 -0
  60. package/src/kernel/fx.test.ts +2 -1
  61. package/src/kernel/fx.ts +13 -3
  62. package/src/kernel/index.ts +2 -0
  63. package/src/kernel/on.ts +5 -0
  64. package/src/kernel/once-signal.test.ts +71 -0
  65. package/src/kernel/stamp-http.test.ts +13 -0
  66. package/src/kernel/stamp-http.ts +21 -3
  67. package/src/kernel/unit.ts +4 -2
  68. package/src/kernel-entry.ts +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okengine",
3
- "version": "0.19.1",
3
+ "version": "0.19.3",
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": {
@@ -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,150 @@ 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
+ Both styles stamp the same Manifest `flow.trigger`. The choice is where the
84
+ declaration lives, not two runtimes.
85
+
86
+ What the Flow is called is a separate choice — [Flow name](#flow-name).
87
+
88
+ <Tabs items={["Inline", "Named"]}>
89
+
90
+ <Tab value="Inline">
91
+
92
+ One file — declare and bind together. The scheduler fires it; no other file
93
+ imports the handle:
94
+
95
+ ```typescript title="src/flows/health/ping.ts"
96
+ import { on, flow, clock } from "okengine";
97
+
98
+ export const pingExternal = on(
99
+ clock.every("health.pingExternal", "30s"),
100
+ flow({
101
+ plane: "operator",
102
+ do: async (_, fx) => {
103
+ await fx.call(pingUpstream);
104
+ },
105
+ }),
106
+ );
107
+ ```
108
+
109
+ </Tab>
110
+
111
+ <Tab value="Named">
112
+
113
+ Export the handle when another file (or a second `on()`) must reuse the same
114
+ declaration:
115
+
116
+ ```typescript title="src/clocks/digest.ts"
117
+ import { clock } from "okengine";
118
+
119
+ export const digestClock = clock.every("notes.digest", "1d");
120
+ ```
121
+
122
+ ```typescript title="src/flows/notes/digest.ts"
123
+ import { on, flow } from "okengine";
124
+ import { digestClock } from "@/clocks/digest";
125
+
126
+ export const digest = on(
127
+ digestClock,
128
+ flow("notes.digest", {
129
+ plane: "operator",
130
+ do: async (_, fx) => {
131
+ await fx.call(buildDigest, { at: fx.clock.now() });
132
+ },
133
+ }),
134
+ );
135
+ ```
136
+
137
+ </Tab>
138
+
139
+ </Tabs>
140
+
141
+ ## Flow name
142
+
143
+ | Style | When |
144
+ | ------------------------- | ---------------------------------------------------------------- |
145
+ | `flow({ do })` | No unit folder — Flow name is the Clock name (`metrics.cleanup`) |
146
+ | `flow("ops.sweep")` | Manifest / `fx.call` name must differ from the Clock |
147
+ | Tree `export const sweep` | `src/flows/ops/sweep.ts` stamps `ops.sweep` — overwrites inherit |
148
+
149
+ Explicit `flow("…")` and the file tree overwrite inherit. HTTP does not inherit a
150
+ name from the path — nameless HTTP stays for the tree or fails **OKE1045**. Two
151
+ Flows that land on the same name fail **OKE1070** (`Flow "{flow}" is defined twice.`).
152
+
153
+ <Tabs items={["Inherit", "Explicit", "Tree"]}>
154
+
155
+ <Tab value="Inherit">
156
+
157
+ A file directly in `src/flows/` (no unit folder) has nothing to stamp. The Flow
158
+ is named `metrics.cleanup` — same as the Clock:
159
+
160
+ ```typescript title="src/flows/cleanup.ts"
161
+ import { on, flow, clock } from "okengine";
162
+
163
+ export const cleanupMetrics = on(
164
+ clock.every("metrics.cleanup", "1h"),
165
+ flow({
166
+ plane: "operator",
167
+ do: async (_, fx) => {
168
+ await fx.call(sweepMetrics);
169
+ },
170
+ }),
171
+ );
172
+ ```
173
+
174
+ </Tab>
175
+
176
+ <Tab value="Explicit">
177
+
178
+ The Clock stays `metrics.cleanup`. The Flow is `ops.sweep` — that is the
179
+ Manifest / `fx.call` name:
180
+
181
+ ```typescript title="src/flows/sweep.ts"
182
+ import { on, flow, clock } from "okengine";
183
+
184
+ export const sweep = on(
185
+ clock.every("metrics.cleanup", "1h"),
186
+ flow("ops.sweep", {
187
+ plane: "operator",
188
+ do: async (_, fx) => {
189
+ await fx.call(sweepMetrics);
190
+ },
191
+ }),
192
+ );
193
+ ```
194
+
195
+ </Tab>
196
+
197
+ <Tab value="Tree">
198
+
199
+ Unit folder + `export const` stamps `unit.export`. The Flow is `ops.sweep`, not
200
+ the Clock name `cleanup`:
201
+
202
+ ```typescript title="src/flows/ops/sweep.ts"
203
+ import { on, flow, clock } from "okengine";
204
+
205
+ export const sweep = on(
206
+ clock.every("cleanup", "10m"),
207
+ flow({
208
+ plane: "operator",
209
+ do: async (_, fx) => {
210
+ await fx.call(sweepMetrics);
211
+ },
212
+ }),
213
+ );
214
+ ```
215
+
216
+ </Tab>
217
+
218
+ </Tabs>
219
+
76
220
  ## Progressive Patterns
77
221
 
78
222
  Same `clock` + `fx.clock` from a calendar cron to an interval, a durable pause, and typed offsets:
@@ -114,7 +258,7 @@ Human durations — integer + unit, no weeks: `"200ms"` · `"30s"` · `"5m"` ·
114
258
  ```typescript title="src/flows/health/ping.ts"
115
259
  import { on, flow, clock } from "okengine";
116
260
 
117
- export const pingClock = clock("health.pingExternal", { every: "30s" });
261
+ export const pingClock = clock.every("health.pingExternal", "30s");
118
262
 
119
263
  export const pingExternal = on(
120
264
  pingClock,
@@ -183,14 +327,14 @@ calendar day. Unknown strings parse as `0`.
183
327
 
184
328
  ## Capability Reference
185
329
 
186
- | Surface | Signature | Purpose | `do` input |
187
- | ------------- | -------------------------------------------------------- | ------------------------------- | ---------- |
188
- | Cron schedule | `clock(name, { cron, timezone? })` | Calendar fires in an IANA zone | none (`_`) |
189
- | Helpers | `clock.daily` · `hourly` · `weekly` · `monthly` · `cron` | Same decl; presets + field bags | none (`_`) |
190
- | Interval | `clock(name, { every })` / `clock.every` | Fixed duration loop | none (`_`) |
191
- | Per-tenant | `clock.perTenant(name, opts)` | One Store row per tenant | none (`_`) |
192
- | Now / offsets | `fx.clock.now` · `ago` · `fromNow` · `duration` | Injectable time math | — |
193
- | Durable sleep | `fx.clock.sleep(label, duration)` | Park a durable Flow until wake | — |
330
+ | Surface | Signature | Purpose | `do` input |
331
+ | ------------- | -------------------------------------------------------- | ------------------------------ | ---------- |
332
+ | Helpers | `clock.daily` · `hourly` · `weekly` · `monthly` · `cron` | Calendar presets + field bags | none (`_`) |
333
+ | Interval | `clock.every(name, duration, opts?)` | Fixed duration loop | none (`_`) |
334
+ | Per-tenant | `clock.perTenant(name, opts)` | One Store row per tenant | none (`_`) |
335
+ | Bare callable | `clock(name, { cron? \| every?, … })` | Same decl; lower-level form | none (`_`) |
336
+ | Now / offsets | `fx.clock.now` · `ago` · `fromNow` · `duration` | Injectable time math | — |
337
+ | Durable sleep | `fx.clock.sleep(label, duration)` | Park a durable Flow until wake | — |
194
338
 
195
339
  At least one of `cron` or `every` is required on every declaration.
196
340
 
@@ -269,6 +413,12 @@ export default defineConfig({
269
413
  declaration in source.
270
414
  </Accordion>
271
415
 
416
+ <Accordion title="OKE1070 — flow name defined twice">
417
+ Cause: `Flow "{flow}" is defined twice.` Two nameless consumers inherited the same Clock name, or
418
+ two explicit `flow("…")` calls collide. Give at least one a distinct `flow("…")` or tree export —
419
+ [Flow name](#flow-name).
420
+ </Accordion>
421
+
272
422
  </Accordions>
273
423
 
274
424
  ## Learn more
@@ -278,7 +428,8 @@ export default defineConfig({
278
428
  - [Consumers · Clock Jobs](/docs/elements/flow/consumers#clock-jobs) — bind with `on(clockDecl, flow)`
279
429
  - [Workflows](/docs/elements/flow/workflows) — `durable: true` + `fx.step` around sleeps
280
430
  - [fx](/docs/reference/fx) — full `fx.clock` table
281
- - [Errors](/docs/reference/errors) — `ScheduleNotOverridableError` · `ClockResourceNotFoundError`
431
+ - [Routing](/docs/elements/flow/routing#names) — tree `unit.export` vs inherit
432
+ - [Errors](/docs/reference/errors) — `ScheduleNotOverridableError` · `ClockResourceNotFoundError` · OKE1070
282
433
 
283
434
  ## Next
284
435
 
@@ -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.
@@ -11,15 +11,17 @@ 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
19
19
 
20
- <Steps>
20
+ <Callout title="Bind and emit are independent">
21
+ The consumer and the producer share one Signal handle. They can live in different files, written
22
+ in any order — emit is not "step 2" after bind.
23
+ </Callout>
21
24
 
22
- <Step>
23
25
  ### Bind a Signal consumer
24
26
 
25
27
  ```typescript title="src/flows/notifications/welcome.ts"
@@ -40,9 +42,6 @@ export const sendWelcome = on(
40
42
  );
41
43
  ```
42
44
 
43
- </Step>
44
-
45
- <Step>
46
45
  ### Emit from any Flow
47
46
 
48
47
  ```typescript
@@ -52,10 +51,6 @@ await fx.emit(userSignedUp, { userId: "usr_123", email: "alice@example.com" });
52
51
  The compiler records `emits: ["users.signed-up"]` on the producer. The consumer runs after the
53
52
  emit commits — the HTTP request does not wait for the welcome mail.
54
53
 
55
- </Step>
56
-
57
- </Steps>
58
-
59
54
  <Callout title="Jobs are consumers">
60
55
  A named Clock bound with `on(clockDecl, flow)` is the same species — an asynchronous Flow. There
61
56
  is no separate job runner. See [Clock jobs](#clock-jobs).
@@ -108,7 +103,7 @@ import { on, flow, clock } from "okengine";
108
103
  import { lt } from "drizzle-orm";
109
104
  import { db, metricLogs } from "@/schema";
110
105
 
111
- export const cleanupClock = clock("metrics.cleanup", { every: "1h" });
106
+ export const cleanupClock = clock.every("metrics.cleanup", "1h");
112
107
 
113
108
  export const cleanupMetrics = on(
114
109
  cleanupClock,
@@ -127,7 +122,8 @@ export const cleanupMetrics = on(
127
122
 
128
123
  <Tab value="CDC">
129
124
 
130
- `db.table(handle).changed()` fires on insert, update, and delete. Input is `{ before, after }`:
125
+ `db.table(handle).changed()` fires on insert, update, and delete. Input is `{ before, after }`
126
+ plus `table`, `action`, and `id`:
131
127
 
132
128
  ```typescript title="src/flows/audit/users.ts"
133
129
  import { on, flow } from "okengine";
@@ -137,14 +133,15 @@ import { users, auditLogs } from "@/schema";
137
133
  export const onUserWrite = on(
138
134
  db.table(users).changed(),
139
135
  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
- });
136
+ do: async ({ table, action, id }, fx) => {
137
+ await fx
138
+ .store(db)
139
+ .insert(auditLogs)
140
+ .values({
141
+ table,
142
+ recordId: String(id),
143
+ action,
144
+ });
148
145
  },
149
146
  }),
150
147
  );
@@ -183,16 +180,20 @@ Omit `key` for competing consumers with no ordering.
183
180
 
184
181
  ## Trigger Reference
185
182
 
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 }` |
183
+ | Trigger | Signature | Purpose | `do` input |
184
+ | ---------- | --------------------------------------------------- | ------------------------------------------- | -------------------------------------- |
185
+ | Signal | `on(signalHandle, flow)` or inline `signal.once(…)` | Queue (`once`) or fan-out (`broadcast`) | Payload (`schema`) |
186
+ | Clock | `on(clockDecl, flow)` or inline `clock.every(…)` | Interval or cron tick | none (`_`) |
187
+ | CDC any | `on(db.table(t).changed(), flow)` | Every insert / update / delete | `{ before, after, table, action, id }` |
188
+ | CDC column | `on(db.table(t).changed("col"), flow)` | Same writes; column stamped on the Manifest | `{ before, after, table, action, id }` |
192
189
 
193
190
  `signal.live` is an HTTP SSE tape — bind it with [`http.live`](/docs/elements/flow/http#live-streams),
194
191
  not as a worker. A Flow with no trigger is [call-only](/docs/elements/flow).
195
192
 
193
+ One-file inline vs exported handle: [Signal](/docs/elements/signal#inline-or-named-export) ·
194
+ [Clock](/docs/elements/clock#inline-or-named-export). Flow name (inherit / explicit / tree):
195
+ [Signal](/docs/elements/signal#flow-name) · [Clock](/docs/elements/clock#flow-name). Collision is **OKE1070**.
196
+
196
197
  ## Signal Consumers
197
198
 
198
199
  <Callout title="Detailed section">
@@ -207,8 +208,12 @@ Each emit is handled according to the Signal helper you declared. The Flow is th
207
208
 
208
209
  <Tab value="Once">
209
210
 
210
- Competing workers — exactly one consumer processes each message. Failed attempts retry, then
211
- dead-letter when `deadLetter` is true (default):
211
+ Competing workers — exactly one consumer claims each message. Failed attempts retry, then
212
+ dead-letter when `deadLetter` is true (default).
213
+
214
+ Two different Flows on one `once` signal fail **OKE1071** — see
215
+ [Once · Competing consumers](/docs/elements/signal/once#competing-consumers-once-vs-broadcast).
216
+ For every bound Flow to run, use [`signal.broadcast`](/docs/elements/signal/broadcast).
212
217
 
213
218
  ```typescript title="src/signals/orders.ts"
214
219
  import { signal } from "okengine";
@@ -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
 
@@ -481,19 +489,34 @@ do not fire.
481
489
  ## CDC
482
490
 
483
491
  <Callout title="Detailed section">
484
- If you only need any-write, jump to the example below. The handle is
492
+ If you only need any-write, jump to Bare or enriched below. The handle is
485
493
  `db.table(table).changed(column?)` — `db` is a `store.sql` declaration, `table` is a schema
486
494
  handle. `changed("insert")` is **not** an op filter; it stamps a column named `insert`.
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, table, action, id }` (`CdcPayload`).
499
+
500
+ ### Bare or enriched
501
+
502
+ | Style | When |
503
+ | ------------------------- | ---------------------------------------------------------------------- |
504
+ | `({ before, after })` | Bound to one table — images are enough (search reindex, listing cache) |
505
+ | `({ table, action, id })` | Log, route, or branch — kind of change and which record (audit log) |
491
506
 
492
- <Tabs items={["Any write", "Column", "Images"]}>
507
+ Both styles receive the same object. There is no second dispatch path, no
508
+ performance difference, and no correctness difference — the choice is which
509
+ fields this handler destructures.
493
510
 
494
- <Tab value="Any write">
511
+ `{ table, action, id }` are always populated; omitting them from `do` does not
512
+ drop them from the payload.
495
513
 
496
- Omit the argument to react to every insert, update, and delete on the table:
514
+ <Tabs items={["Bare", "Enriched", "Images"]}>
515
+
516
+ <Tab value="Bare">
517
+
518
+ Bound to `notes` — the table is already in the trigger. Images decide upsert vs
519
+ drop; the row's `id` is on the surviving image:
497
520
 
498
521
  ```typescript title="src/flows/search/reindex.ts"
499
522
  import { on, flow } from "okengine";
@@ -505,13 +528,11 @@ export const reindexNotes = on(
505
528
  flow("search.reindexNotes", {
506
529
  plane: "operator",
507
530
  do: async ({ before, after }, fx) => {
508
- const id = String(after?.id ?? before?.id ?? "");
509
- if (!id) return;
510
531
  if (!after) {
511
- await fx.call(dropNoteIndex, { id });
532
+ await fx.call(dropNoteIndex, { id: String(before?.id ?? "") });
512
533
  return;
513
534
  }
514
- await fx.call(upsertNoteIndex, { id });
535
+ await fx.call(upsertNoteIndex, { id: String(after.id) });
515
536
  },
516
537
  }),
517
538
  );
@@ -519,29 +540,28 @@ export const reindexNotes = on(
519
540
 
520
541
  </Tab>
521
542
 
522
- <Tab value="Column">
543
+ <Tab value="Enriched">
523
544
 
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:
545
+ `table` is the real table name. `action` is `"created"` / `"updated"` / `"deleted"`.
546
+ `id` is the declared primary-key value — not a hardcoded `"id"` column:
526
547
 
527
- ```typescript title="src/flows/tasks/on-status.ts"
548
+ ```typescript title="src/flows/audit/users.ts"
528
549
  import { on, flow } from "okengine";
529
550
  import { db } from "@/core";
530
- import { tasks } from "@/schema";
551
+ import { users, auditLogs } from "@/schema";
531
552
 
532
- export const onStatus = on(
533
- db.table(tasks).changed("status"),
534
- flow("tasks.onStatus", {
535
- plane: "operator",
536
- do: async ({ before, after }, fx) => {
537
- if (before?.status === after?.status) return;
538
- const id = String(after?.id ?? before?.id ?? "");
539
- if (!id) return;
540
- await fx.emit(taskStatusChanged, {
541
- id,
542
- from: before?.status ?? null,
543
- to: after?.status ?? null,
544
- });
553
+ export const onUserWrite = on(
554
+ db.table(users).changed(),
555
+ flow("audit.users", {
556
+ do: async ({ table, action, id }, fx) => {
557
+ await fx
558
+ .store(db)
559
+ .insert(auditLogs)
560
+ .values({
561
+ table,
562
+ recordId: String(id),
563
+ action,
564
+ });
545
565
  },
546
566
  }),
547
567
  );
@@ -551,21 +571,21 @@ export const onStatus = on(
551
571
 
552
572
  <Tab value="Images">
553
573
 
554
- Op is inferred from which image is null:
574
+ Op is inferred from which image is null — the same derivation as `action`:
555
575
 
556
- | Write | `before` | `after` |
557
- | ------ | ------------ | ------- |
558
- | Insert | `null` | new row |
559
- | Update | previous row | new row |
560
- | Delete | previous row | `null` |
576
+ | Write | `before` | `after` | `action` |
577
+ | ------ | ------------ | ------- | ----------- |
578
+ | Insert | `null` | new row | `"created"` |
579
+ | Update | previous row | new row | `"updated"` |
580
+ | Delete | previous row | `null` | `"deleted"` |
561
581
 
562
582
  ```typescript
563
- do: async ({ before, after }, fx) => {
564
- if (!before && after) {
583
+ do: async ({ before, after, action }, fx) => {
584
+ if (action === "created") {
565
585
  /* insert */
566
- } else if (before && after) {
586
+ } else if (action === "updated") {
567
587
  /* update */
568
- } else if (before && !after) {
588
+ } else {
569
589
  /* delete */
570
590
  }
571
591
  };
@@ -575,11 +595,36 @@ do: async ({ before, after }, fx) => {
575
595
 
576
596
  </Tabs>
577
597
 
598
+ `changed("status")` stamps `trigger.cdc.column` on the Manifest. Still the same
599
+ payload — filter in `do` when you only care about that field:
600
+
601
+ ```typescript title="src/flows/tasks/on-status.ts"
602
+ import { on, flow } from "okengine";
603
+ import { db } from "@/core";
604
+ import { tasks } from "@/schema";
605
+
606
+ export const onStatus = on(
607
+ db.table(tasks).changed("status"),
608
+ flow("tasks.onStatus", {
609
+ plane: "operator",
610
+ do: async ({ before, after, id }, fx) => {
611
+ if (before?.status === after?.status) return;
612
+ await fx.emit(taskStatusChanged, {
613
+ id,
614
+ from: before?.status ?? null,
615
+ to: after?.status ?? null,
616
+ });
617
+ },
618
+ }),
619
+ );
620
+ ```
621
+
578
622
  <Accordions>
579
623
 
580
624
  <Accordion title="CDC payload">
581
- Kernel input is `{ before, after }` — not `{ record }`, not `{ op }`. Read the primary key from
582
- `after?.id ?? before?.id`.
625
+ `{ before, after }` are always present. `{ table, action, id }` are always populated — `id` is
626
+ the table's declared primary-key value, not a column assumed to be named `"id"`. There is no
627
+ `record` field and no `{ op }` (that stays on the live-query / outbox path).
583
628
 
584
629
  Writes must go through `fx.store`. A raw SQL client bypasses the sink, so no consumer runs.
585
630
 
@@ -625,7 +670,19 @@ Clock drivers: **postgres** in `dev`/`prod`, **frozen** in `test`. Signal driver
625
670
  <Accordion title="TypeError: on() expected a trigger or signal handle">
626
671
  The first argument must be a Signal handle, a Clock handle, `db.table(…).changed()`, an HTTP
627
672
  trigger, `internal`, or `mcp.tool(…)`. A bare interval string is not a trigger — wrap it in
628
- `clock("name", { every: "1h" })`.
673
+ `clock.every("name", "1h")`.
674
+ </Accordion>
675
+
676
+ <Accordion title="OKE1070 — flow name defined twice">
677
+ Cause: `Flow "{flow}" is defined twice.` Two nameless `flow({ do })` bindings inherited the same
678
+ Signal or Clock name, or two explicit `flow("…")` calls share a name. Give at least one an
679
+ explicit `flow("…")` or a distinct tree export.
680
+ </Accordion>
681
+
682
+ <Accordion title="OKE1071 — once signal bound to more than one Flow">
683
+ Cause: `Once signal "{signal}" is bound to more than one Flow ({flows}).` Use `signal.broadcast`
684
+ if each Flow should independently receive this event, or bind only one Flow. See [Once · Competing
685
+ consumers](/docs/elements/signal/once#competing-consumers-once-vs-broadcast).
629
686
  </Accordion>
630
687
 
631
688
  <Accordion title="OKE1240 — emit with no subscriber">
@@ -644,8 +701,9 @@ Clock drivers: **postgres** in `dev`/`prod`, **frozen** in `test`. Signal driver
644
701
  </Accordion>
645
702
 
646
703
  <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.
704
+ Input is `{ before, after, table, action, id }`. There is no `record` field. `action` is
705
+ `"created"` / `"updated"` / `"deleted"` from which image is null. `{ op }` is live-query /
706
+ outbox only.
649
707
  </Accordion>
650
708
 
651
709
  <Accordion title="Cron fired 24 times after overnight downtime">
@@ -678,7 +736,7 @@ Clock drivers: **postgres** in `dev`/`prod`, **frozen** in `test`. Signal driver
678
736
  - [Store · SQL](/docs/elements/store/sql) — tables CDC watches
679
737
  - [HTTP · Live Streams](/docs/elements/flow/http#live-streams) — `signal.live` SSE
680
738
  - [fx](/docs/reference/fx) — `fx.emit`, `fx.deadLetters`, `fx.clock`
681
- - [Errors](/docs/reference/errors) — OKE1240 · OKE1250
739
+ - [Errors](/docs/reference/errors) — OKE1070 · OKE1071 · OKE1240 · OKE1250
682
740
  - [Workflows](/docs/elements/flow/workflows) — `durable: true` + `fx.step` on a consumer
683
741
 
684
742
  ## Next