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
|
@@ -21,10 +21,12 @@ For developers shipping jobs on okengine — declare the Signal, bind `on(signal
|
|
|
21
21
|
|
|
22
22
|
## Smallest Example
|
|
23
23
|
|
|
24
|
-
<
|
|
24
|
+
<Callout title="One handle, three independent uses">
|
|
25
|
+
`emailTask` is a shared const. Declare it, bind a worker, and emit — different files, any order.
|
|
26
|
+
Binding is not "step 2" after declare; emit is not "step 3".
|
|
27
|
+
</Callout>
|
|
25
28
|
|
|
26
|
-
|
|
27
|
-
### Define the once signal
|
|
29
|
+
### Declare
|
|
28
30
|
|
|
29
31
|
```typescript title="src/signals/email.ts"
|
|
30
32
|
import { signal } from "okengine";
|
|
@@ -37,10 +39,7 @@ export const emailTask = signal.once("tasks.email", {
|
|
|
37
39
|
});
|
|
38
40
|
```
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
<Step>
|
|
43
|
-
### Attach a worker and emit
|
|
42
|
+
### Bind a worker
|
|
44
43
|
|
|
45
44
|
```typescript title="src/flows/workers/email.ts"
|
|
46
45
|
import { on, flow } from "okengine";
|
|
@@ -57,6 +56,8 @@ export const processEmail = on(
|
|
|
57
56
|
);
|
|
58
57
|
```
|
|
59
58
|
|
|
59
|
+
### Emit
|
|
60
|
+
|
|
60
61
|
```typescript
|
|
61
62
|
await fx.emit(emailTask, { to: "alice@example.com", body: "Welcome" });
|
|
62
63
|
```
|
|
@@ -64,14 +65,67 @@ await fx.emit(emailTask, { to: "alice@example.com", body: "Welcome" });
|
|
|
64
65
|
The emit resolves when the outbox commits. The worker runs asynchronously — the producer does not
|
|
65
66
|
wait for `fx.send` to finish.
|
|
66
67
|
|
|
67
|
-
|
|
68
|
+
## Competing consumers (once vs broadcast)
|
|
68
69
|
|
|
69
|
-
|
|
70
|
+
This is the most common mix-up. `signal.once` is a competing-consumer work queue: **exactly one**
|
|
71
|
+
worker claims each message. It is not fan-out.
|
|
70
72
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
Three differently-named Flows bound to the same `once` signal:
|
|
74
|
+
|
|
75
|
+
```typescript title="src/flows/orders/side-effects.ts"
|
|
76
|
+
import { on, flow } from "okengine";
|
|
77
|
+
import { orderPlaced } from "@/signals/orders";
|
|
78
|
+
|
|
79
|
+
export const charge = on(
|
|
80
|
+
orderPlaced,
|
|
81
|
+
flow("orders.charge", {
|
|
82
|
+
do: async ({ orderId }, fx) => {
|
|
83
|
+
await fx.call(chargeOrder, { orderId });
|
|
84
|
+
},
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
export const ship = on(
|
|
89
|
+
orderPlaced,
|
|
90
|
+
flow("orders.ship", {
|
|
91
|
+
do: async ({ orderId }, fx) => {
|
|
92
|
+
await fx.call(shipOrder, { orderId });
|
|
93
|
+
},
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
export const notify = on(
|
|
98
|
+
orderPlaced,
|
|
99
|
+
flow("orders.notify", {
|
|
100
|
+
do: async ({ orderId }, fx) => {
|
|
101
|
+
await fx.call(notifyOrder, { orderId });
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```typescript
|
|
108
|
+
await fx.emit(orderPlaced, { orderId: "ord_99", amount: 150, userId: "usr_1" });
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
| Expectation | Real result |
|
|
112
|
+
| ----------------------------------- | -------------------------------------------------------------------- |
|
|
113
|
+
| All three Flows run | **No.** Exactly one of the three runs |
|
|
114
|
+
| Always `orders.charge` (first `on`) | **No.** The winner is whichever claim lands first — not a fixed Flow |
|
|
115
|
+
| A sticky assignment to one Flow | **No.** There is no owner — only an exclusive claim per message |
|
|
116
|
+
|
|
117
|
+
**Consequence:** if every bound Flow should independently receive its own copy, use
|
|
118
|
+
[`signal.broadcast`](/docs/elements/signal/broadcast). That is the correct fix for this exact
|
|
119
|
+
mistake.
|
|
120
|
+
|
|
121
|
+
Two or more **different** Flow definitions on the same `once` signal fail **OKE1071**.
|
|
122
|
+
|
|
123
|
+
Cause: `Once signal "{signal}" is bound to more than one Flow ({flows}).`
|
|
124
|
+
|
|
125
|
+
Fix: `Use signal.broadcast if each flow should independently receive this event, or bind only one flow if these should compete for the same work.`
|
|
126
|
+
|
|
127
|
+
Load-balancing competing consumers is the **same** Flow on many process replicas — still one
|
|
128
|
+
`on()` in source. That case is not a second Flow definition.
|
|
75
129
|
|
|
76
130
|
## Progressive Patterns
|
|
77
131
|
|
|
@@ -251,12 +305,11 @@ The compiler records `emits: ["tasks.email"]` on producers that call `fx.emit(em
|
|
|
251
305
|
|
|
252
306
|
<Tab value="Competing">
|
|
253
307
|
|
|
254
|
-
Two Flows on the same `once` Signal
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
import { on, flow } from "okengine";
|
|
258
|
-
import { orderPlaced } from "@/signals/orders";
|
|
308
|
+
Two different Flows on the same `once` Signal is the once-vs-broadcast mix-up — see
|
|
309
|
+
[Competing consumers](#competing-consumers-once-vs-broadcast). The bus would let only one claim
|
|
310
|
+
each message (race winner, not both, not a fixed Flow). The Manifest now fails **OKE1071**:
|
|
259
311
|
|
|
312
|
+
```typescript
|
|
260
313
|
export const fulfillA = on(
|
|
261
314
|
orderPlaced,
|
|
262
315
|
flow("orders.fulfillA", {
|
|
@@ -265,11 +318,6 @@ export const fulfillA = on(
|
|
|
265
318
|
},
|
|
266
319
|
}),
|
|
267
320
|
);
|
|
268
|
-
```
|
|
269
|
-
|
|
270
|
-
```typescript title="src/flows/orders/fulfill-b.ts"
|
|
271
|
-
import { on, flow } from "okengine";
|
|
272
|
-
import { orderPlaced } from "@/signals/orders";
|
|
273
321
|
|
|
274
322
|
export const fulfillB = on(
|
|
275
323
|
orderPlaced,
|
|
@@ -281,7 +329,8 @@ export const fulfillB = on(
|
|
|
281
329
|
);
|
|
282
330
|
```
|
|
283
331
|
|
|
284
|
-
**Consequence:**
|
|
332
|
+
**Consequence:** bind one Flow (replicas of that process still compete for claims), or switch the
|
|
333
|
+
declaration to [`signal.broadcast`](/docs/elements/signal/broadcast) so every Flow gets a copy.
|
|
285
334
|
|
|
286
335
|
</Tab>
|
|
287
336
|
|
|
@@ -531,9 +580,15 @@ See [Durable Workflows](/docs/elements/flow/workflows).
|
|
|
531
580
|
</Accordion>
|
|
532
581
|
|
|
533
582
|
<Accordion title="Both of two Flows ran on one once message">
|
|
534
|
-
That is broadcast physics, not once.
|
|
535
|
-
|
|
536
|
-
`signal.broadcast
|
|
583
|
+
That is broadcast physics, not once. Two different Flow definitions on one `once` signal now fail
|
|
584
|
+
**OKE1071** at boot. If you need every Flow to run, switch the declaration to
|
|
585
|
+
[`signal.broadcast`](/docs/elements/signal/broadcast).
|
|
586
|
+
</Accordion>
|
|
587
|
+
|
|
588
|
+
<Accordion title="OKE1071 — once signal bound to more than one Flow">
|
|
589
|
+
Cause: `Once signal "{signal}" is bound to more than one Flow ({flows}).` Use `signal.broadcast`
|
|
590
|
+
if each Flow should independently receive this event, or bind only one Flow. Replicas of one Flow
|
|
591
|
+
are still one `on()` in source.
|
|
537
592
|
</Accordion>
|
|
538
593
|
|
|
539
594
|
<Accordion title="Messages stuck inflight">
|
|
@@ -574,7 +629,7 @@ See [Durable Workflows](/docs/elements/flow/workflows).
|
|
|
574
629
|
- [Consumers](/docs/elements/flow/consumers) — `on(signal)` next to Clock / CDC
|
|
575
630
|
- [Workflows](/docs/elements/flow/workflows) — `durable` + `fx.step` for idempotent side effects
|
|
576
631
|
- [fx](/docs/reference/fx) — `fx.emit`, `fx.deadLetters`
|
|
577
|
-
- [Errors](/docs/reference/errors) — OKE1240 · OKE1250 · OKE1001
|
|
632
|
+
- [Errors](/docs/reference/errors) — OKE1071 · OKE1240 · OKE1250 · OKE1001
|
|
578
633
|
|
|
579
634
|
## Next
|
|
580
635
|
|
|
@@ -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
|
```
|
|
@@ -78,32 +78,34 @@ string. Custom app codes stay message-less until registered. Full catalogs:
|
|
|
78
78
|
|
|
79
79
|
## OKE numeric codes
|
|
80
80
|
|
|
81
|
-
| Code | Name
|
|
82
|
-
| ------ |
|
|
83
|
-
| `1001` | undeclared read
|
|
84
|
-
| `1002` | undeclared write
|
|
85
|
-
| `1003` | undeclared emit
|
|
86
|
-
| `1004` | undeclared send
|
|
87
|
-
| `1005` | undeclared ask
|
|
88
|
-
| `1006` | undeclared secret
|
|
89
|
-
| `1007` | undeclared call
|
|
90
|
-
| `1008` | undeclared fetch
|
|
91
|
-
| `1009` | undeclared embed
|
|
92
|
-
| `1020` | no effects declared
|
|
93
|
-
| `1030` | adopt barrel stale
|
|
94
|
-
| `1040` | HTTP path unresolved
|
|
95
|
-
| `1041` | HTTP route clash
|
|
96
|
-
| `1045` | HTTP flow unnamed
|
|
97
|
-
| `1050` | live exposure dup
|
|
98
|
-
| `1060` | MCP tool duplicate
|
|
99
|
-
| `
|
|
100
|
-
| `
|
|
101
|
-
| `
|
|
102
|
-
| `
|
|
103
|
-
| `
|
|
104
|
-
| `
|
|
105
|
-
| `
|
|
106
|
-
| `
|
|
81
|
+
| Code | Name | Cause | Fix |
|
|
82
|
+
| ------ | ---------------------- | ------------------------------------------------------ | --------------------------------------------------------- |
|
|
83
|
+
| `1001` | undeclared read | Flow reads a resource not in `effects.reads` | Add it to the flow's `effects.reads` |
|
|
84
|
+
| `1002` | undeclared write | Flow writes a resource not in `effects.writes` | Add it to the flow's `effects.writes` |
|
|
85
|
+
| `1003` | undeclared emit | Flow emits a signal not in `effects.emits` | Add it to the flow's `effects.emits` |
|
|
86
|
+
| `1004` | undeclared send | Flow sends a template not in `effects.sends` | Add it to the flow's `effects.sends` |
|
|
87
|
+
| `1005` | undeclared ask | Flow asks a prompt not in `effects.asks` | Add it to the flow's `effects.asks` |
|
|
88
|
+
| `1006` | undeclared secret | Flow reads a secret not in `effects.secrets` | Add it to the flow's `effects.secrets` |
|
|
89
|
+
| `1007` | undeclared call | Flow calls a flow not in `effects.calls` | Add it to the flow's `effects.calls` |
|
|
90
|
+
| `1008` | undeclared fetch | Flow fetches a host not in `effects.fetches` | Add the hostname to the flow's `effects.fetches` |
|
|
91
|
+
| `1009` | undeclared embed | Flow embeds with a model not in `effects.embeds` | Add it to the flow's `effects.embeds` |
|
|
92
|
+
| `1020` | no effects declared | Flow has no `effects` and no Manifest to infer from | Run `oke build` / `oke dev`, or declare effects |
|
|
93
|
+
| `1030` | adopt barrel stale | A `src/flows/<unit>` folder was not adopted | Run `oke dev` or `oke build` to regenerate `generated.ts` |
|
|
94
|
+
| `1040` | HTTP path unresolved | Pathless `http.get()` never received a file-tree stamp | Import `@/flows/generated`, or pass `http.get("/…")` |
|
|
95
|
+
| `1041` | HTTP route clash | Two HTTP flows share the same method + path | Give each flow a unique method + path |
|
|
96
|
+
| `1045` | HTTP flow unnamed | Adopted HTTP flow still has no `unit.export` | Export from `flows/<unit>/` or pass a named `flow` |
|
|
97
|
+
| `1050` | live exposure dup | Same signal, gates, and match on two GET routes | Change the gate or path-param filter |
|
|
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 |
|
|
100
|
+
| `1071` | once-signal multi-flow | Two different Flows bound to the same `signal.once` | Use `signal.broadcast`, or bind only one Flow |
|
|
101
|
+
| `1110` | schema missing | Domain table absent in `prod` — no auto-DDL | Run `oke db migrate` against this environment |
|
|
102
|
+
| `1210` | live resume gap | `Last-Event-ID` is not on the retained tape | Reconnect without the cursor; remaining tape replays |
|
|
103
|
+
| `1240` | orphan emit | Emit with zero subscribers and `optional` false | Add `on(signal, …)` or declare `optional: true` |
|
|
104
|
+
| `1250` | signal schema | Emit payload failed the signal's `schema` | Pass a payload that matches `schema`, or remove it |
|
|
105
|
+
| `1605` | channel schema | Send payload failed the template's `schema` | Fix template `data` payload or the template `schema` |
|
|
106
|
+
| `1810` | tenant required | Tenant-scoped op with no `fx.tenant.id` | `switchTenant`, signed `tid`, or tenant header |
|
|
107
|
+
| `1820` | tenant not member | Client-supplied tenant id is not a membership | Pick from `listTenants` or add the user as a member |
|
|
108
|
+
| `1830` | tenant unknown scope | Tenant role used an invented or `console:*` scope | Use a declared application scope |
|
|
107
109
|
|
|
108
110
|
<Callout title="Effects are usually inferred">
|
|
109
111
|
The 1001–1007 · 1008 · 1009 family exists for flows that declare effects explicitly. Most apps
|
|
@@ -171,6 +173,12 @@ Thrown by specific subsystems — each names its own cause:
|
|
|
171
173
|
A resource mount and a handwritten route share the same method + path. Drop one binding.
|
|
172
174
|
</Accordion>
|
|
173
175
|
|
|
176
|
+
<Accordion title="OKE1071 once signal bound to more than one Flow">
|
|
177
|
+
Cause: `Once signal "{signal}" is bound to more than one Flow ({flows}).` Use `signal.broadcast`
|
|
178
|
+
if each Flow should get a copy, or bind only one Flow. See [Once · Competing
|
|
179
|
+
consumers](/docs/elements/signal/once#competing-consumers-once-vs-broadcast).
|
|
180
|
+
</Accordion>
|
|
181
|
+
|
|
174
182
|
<Accordion title="OKE1110 in production">
|
|
175
183
|
Domain tables missing — prod has no auto-DDL. Run `oke db migrate` against that environment
|
|
176
184
|
([CLI](/docs/reference/cli)).
|
|
@@ -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 |
|
|
@@ -530,7 +530,6 @@ export const live = on(
|
|
|
530
530
|
);
|
|
531
531
|
`;
|
|
532
532
|
const manifest = await extractFromSources({
|
|
533
|
-
"src/schema.decl.ts": source,
|
|
534
533
|
"src/flows/live.ts": source,
|
|
535
534
|
});
|
|
536
535
|
expect(manifest.flows?.["tasks.live"]).toBeDefined();
|
|
@@ -2069,3 +2068,126 @@ export const app = oke({
|
|
|
2069
2068
|
);
|
|
2070
2069
|
});
|
|
2071
2070
|
});
|
|
2071
|
+
|
|
2072
|
+
describe("extractManifest — inline Signal/Clock + name inheritance", () => {
|
|
2073
|
+
test("inline signal.once / clock.every stamp element maps and flow.trigger", async () => {
|
|
2074
|
+
const source = `
|
|
2075
|
+
import { on, flow, signal, clock } from "okengine";
|
|
2076
|
+
|
|
2077
|
+
on(
|
|
2078
|
+
signal.once("link-clicked", { retries: 3, deadLetter: true }),
|
|
2079
|
+
flow({ do: async (input, fx) => {
|
|
2080
|
+
await fx.store("sql:links").set("x", input);
|
|
2081
|
+
} }),
|
|
2082
|
+
);
|
|
2083
|
+
|
|
2084
|
+
on(
|
|
2085
|
+
clock.every("cleanup", "10m"),
|
|
2086
|
+
flow({ do: async (_input, fx) => {
|
|
2087
|
+
await fx.store("sql:links").set("y", 1);
|
|
2088
|
+
} }),
|
|
2089
|
+
);
|
|
2090
|
+
`;
|
|
2091
|
+
const manifest = await extractFromSources({ "inline.ts": source });
|
|
2092
|
+
expect(manifest.signals?.["link-clicked"]).toMatchObject({
|
|
2093
|
+
delivery: "once",
|
|
2094
|
+
retries: 3,
|
|
2095
|
+
deadLetter: true,
|
|
2096
|
+
});
|
|
2097
|
+
expect(manifest.clocks?.cleanup).toMatchObject({ every: "10m" });
|
|
2098
|
+
expect(manifest.flows?.["link-clicked"]?.trigger).toEqual({ signal: "link-clicked" });
|
|
2099
|
+
expect(manifest.flows?.cleanup?.trigger).toEqual({ every: "10m" });
|
|
2100
|
+
});
|
|
2101
|
+
|
|
2102
|
+
test("nameless flow inherits the named trigger; explicit flow name wins", async () => {
|
|
2103
|
+
const source = `
|
|
2104
|
+
import { on, flow, signal, clock } from "okengine";
|
|
2105
|
+
|
|
2106
|
+
export const orderPlaced = signal.once("order-placed");
|
|
2107
|
+
on(orderPlaced, flow({ do: () => ({ ok: true }) }));
|
|
2108
|
+
|
|
2109
|
+
on(
|
|
2110
|
+
clock.every("metrics.cleanup", "1h"),
|
|
2111
|
+
flow("ops.sweep", { do: () => ({ ok: true }) }),
|
|
2112
|
+
);
|
|
2113
|
+
`;
|
|
2114
|
+
const manifest = await extractFromSources({ "workers.ts": source });
|
|
2115
|
+
expect(manifest.flows?.["order-placed"]?.trigger).toEqual({ signal: "order-placed" });
|
|
2116
|
+
expect(manifest.flows?.["ops.sweep"]?.trigger).toEqual({ every: "1h" });
|
|
2117
|
+
expect(manifest.flows?.["metrics.cleanup"]).toBeUndefined();
|
|
2118
|
+
});
|
|
2119
|
+
|
|
2120
|
+
test("file-tree unit.export wins over trigger-name inheritance", async () => {
|
|
2121
|
+
const source = `
|
|
2122
|
+
import { on, flow, signal } from "okengine";
|
|
2123
|
+
export const onCreated = on(
|
|
2124
|
+
signal.once("note-created"),
|
|
2125
|
+
flow({ do: () => ({ ok: true }) }),
|
|
2126
|
+
);
|
|
2127
|
+
`;
|
|
2128
|
+
const manifest = await extractFromSources({
|
|
2129
|
+
"src/flows/notes/on-created.ts": source,
|
|
2130
|
+
});
|
|
2131
|
+
expect(manifest.flows?.["notes.onCreated"]?.trigger).toEqual({ signal: "note-created" });
|
|
2132
|
+
expect(manifest.flows?.["note-created"]).toBeUndefined();
|
|
2133
|
+
});
|
|
2134
|
+
|
|
2135
|
+
test("two nameless inheritances of the same trigger name fail extract", async () => {
|
|
2136
|
+
const source = `
|
|
2137
|
+
import { on, flow, signal } from "okengine";
|
|
2138
|
+
const ping = signal.once("health.ping");
|
|
2139
|
+
on(ping, flow({ do: () => ({ a: true }) }));
|
|
2140
|
+
on(ping, flow({ do: () => ({ b: true }) }));
|
|
2141
|
+
`;
|
|
2142
|
+
await expect(extractFromSources({ "dup.ts": source })).rejects.toThrow(
|
|
2143
|
+
/duplicate flow name "health.ping"/,
|
|
2144
|
+
);
|
|
2145
|
+
});
|
|
2146
|
+
});
|
|
2147
|
+
|
|
2148
|
+
describe("extractManifest — once-signal uniqueness OKE1071", () => {
|
|
2149
|
+
test("two differently-named flows on the same once signal fail OKE1071", async () => {
|
|
2150
|
+
const source = `
|
|
2151
|
+
import { on, flow, signal } from "okengine";
|
|
2152
|
+
export const orderPlaced = signal.once("orders.placed");
|
|
2153
|
+
on(orderPlaced, flow("orders.charge", { do: () => ({ a: true }) }));
|
|
2154
|
+
on(orderPlaced, flow("orders.ship", { do: () => ({ b: true }) }));
|
|
2155
|
+
`;
|
|
2156
|
+
try {
|
|
2157
|
+
await extractFromSources({ "dup-once.ts": source });
|
|
2158
|
+
expect.unreachable("extract should throw OKE1071");
|
|
2159
|
+
} catch (err) {
|
|
2160
|
+
expect(err).toBeInstanceOf(Error);
|
|
2161
|
+
const message = (err as Error).message;
|
|
2162
|
+
expect(message).toMatch(/OKE1071/);
|
|
2163
|
+
expect(message).toContain("orders.placed");
|
|
2164
|
+
expect(message).toContain("orders.charge");
|
|
2165
|
+
expect(message).toContain("orders.ship");
|
|
2166
|
+
expect(message).toMatch(/signal\.broadcast/);
|
|
2167
|
+
}
|
|
2168
|
+
});
|
|
2169
|
+
|
|
2170
|
+
test("the same scenario with signal.broadcast does not fail", async () => {
|
|
2171
|
+
const source = `
|
|
2172
|
+
import { on, flow, signal } from "okengine";
|
|
2173
|
+
export const catalogChanged = signal.broadcast("catalog.changed");
|
|
2174
|
+
on(catalogChanged, flow("cache.invalidate", { do: () => ({ a: true }) }));
|
|
2175
|
+
on(catalogChanged, flow("search.reindex", { do: () => ({ b: true }) }));
|
|
2176
|
+
`;
|
|
2177
|
+
const manifest = await extractFromSources({ "fanout.ts": source });
|
|
2178
|
+
expect(manifest.signals?.["catalog.changed"]?.delivery).toBe("broadcast");
|
|
2179
|
+
expect(manifest.flows?.["cache.invalidate"]?.trigger).toEqual({ signal: "catalog.changed" });
|
|
2180
|
+
expect(manifest.flows?.["search.reindex"]?.trigger).toEqual({ signal: "catalog.changed" });
|
|
2181
|
+
});
|
|
2182
|
+
|
|
2183
|
+
test("a single flow bound to once extracts cleanly", async () => {
|
|
2184
|
+
const source = `
|
|
2185
|
+
import { on, flow, signal } from "okengine";
|
|
2186
|
+
export const emailTask = signal.once("tasks.email");
|
|
2187
|
+
on(emailTask, flow("workers.email", { do: () => ({ ok: true }) }));
|
|
2188
|
+
`;
|
|
2189
|
+
const manifest = await extractFromSources({ "once.ts": source });
|
|
2190
|
+
expect(manifest.signals?.["tasks.email"]?.delivery).toBe("once");
|
|
2191
|
+
expect(manifest.flows?.["workers.email"]?.trigger).toEqual({ signal: "tasks.email" });
|
|
2192
|
+
});
|
|
2193
|
+
});
|