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.
- package/package.json +1 -1
- package/site/content/docs/elements/clock/index.mdx +162 -11
- package/site/content/docs/elements/clock/schedules.mdx +6 -3
- package/site/content/docs/elements/flow/consumers.mdx +137 -79
- package/site/content/docs/elements/flow/index.mdx +14 -14
- package/site/content/docs/elements/flow/routing.mdx +18 -2
- package/site/content/docs/elements/signal/broadcast.mdx +9 -13
- package/site/content/docs/elements/signal/index.mdx +245 -13
- package/site/content/docs/elements/signal/live.mdx +5 -13
- package/site/content/docs/elements/signal/once.mdx +83 -28
- package/site/content/docs/elements/store/sql.mdx +2 -2
- package/site/content/docs/plugins/anonymous.mdx +1 -1
- package/site/content/docs/plugins/cors.mdx +1 -1
- package/site/content/docs/plugins/csrf.mdx +1 -1
- package/site/content/docs/plugins/headers.mdx +1 -1
- package/site/content/docs/plugins/ip-allowlist.mdx +1 -1
- package/site/content/docs/plugins/maintenance-mode.mdx +1 -1
- package/site/content/docs/reference/errors.mdx +34 -26
- package/site/content/docs/reference/fx.mdx +6 -6
- package/site/content/docs/reference/okid.mdx +1 -1
- package/site/content/docs/reference/plugins.mdx +1 -1
- package/site/content/docs/understand/the-architecture.mdx +1 -1
- package/src/compiler/extract.test.ts +123 -1
- package/src/compiler/extract.ts +134 -7
- package/src/compiler/search-writer-isolation.test.ts +0 -1
- package/src/console/ui-next/dist/assets/{access-page-C_qLDhTq.js → access-page-BpugjHHY.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-BHVqr3TN.js → agent-disclosure-qnsmtJhf.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-CKe92lRQ.js → cache-glyph-BcpWUM98.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-DEwTl8ZX.js → call-pii-button-Bz5xiNmR.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-BCBtDrCt.js → collapsible-Cuxn2WH8.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-JroqeuCp.js → duration-tone-Bjnl3EaM.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-CVHa0RTt.js → flows-page-DVujp-T2.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-DjJW6hqe.js → highlighted-json-Bql0qlqW.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-DC5HBdLU.js → http-method-BljvrfRg.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-CYjiZ3WO.js → index-D1vE656k.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CAYMyKb3.js → observability-page-Bdi0bLJl.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-yAQYLv75.js → replica-lag-CpkPfITG.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-D0yusGxJ.js → request-meta-C4ZVNFVt.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-BTKJeJ02.js → store-page-DAHtnesC.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-Bp-Yygs5.js → trace-detail-sheet-B2c9QRBw.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-DoaVDfAM.js → tree-expand-toggle-BP4tRC98.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-BRz7xyYL.js → units-page-Oi6--n09.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-3jQt-bOJ.js → vault-page-Dt-cPkiU.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/elements/store/live-default.test.ts +8 -0
- package/src/elements/store/search-embed-flow.ts +2 -2
- package/src/full.ts +3 -0
- package/src/http.ts +3 -0
- package/src/index.ts +3 -0
- package/src/kernel/app.ts +94 -10
- package/src/kernel/boot.ts +1 -1
- package/src/kernel/cdc-payload.test.ts +224 -0
- package/src/kernel/cdc-payload.ts +146 -0
- package/src/kernel/errors-flow-name.ts +17 -0
- package/src/kernel/errors-once-signal.ts +25 -0
- package/src/kernel/errors.registry.test.ts +14 -4
- package/src/kernel/flow-name.test.ts +104 -0
- package/src/kernel/flow.ts +3 -2
- package/src/kernel/fx-emit-types.test.ts +31 -0
- package/src/kernel/fx.test.ts +2 -1
- package/src/kernel/fx.ts +13 -3
- package/src/kernel/index.ts +2 -0
- package/src/kernel/on.ts +5 -0
- package/src/kernel/once-signal.test.ts +71 -0
- package/src/kernel/stamp-http.test.ts +13 -0
- package/src/kernel/stamp-http.ts +21 -3
- package/src/kernel/unit.ts +4 -2
- 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.
|
|
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",
|
|
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",
|
|
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
|
|
187
|
-
| ------------- | -------------------------------------------------------- |
|
|
188
|
-
|
|
|
189
|
-
|
|
|
190
|
-
|
|
|
191
|
-
|
|
|
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
|
-
- [
|
|
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
|
-
|
|
103
|
-
|
|
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
|
|
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
|
|
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
|
-
<
|
|
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",
|
|
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 ({
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
187
|
-
| ---------- |
|
|
188
|
-
| Signal | `on(signalHandle, flow)`
|
|
189
|
-
| Clock | `on(clockDecl, flow)`
|
|
190
|
-
| CDC any | `on(db.table(t).changed(), flow)`
|
|
191
|
-
| CDC column | `on(db.table(t).changed("col"), flow)`
|
|
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
|
|
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
|
-
|
|
346
|
-
—
|
|
347
|
-
`
|
|
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",
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
511
|
+
`{ table, action, id }` are always populated; omitting them from `do` does not
|
|
512
|
+
drop them from the payload.
|
|
495
513
|
|
|
496
|
-
|
|
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="
|
|
543
|
+
<Tab value="Enriched">
|
|
523
544
|
|
|
524
|
-
`
|
|
525
|
-
|
|
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/
|
|
548
|
+
```typescript title="src/flows/audit/users.ts"
|
|
528
549
|
import { on, flow } from "okengine";
|
|
529
550
|
import { db } from "@/core";
|
|
530
|
-
import {
|
|
551
|
+
import { users, auditLogs } from "@/schema";
|
|
531
552
|
|
|
532
|
-
export const
|
|
533
|
-
db.table(
|
|
534
|
-
flow("
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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 (
|
|
583
|
+
do: async ({ before, after, action }, fx) => {
|
|
584
|
+
if (action === "created") {
|
|
565
585
|
/* insert */
|
|
566
|
-
} else if (
|
|
586
|
+
} else if (action === "updated") {
|
|
567
587
|
/* update */
|
|
568
|
-
} else
|
|
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
|
-
|
|
582
|
-
`
|
|
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",
|
|
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.
|
|
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
|