okengine 0.19.1 → 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.
- package/package.json +1 -1
- package/site/content/docs/elements/clock/index.mdx +20 -10
- package/site/content/docs/elements/clock/schedules.mdx +6 -3
- package/site/content/docs/elements/flow/consumers.mdx +96 -58
- package/site/content/docs/elements/flow/index.mdx +12 -12
- package/site/content/docs/elements/flow/routing.mdx +10 -1
- package/site/content/docs/elements/signal/index.mdx +13 -0
- 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 +1 -0
- 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 +76 -1
- package/src/compiler/extract.ts +106 -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-DceEWH9u.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-BHVqr3TN.js → agent-disclosure-tC9s2VFd.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-CKe92lRQ.js → cache-glyph-C-naNQSR.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-DEwTl8ZX.js → call-pii-button-DnZ_MlDn.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-BCBtDrCt.js → collapsible-RekgR6Qz.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-JroqeuCp.js → duration-tone-DugtWBS0.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-CVHa0RTt.js → flows-page-DVmn1ZuQ.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-DjJW6hqe.js → highlighted-json-CvBGaiSD.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-DC5HBdLU.js → http-method-D7_OXbdC.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-CYjiZ3WO.js → index-DH0K2f6N.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CAYMyKb3.js → observability-page-qJzF2nSN.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-yAQYLv75.js → replica-lag-Vqk0pUBA.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-D0yusGxJ.js → request-meta-BtShi4sG.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-BTKJeJ02.js → store-page-CMsYH_vH.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-Bp-Yygs5.js → trace-detail-sheet-ycFB2uua.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-DoaVDfAM.js → tree-expand-toggle-iG1jcWgU.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-BRz7xyYL.js → units-page-Bavchke4.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-3jQt-bOJ.js → vault-page-DxUFhiZI.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 +30 -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.registry.test.ts +6 -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/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.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": {
|
|
@@ -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,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",
|
|
127
|
+
export const pingClock = clock.every("health.pingExternal", "30s");
|
|
118
128
|
|
|
119
129
|
export const pingExternal = on(
|
|
120
130
|
pingClock,
|
|
@@ -183,14 +193,14 @@ calendar day. Unknown strings parse as `0`.
|
|
|
183
193
|
|
|
184
194
|
## Capability Reference
|
|
185
195
|
|
|
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
|
|
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 | — |
|
|
194
204
|
|
|
195
205
|
At least one of `cron` or `every` is required on every declaration.
|
|
196
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
|
-
|
|
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,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
|
|
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",
|
|
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 ({
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
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)`
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
530
|
+
</Tab>
|
|
493
531
|
|
|
494
|
-
<Tab value="
|
|
532
|
+
<Tab value="Bare">
|
|
495
533
|
|
|
496
|
-
Omit
|
|
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
|
-
|
|
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 (
|
|
593
|
+
do: async ({ before, after, action }, fx) => {
|
|
594
|
+
if (action === "created") {
|
|
565
595
|
/* insert */
|
|
566
|
-
} else if (
|
|
596
|
+
} else if (action === "updated") {
|
|
567
597
|
/* update */
|
|
568
|
-
} else
|
|
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
|
-
|
|
582
|
-
`
|
|
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",
|
|
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.
|
|
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()` | — |
|
|
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
|
-
`
|
|
529
|
-
|
|
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">
|
|
@@ -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,7 +543,7 @@ 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
|
|
546
|
+
- [Errors](/docs/reference/errors) — OKE1040 · OKE1030 · OKE1041 · OKE1045 · OKE1070
|
|
538
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
|
|
|
@@ -80,6 +80,19 @@ for the worker.
|
|
|
80
80
|
competing consumer.
|
|
81
81
|
</Callout>
|
|
82
82
|
|
|
83
|
+
## Inline or named export
|
|
84
|
+
|
|
85
|
+
| Style | When |
|
|
86
|
+
| --------------------------------------------- | --------------------------------------------------------------------------- |
|
|
87
|
+
| `on(signal.once("name", opts), flow({ do }))` | Self-contained — nothing else emits to this Signal |
|
|
88
|
+
| `export const x = signal.once<Payload>(…)` | Another file needs `fx.emit(x, payload)` with compile-time payload checking |
|
|
89
|
+
|
|
90
|
+
A string `fx.emit("name", payload)` still runs (runtime `schema` still applies) but does not
|
|
91
|
+
type-check the payload.
|
|
92
|
+
|
|
93
|
+
Nameless `flow({ do })` inherits the Signal name; file-tree `unit.export` and explicit
|
|
94
|
+
`flow("…")` win. Two nameless inheritances of the same name fail **OKE1070**.
|
|
95
|
+
|
|
83
96
|
## Progressive Patterns
|
|
84
97
|
|
|
85
98
|
Same helpers + `fx.emit` from a queue job to a fan-out and a browser feed:
|
|
@@ -807,8 +807,8 @@ Default is insert-once; pass `{ onExisting: "update" }` to overwrite.
|
|
|
807
807
|
</Callout>
|
|
808
808
|
|
|
809
809
|
`db.table(orders).changed(column?)` builds a CDC trigger for `on(…)`. Input is
|
|
810
|
-
|
|
811
|
-
Manifest — it is not an op filter.
|
|
810
|
+
`{ before, after }` plus `table` / `action` / `id`. `changed("status")` stamps a
|
|
811
|
+
**column** name on the Manifest — it is not an op filter.
|
|
812
812
|
|
|
813
813
|
```typescript title="src/flows/orders/on-status.ts"
|
|
814
814
|
import { on, flow } from "okengine";
|
|
@@ -38,7 +38,7 @@ export const app = oke({
|
|
|
38
38
|
|
|
39
39
|
```typescript
|
|
40
40
|
const { data } = await api.auth.signInAnonymous();
|
|
41
|
-
// data.userId is a fresh
|
|
41
|
+
// data.userId is a fresh OKID; store tokens like any other session
|
|
42
42
|
```
|
|
43
43
|
|
|
44
44
|
`POST /auth/sign-in/anonymous` — no body.
|
|
@@ -71,7 +71,7 @@ const origins = configSource({
|
|
|
71
71
|
db: { store: db },
|
|
72
72
|
kv: cache,
|
|
73
73
|
});
|
|
74
|
-
const corsSyncClock = clock("cors.sync",
|
|
74
|
+
const corsSyncClock = clock.every("cors.sync", "30s");
|
|
75
75
|
on(corsSyncClock, origins.sync());
|
|
76
76
|
export const app = oke({ name: "shop", env: "dev" }).plug(cors(origins));
|
|
77
77
|
```
|
|
@@ -75,7 +75,7 @@ const rules = configSource({
|
|
|
75
75
|
db: { store: db },
|
|
76
76
|
kv: cache,
|
|
77
77
|
});
|
|
78
|
-
const csrfSyncClock = clock("csrf.sync",
|
|
78
|
+
const csrfSyncClock = clock.every("csrf.sync", "30s");
|
|
79
79
|
on(csrfSyncClock, rules.sync());
|
|
80
80
|
export const app = oke({ name: "shop", env: "dev" }).plug(csrf(rules));
|
|
81
81
|
```
|
|
@@ -103,7 +103,7 @@ const headerConfig = configSource({
|
|
|
103
103
|
db: { store: db },
|
|
104
104
|
kv: cache,
|
|
105
105
|
});
|
|
106
|
-
const headerSyncClock = clock("headers.sync",
|
|
106
|
+
const headerSyncClock = clock.every("headers.sync", "30s");
|
|
107
107
|
on(headerSyncClock, headerConfig.sync());
|
|
108
108
|
export const app = oke({ name: "shop", env: "dev" }).plug(headers(headerConfig));
|
|
109
109
|
```
|
|
@@ -69,7 +69,7 @@ const rules = configSource({
|
|
|
69
69
|
db: { store: db },
|
|
70
70
|
kv: cache,
|
|
71
71
|
});
|
|
72
|
-
const ipRulesSyncClock = clock("ip-allowlist.sync",
|
|
72
|
+
const ipRulesSyncClock = clock.every("ip-allowlist.sync", "30s");
|
|
73
73
|
on(ipRulesSyncClock, rules.sync());
|
|
74
74
|
export const app = oke({ name: "shop", env: "dev" }).plug(ipAllowlist(rules));
|
|
75
75
|
```
|
|
@@ -78,7 +78,7 @@ const maintenance = configSource({
|
|
|
78
78
|
db: { store: db },
|
|
79
79
|
kv: cache,
|
|
80
80
|
});
|
|
81
|
-
const maintenanceSyncClock = clock("maintenance.sync",
|
|
81
|
+
const maintenanceSyncClock = clock.every("maintenance.sync", "30s");
|
|
82
82
|
on(maintenanceSyncClock, maintenance.sync());
|
|
83
83
|
export const app = oke({ name: "shop", env: "dev" }).plug(maintenanceMode(maintenance));
|
|
84
84
|
```
|
|
@@ -96,6 +96,7 @@ string. Custom app codes stay message-less until registered. Full catalogs:
|
|
|
96
96
|
| `1045` | HTTP flow unnamed | Adopted HTTP flow still has no `unit.export` | Export from `flows/<unit>/` or pass a named `flow` |
|
|
97
97
|
| `1050` | live exposure dup | Same signal, gates, and match on two GET routes | Change the gate or path-param filter |
|
|
98
98
|
| `1060` | MCP tool duplicate | Two MCP tool bindings share the same tool name | Give each MCP tool exposure a unique name |
|
|
99
|
+
| `1070` | flow name duplicate | Two Flows share the same Manifest / `fx.call` name | Give at least one an explicit `flow("…")` or tree export |
|
|
99
100
|
| `1110` | schema missing | Domain table absent in `prod` — no auto-DDL | Run `oke db migrate` against this environment |
|
|
100
101
|
| `1210` | live resume gap | `Last-Event-ID` is not on the retained tape | Reconnect without the cursor; remaining tape replays |
|
|
101
102
|
| `1240` | orphan emit | Emit with zero subscribers and `optional` false | Add `on(signal, …)` or declare `optional: true` |
|
|
@@ -90,11 +90,11 @@ See [Store](/docs/elements/store) for the query-builder surface.
|
|
|
90
90
|
|
|
91
91
|
## Signals
|
|
92
92
|
|
|
93
|
-
| Signature | Records | Notes
|
|
94
|
-
| ------------------------------------- | ---------------------- |
|
|
95
|
-
| `fx.emit(signal, payload?, { key? })` | `emit` | Commits the signal outbox when the call resolves; optional `key` serializes `once` per key; stamps producer run id as `parentRunId` for trace chains; throws **OKE1240** (orphan) or **OKE1250** (schema) |
|
|
96
|
-
| `fx.deadLetters(signal)` | `read` `signal:<name>` | Dead-lettered messages for that signal. Payload typed from `SignalDecl<T>`. Page with `fx.json.withQuery`. Cross-signal throws **OKE1001**.
|
|
97
|
-
| `fx.live(signal, { match? })` | `read` `signal:<name>` | Live tape as SSE. Returns `JsonStreamResult` (object chunks, `id:` on the wire). Cross-signal throws **OKE1001**. Do not wrap with `fx.json.stream`.
|
|
93
|
+
| Signature | Records | Notes |
|
|
94
|
+
| ------------------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
95
|
+
| `fx.emit(signal, payload?, { key? })` | `emit` | Pass a `SignalDecl<T>` handle to type-check `payload`; a string name stays `unknown` (runtime `schema` still applies). Commits the signal outbox when the call resolves; optional `key` serializes `once` per key; stamps producer run id as `parentRunId` for trace chains; throws **OKE1240** (orphan) or **OKE1250** (schema) |
|
|
96
|
+
| `fx.deadLetters(signal)` | `read` `signal:<name>` | Dead-lettered messages for that signal. Payload typed from `SignalDecl<T>`. Page with `fx.json.withQuery`. Cross-signal throws **OKE1001**. |
|
|
97
|
+
| `fx.live(signal, { match? })` | `read` `signal:<name>` | Live tape as SSE. Returns `JsonStreamResult` (object chunks, `id:` on the wire). Cross-signal throws **OKE1001**. Do not wrap with `fx.json.stream`. |
|
|
98
98
|
|
|
99
99
|
## Runs (observability read)
|
|
100
100
|
|
|
@@ -108,7 +108,7 @@ Declare `effects: { reads: ["runs"] }`. Powers native SLO checkers (Clock + Chan
|
|
|
108
108
|
| `fx.runs.checkSlo(flow, slo, windowMs?)` | `read` `runs` | Availability / latency breaches |
|
|
109
109
|
|
|
110
110
|
```typescript
|
|
111
|
-
const sloCheckClock = clock("ops.slo-check",
|
|
111
|
+
const sloCheckClock = clock.every("ops.slo-check", "5m");
|
|
112
112
|
|
|
113
113
|
on(
|
|
114
114
|
sloCheckClock,
|
|
@@ -150,7 +150,7 @@ The default alphabet order is not lexicographic; `_` sorts between uppercase and
|
|
|
150
150
|
## Learn more
|
|
151
151
|
|
|
152
152
|
- [Store](/docs/elements/store) — `defaultFn(id)` in table declarations delegates to `okid()`
|
|
153
|
-
- [fx](/docs/reference/fx) — `fx.id()`
|
|
153
|
+
- [fx](/docs/reference/fx) — `fx.id()` returns an OKID; options live here
|
|
154
154
|
- [Clock](/docs/elements/clock) — process `instanceId` (`inst-<okid>`) is an OKID
|
|
155
155
|
|
|
156
156
|
## Next
|
|
@@ -146,7 +146,7 @@ const maintenance = configSource({
|
|
|
146
146
|
kv: cache, // read-through cache (optional)
|
|
147
147
|
});
|
|
148
148
|
|
|
149
|
-
const maintenanceSyncClock = clock("maintenance.sync",
|
|
149
|
+
const maintenanceSyncClock = clock.every("maintenance.sync", "30s");
|
|
150
150
|
on(maintenanceSyncClock, maintenance.sync()); // one clock flow refreshes the box
|
|
151
151
|
|
|
152
152
|
export const app = oke({ name: "shop", env: "dev" }).plug(maintenanceMode(maintenance));
|
|
@@ -239,7 +239,7 @@ None of that required new code beyond what's above. It required the four lines t
|
|
|
239
239
|
| Trigger | Element | Starts When |
|
|
240
240
|
| --------------------------------------------------- | ------- | -------------------------------- |
|
|
241
241
|
| `http.post()` (path from file tree) | Flow | A request arrives |
|
|
242
|
-
| `clock("name",
|
|
242
|
+
| `clock.every("name", "10m")` | Clock | A time interval elapses |
|
|
243
243
|
| `signal.once("name", {…})` / `.broadcast` / `.live` | Signal | Another flow announces something |
|
|
244
244
|
| `db.table(users).changed("email")` | Store | A database row changes |
|
|
245
245
|
| `mcp.tool("name")` | AI | An AI agent calls it |
|