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
|
@@ -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
|
|
|
@@ -345,8 +345,8 @@ A bare `404` with body `Not Found` means **no route matched** — not `fx.fail("
|
|
|
345
345
|
|
|
346
346
|
<Accordion title="Name stamping">
|
|
347
347
|
Prefer nameless `flow({ do })` on tree files — the file stamps `unit.export`.
|
|
348
|
-
|
|
349
|
-
|
|
348
|
+
Signal / Clock inherit the trigger name only outside a unit folder
|
|
349
|
+
([Signal · Flow name](/docs/elements/signal#flow-name)). Nameless HTTP after adopt fails **OKE1045**.
|
|
350
350
|
</Accordion>
|
|
351
351
|
|
|
352
352
|
</Accordions>
|
|
@@ -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,7 +455,18 @@ 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
|
-
|
|
458
|
+
Nameless Signal / Clock consumers take the trigger name only when the tree does
|
|
459
|
+
not stamp `unit.export`:
|
|
460
|
+
|
|
461
|
+
| Style | Flow name |
|
|
462
|
+
| ------------------------------- | ----------------------- |
|
|
463
|
+
| `flow({ do })` (no unit folder) | the Signal / Clock name |
|
|
464
|
+
| `flow("orders.fulfill")` | the string you passed |
|
|
465
|
+
| `src/flows/notes/on-created.ts` | `notes.onCreated` |
|
|
466
|
+
|
|
467
|
+
Worked examples: [Signal · Flow name](/docs/elements/signal#flow-name) ·
|
|
468
|
+
[Clock · Flow name](/docs/elements/clock#flow-name). Collision fails **OKE1070**.
|
|
469
|
+
HTTP has no trigger name of this kind — nameless HTTP stays for the tree or **OKE1045**.
|
|
459
470
|
|
|
460
471
|
## Runtime Matching
|
|
461
472
|
|
|
@@ -512,6 +523,11 @@ plus a handwritten `http.get("/notes")`.
|
|
|
512
523
|
file so the tree can stamp `unit.export`. `export default` is not picked up.
|
|
513
524
|
</Accordion>
|
|
514
525
|
|
|
526
|
+
<Accordion title="OKE1070 — flow name defined twice">
|
|
527
|
+
Cause: `Flow "{flow}" is defined twice.` Two nameless Signal/Clock consumers inherited the same
|
|
528
|
+
name, or two explicit `flow("…")` calls collide. Give at least one a distinct name or tree export.
|
|
529
|
+
</Accordion>
|
|
530
|
+
|
|
515
531
|
<Accordion title="422 — path param missing from in">
|
|
516
532
|
`[id]` stamps `:id`. `in` must declare `id` (same key). A schema that expects `userId` while the
|
|
517
533
|
path is `:id` fails validation before `do`.
|
|
@@ -534,7 +550,7 @@ plus a handwritten `http.get("/notes")`.
|
|
|
534
550
|
|
|
535
551
|
- [HTTP](/docs/elements/flow/http) — verbs, envelopes, `http.resource`, live SSE
|
|
536
552
|
- [Client](/docs/client/calling) — `api.notes.get`, REST vs RPC, `$routes`
|
|
537
|
-
- [Errors](/docs/reference/errors) — OKE1040 · OKE1030 · OKE1041 · OKE1045
|
|
553
|
+
- [Errors](/docs/reference/errors) — OKE1040 · OKE1030 · OKE1041 · OKE1045 · OKE1070
|
|
538
554
|
- [The Architecture](/docs/understand/the-architecture) — derived routes, no hand-written table
|
|
539
555
|
- [Gate](/docs/elements/gate) — `.gate(...)` / `.public()` on the same trigger
|
|
540
556
|
|
|
@@ -21,10 +21,12 @@ more `on(signal, flow)` subscribers, emit with `fx.emit`.
|
|
|
21
21
|
|
|
22
22
|
## Smallest Example
|
|
23
23
|
|
|
24
|
-
<
|
|
24
|
+
<Callout title="One handle, three independent uses">
|
|
25
|
+
`cacheInvalidated` is a shared const. Declare it, bind any number of subscribers, and emit —
|
|
26
|
+
different files, any order. Bind and emit do not have a required sequence.
|
|
27
|
+
</Callout>
|
|
25
28
|
|
|
26
|
-
|
|
27
|
-
### Define the broadcast signal
|
|
29
|
+
### Declare
|
|
28
30
|
|
|
29
31
|
```typescript title="src/signals/cache.ts"
|
|
30
32
|
import { signal } from "okengine";
|
|
@@ -35,9 +37,6 @@ export const cacheInvalidated = signal.broadcast("cache.invalidated", {
|
|
|
35
37
|
});
|
|
36
38
|
```
|
|
37
39
|
|
|
38
|
-
</Step>
|
|
39
|
-
|
|
40
|
-
<Step>
|
|
41
40
|
### Bind a subscriber
|
|
42
41
|
|
|
43
42
|
```typescript title="src/flows/cache/purge.ts"
|
|
@@ -54,10 +53,11 @@ export const purgeLocalCache = on(
|
|
|
54
53
|
);
|
|
55
54
|
```
|
|
56
55
|
|
|
57
|
-
|
|
56
|
+
Multiple Flows may bind the same handle — every one gets a copy. That is fan-out, not a race.
|
|
57
|
+
See [Once · Competing consumers](/docs/elements/signal/once#competing-consumers-once-vs-broadcast)
|
|
58
|
+
when you meant a work queue instead.
|
|
58
59
|
|
|
59
|
-
|
|
60
|
-
### Emit from any Flow
|
|
60
|
+
### Emit
|
|
61
61
|
|
|
62
62
|
```typescript title="src/flows/skus/[sku]/update.ts"
|
|
63
63
|
import { on, flow, http } from "okengine";
|
|
@@ -81,10 +81,6 @@ export const update = on(
|
|
|
81
81
|
The compiler records `emits: ["cache.invalidated"]` on the producer. Emit resolves when the
|
|
82
82
|
outbox commits — the HTTP request does not wait for every subscriber to finish.
|
|
83
83
|
|
|
84
|
-
</Step>
|
|
85
|
-
|
|
86
|
-
</Steps>
|
|
87
|
-
|
|
88
84
|
<Callout title="Same handle everywhere">
|
|
89
85
|
Import the declared Signal handle (or the same name) in every subscriber and producer. A typo in
|
|
90
86
|
the name creates a different Manifest entry — fan-out never crosses names.
|
|
@@ -19,10 +19,12 @@ For developers wiring async work on okengine — declare the physics, emit with
|
|
|
19
19
|
|
|
20
20
|
## Smallest Example
|
|
21
21
|
|
|
22
|
-
<
|
|
22
|
+
<Callout title="One handle, three independent uses">
|
|
23
|
+
`orderPlaced` is a shared const. Declare it, bind a worker, and emit from a producer — different
|
|
24
|
+
files, different people, any order. None of these is a prerequisite step for the others.
|
|
25
|
+
</Callout>
|
|
23
26
|
|
|
24
|
-
|
|
25
|
-
### Declare the signal
|
|
27
|
+
### Declare
|
|
26
28
|
|
|
27
29
|
```typescript title="src/signals/orders.ts"
|
|
28
30
|
import { signal } from "okengine";
|
|
@@ -39,10 +41,7 @@ export const orderPlaced = signal.once("orders.placed", {
|
|
|
39
41
|
});
|
|
40
42
|
```
|
|
41
43
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
<Step>
|
|
45
|
-
### Bind a worker and emit
|
|
44
|
+
### Bind a worker
|
|
46
45
|
|
|
47
46
|
```typescript title="src/flows/orders/fulfill.ts"
|
|
48
47
|
import { on, flow } from "okengine";
|
|
@@ -58,8 +57,10 @@ export const fulfill = on(
|
|
|
58
57
|
);
|
|
59
58
|
```
|
|
60
59
|
|
|
60
|
+
### Emit
|
|
61
|
+
|
|
61
62
|
```typescript
|
|
62
|
-
// Inside any Flow:
|
|
63
|
+
// Inside any Flow — need not live next to the worker:
|
|
63
64
|
await fx.emit(orderPlaced, {
|
|
64
65
|
orderId: "ord_99",
|
|
65
66
|
amount: 150,
|
|
@@ -70,16 +71,174 @@ await fx.emit(orderPlaced, {
|
|
|
70
71
|
The compiler records `emits: ["orders.placed"]` on the producer. The HTTP request does not wait
|
|
71
72
|
for the worker.
|
|
72
73
|
|
|
73
|
-
</Step>
|
|
74
|
-
|
|
75
|
-
</Steps>
|
|
76
|
-
|
|
77
74
|
<Callout title="Live is not a worker">
|
|
78
75
|
`signal.live` is an HTTP SSE tape. Expose it with
|
|
79
76
|
[`http.live`](/docs/elements/flow/http#live-streams) — do not bind `on(liveSignal, flow)` as a
|
|
80
77
|
competing consumer.
|
|
81
78
|
</Callout>
|
|
82
79
|
|
|
80
|
+
## Inline or named export
|
|
81
|
+
|
|
82
|
+
| Style | When |
|
|
83
|
+
| --------------------------------------------- | --------------------------------------------------------------------------- |
|
|
84
|
+
| `on(signal.once("name", opts), flow({ do }))` | Self-contained — nothing else needs the Signal handle |
|
|
85
|
+
| `export const x = signal.once<Payload>(…)` | Another file needs `fx.emit(x, payload)` with compile-time payload checking |
|
|
86
|
+
|
|
87
|
+
Both styles stamp the same Manifest `flow.trigger`. The choice is where the
|
|
88
|
+
declaration lives, not two runtimes.
|
|
89
|
+
|
|
90
|
+
A string `fx.emit("name", payload)` still runs (runtime `schema` still applies) but does not
|
|
91
|
+
type-check. What the Flow is called is a separate choice — [Flow name](#flow-name).
|
|
92
|
+
|
|
93
|
+
<Tabs items={["Inline", "Named"]}>
|
|
94
|
+
|
|
95
|
+
<Tab value="Inline">
|
|
96
|
+
|
|
97
|
+
One file — declare and bind together. Producers emit with the string name:
|
|
98
|
+
|
|
99
|
+
```typescript title="src/flows/hooks/inbound.ts"
|
|
100
|
+
import { on, flow, signal } from "okengine";
|
|
101
|
+
import { z } from "zod";
|
|
102
|
+
|
|
103
|
+
export const ingestWebhook = on(
|
|
104
|
+
signal.once("hooks.inbound", {
|
|
105
|
+
schema: z.object({ id: z.string() }),
|
|
106
|
+
retries: 3,
|
|
107
|
+
deadLetter: true,
|
|
108
|
+
}),
|
|
109
|
+
flow({
|
|
110
|
+
do: async ({ id }, fx) => {
|
|
111
|
+
await fx.call(persistHook, { id });
|
|
112
|
+
},
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
await fx.emit("hooks.inbound", { id: "h_1" });
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
</Tab>
|
|
122
|
+
|
|
123
|
+
<Tab value="Named">
|
|
124
|
+
|
|
125
|
+
Export the handle when another file must `fx.emit` with compile-time payload checking:
|
|
126
|
+
|
|
127
|
+
```typescript title="src/signals/orders.ts"
|
|
128
|
+
import { signal } from "okengine";
|
|
129
|
+
import { z } from "zod";
|
|
130
|
+
|
|
131
|
+
export const orderPlaced = signal.once("orders.placed", {
|
|
132
|
+
schema: z.object({ orderId: z.string() }),
|
|
133
|
+
});
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
```typescript title="src/flows/orders/fulfill.ts"
|
|
137
|
+
import { on, flow } from "okengine";
|
|
138
|
+
import { orderPlaced } from "@/signals/orders";
|
|
139
|
+
|
|
140
|
+
export const fulfill = on(
|
|
141
|
+
orderPlaced,
|
|
142
|
+
flow("orders.fulfill", {
|
|
143
|
+
do: async ({ orderId }, fx) => {
|
|
144
|
+
await fx.call(chargeAndShip, { orderId });
|
|
145
|
+
},
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
```typescript
|
|
151
|
+
await fx.emit(orderPlaced, { orderId: "ord_99" });
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
</Tab>
|
|
155
|
+
|
|
156
|
+
</Tabs>
|
|
157
|
+
|
|
158
|
+
## Flow name
|
|
159
|
+
|
|
160
|
+
| Style | When |
|
|
161
|
+
| ----------------------------- | ----------------------------------------------------------------------------- |
|
|
162
|
+
| `flow({ do })` | No unit folder — Flow name is the Signal name (`hooks.inbound`) |
|
|
163
|
+
| `flow("orders.fulfill")` | Manifest / `fx.call` name must differ from the Signal |
|
|
164
|
+
| Tree `export const onCreated` | `src/flows/notes/on-created.ts` stamps `notes.onCreated` — overwrites inherit |
|
|
165
|
+
|
|
166
|
+
Explicit `flow("…")` and the file tree overwrite inherit. HTTP does not inherit a
|
|
167
|
+
name from the path — nameless HTTP stays for the tree or fails **OKE1045**. Two
|
|
168
|
+
Flows that land on the same name fail **OKE1070** (`Flow "{flow}" is defined twice.`).
|
|
169
|
+
|
|
170
|
+
<Tabs items={["Inherit", "Explicit", "Tree"]}>
|
|
171
|
+
|
|
172
|
+
<Tab value="Inherit">
|
|
173
|
+
|
|
174
|
+
A file directly in `src/flows/` (no unit folder) has nothing to stamp. The Flow
|
|
175
|
+
is named `hooks.inbound` — same as the Signal:
|
|
176
|
+
|
|
177
|
+
```typescript title="src/flows/hooks.ts"
|
|
178
|
+
import { on, flow, signal } from "okengine";
|
|
179
|
+
import { z } from "zod";
|
|
180
|
+
|
|
181
|
+
export const ingestWebhook = on(
|
|
182
|
+
signal.once("hooks.inbound", {
|
|
183
|
+
schema: z.object({ id: z.string() }),
|
|
184
|
+
retries: 3,
|
|
185
|
+
deadLetter: true,
|
|
186
|
+
}),
|
|
187
|
+
flow({
|
|
188
|
+
do: async ({ id }, fx) => {
|
|
189
|
+
await fx.call(persistHook, { id });
|
|
190
|
+
},
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
</Tab>
|
|
196
|
+
|
|
197
|
+
<Tab value="Explicit">
|
|
198
|
+
|
|
199
|
+
The Signal stays `orders.placed`. The Flow is `orders.fulfill` — that is the
|
|
200
|
+
Manifest / `fx.call` name:
|
|
201
|
+
|
|
202
|
+
```typescript title="src/flows/fulfill.ts"
|
|
203
|
+
import { on, flow } from "okengine";
|
|
204
|
+
import { orderPlaced } from "@/signals/orders";
|
|
205
|
+
|
|
206
|
+
export const fulfill = on(
|
|
207
|
+
orderPlaced,
|
|
208
|
+
flow("orders.fulfill", {
|
|
209
|
+
do: async ({ orderId }, fx) => {
|
|
210
|
+
await fx.call(chargeAndShip, { orderId });
|
|
211
|
+
},
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
</Tab>
|
|
217
|
+
|
|
218
|
+
<Tab value="Tree">
|
|
219
|
+
|
|
220
|
+
Unit folder + `export const` stamps `unit.export`. The Flow is `notes.onCreated`,
|
|
221
|
+
not `note-created`:
|
|
222
|
+
|
|
223
|
+
```typescript title="src/flows/notes/on-created.ts"
|
|
224
|
+
import { on, flow } from "okengine";
|
|
225
|
+
import { noteCreatedMail } from "@/core";
|
|
226
|
+
import { noteCreated } from "./signals";
|
|
227
|
+
|
|
228
|
+
export const onCreated = on(
|
|
229
|
+
noteCreated,
|
|
230
|
+
flow({
|
|
231
|
+
do: async ({ id }, fx) => {
|
|
232
|
+
await fx.send(noteCreatedMail, { to: "you@localhost", data: { id } });
|
|
233
|
+
},
|
|
234
|
+
}),
|
|
235
|
+
);
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
</Tab>
|
|
239
|
+
|
|
240
|
+
</Tabs>
|
|
241
|
+
|
|
83
242
|
## Progressive Patterns
|
|
84
243
|
|
|
85
244
|
Same helpers + `fx.emit` from a queue job to a fan-out and a browser feed:
|
|
@@ -208,6 +367,66 @@ Fix: add `on(signal, …)` or mark `{ optional: true }`.
|
|
|
208
367
|
| `signal.broadcast` | Pub/sub | Every active subscriber | None (miss if offline) | Cache invalidation, fan-out |
|
|
209
368
|
| `signal.live` | SSE tape | HTTP clients via `http.live` | `Last-Event-ID` resume | Status feeds, progress |
|
|
210
369
|
|
|
370
|
+
## Competing consumers (once vs broadcast)
|
|
371
|
+
|
|
372
|
+
This is the most common mix-up. `signal.once` is a work queue: **exactly one** worker claims each
|
|
373
|
+
message. It is not fan-out.
|
|
374
|
+
|
|
375
|
+
Three differently-named Flows bound to the same `once` signal, then one emit:
|
|
376
|
+
|
|
377
|
+
```typescript title="src/flows/orders/side-effects.ts"
|
|
378
|
+
import { on, flow } from "okengine";
|
|
379
|
+
import { orderPlaced } from "@/signals/orders";
|
|
380
|
+
|
|
381
|
+
export const charge = on(
|
|
382
|
+
orderPlaced,
|
|
383
|
+
flow("orders.charge", {
|
|
384
|
+
do: async ({ orderId }, fx) => {
|
|
385
|
+
await fx.call(chargeOrder, { orderId });
|
|
386
|
+
},
|
|
387
|
+
}),
|
|
388
|
+
);
|
|
389
|
+
export const ship = on(
|
|
390
|
+
orderPlaced,
|
|
391
|
+
flow("orders.ship", {
|
|
392
|
+
do: async ({ orderId }, fx) => {
|
|
393
|
+
await fx.call(shipOrder, { orderId });
|
|
394
|
+
},
|
|
395
|
+
}),
|
|
396
|
+
);
|
|
397
|
+
export const notify = on(
|
|
398
|
+
orderPlaced,
|
|
399
|
+
flow("orders.notify", {
|
|
400
|
+
do: async ({ orderId }, fx) => {
|
|
401
|
+
await fx.call(notifyOrder, { orderId });
|
|
402
|
+
},
|
|
403
|
+
}),
|
|
404
|
+
);
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
```typescript
|
|
408
|
+
await fx.emit(orderPlaced, { orderId: "ord_99", amount: 150, userId: "usr_1" });
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
| Expectation | Real result |
|
|
412
|
+
| ----------------------------------- | -------------------------------------------------------------------- |
|
|
413
|
+
| All three Flows run | **No.** Exactly one of the three runs |
|
|
414
|
+
| Always `orders.charge` (first `on`) | **No.** The winner is whichever claim lands first — not a fixed Flow |
|
|
415
|
+
| A sticky assignment to one Flow | **No.** There is no owner — only an exclusive claim per message |
|
|
416
|
+
|
|
417
|
+
**Consequence:** if every bound Flow should independently receive its own copy, that is
|
|
418
|
+
[`signal.broadcast`](/docs/elements/signal/broadcast) — switch the declaration. That is the correct
|
|
419
|
+
fix for this exact mistake.
|
|
420
|
+
|
|
421
|
+
Two or more **different** Flow definitions on the same `once` signal fail **OKE1071**.
|
|
422
|
+
|
|
423
|
+
Cause: `Once signal "{signal}" is bound to more than one Flow ({flows}).`
|
|
424
|
+
|
|
425
|
+
Load-balancing competing consumers is the **same** Flow on many process replicas — still one
|
|
426
|
+
`on()` in source.
|
|
427
|
+
|
|
428
|
+
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.`
|
|
429
|
+
|
|
211
430
|
## The Capabilities of Signal
|
|
212
431
|
|
|
213
432
|
<Cards>
|
|
@@ -307,6 +526,18 @@ export default defineConfig({
|
|
|
307
526
|
on the Signal. The consumer never ran.
|
|
308
527
|
</Accordion>
|
|
309
528
|
|
|
529
|
+
<Accordion title="OKE1070 — flow name defined twice">
|
|
530
|
+
Cause: `Flow "{flow}" is defined twice.` Two nameless consumers inherited the same Signal name, or
|
|
531
|
+
two explicit `flow("…")` calls collide. Give at least one a distinct `flow("…")` or tree export —
|
|
532
|
+
[Flow name](#flow-name).
|
|
533
|
+
</Accordion>
|
|
534
|
+
|
|
535
|
+
<Accordion title="OKE1071 — once signal bound to more than one Flow">
|
|
536
|
+
Cause: `Once signal "{signal}" is bound to more than one Flow ({flows}).` Use `signal.broadcast`
|
|
537
|
+
if each Flow should independently receive this event, or bind only one Flow. See [Competing
|
|
538
|
+
consumers](#competing-consumers-once-vs-broadcast).
|
|
539
|
+
</Accordion>
|
|
540
|
+
|
|
310
541
|
<Accordion title='oke boot: signal driver "postgres" / "nats"'>
|
|
311
542
|
Those ids are reserved but not bound for production yet. Use `"memory"` or `"redis"`, or inject a
|
|
312
543
|
custom `elements.signal` runtime.
|
|
@@ -326,10 +557,11 @@ export default defineConfig({
|
|
|
326
557
|
- [Broadcast](/docs/elements/signal/broadcast) — ephemeral fan-out
|
|
327
558
|
- [Live](/docs/elements/signal/live) — SSE tapes and retention
|
|
328
559
|
- [Consumers](/docs/elements/flow/consumers) — `on(signal)` workers next to Clock / CDC
|
|
560
|
+
- [Routing](/docs/elements/flow/routing#names) — tree `unit.export` vs inherit
|
|
329
561
|
- [HTTP · Live Streams](/docs/elements/flow/http#live-streams) — `http.live` exposure
|
|
330
562
|
- [fx](/docs/reference/fx) — `fx.emit`, `fx.deadLetters`, `fx.live`
|
|
331
563
|
- [Client](/docs/client/live) — `api.live` for browsers
|
|
332
|
-
- [Errors](/docs/reference/errors) — OKE1240 · OKE1250 · OKE1210
|
|
564
|
+
- [Errors](/docs/reference/errors) — OKE1070 · OKE1071 · OKE1240 · OKE1250 · OKE1210
|
|
333
565
|
|
|
334
566
|
## Next
|
|
335
567
|
|
|
@@ -21,10 +21,12 @@ from the typed client.
|
|
|
21
21
|
|
|
22
22
|
## Smallest Example
|
|
23
23
|
|
|
24
|
-
<
|
|
24
|
+
<Callout title="One handle, three independent uses">
|
|
25
|
+
`orderStatus` is a shared const. Declare it, expose SSE, and emit — different files, any order.
|
|
26
|
+
The firehose is not "step 2"; emit is not "step 3".
|
|
27
|
+
</Callout>
|
|
25
28
|
|
|
26
|
-
|
|
27
|
-
### Declare the live signal
|
|
29
|
+
### Declare
|
|
28
30
|
|
|
29
31
|
```typescript title="src/signals/orders.ts"
|
|
30
32
|
import { signal } from "okengine";
|
|
@@ -39,9 +41,6 @@ export const orderStatus = signal.live("order-status", {
|
|
|
39
41
|
});
|
|
40
42
|
```
|
|
41
43
|
|
|
42
|
-
</Step>
|
|
43
|
-
|
|
44
|
-
<Step>
|
|
45
44
|
### Expose SSE
|
|
46
45
|
|
|
47
46
|
```typescript title="src/flows/orders/firehose.ts"
|
|
@@ -52,9 +51,6 @@ import { orderStatus } from "@/signals/orders";
|
|
|
52
51
|
export const firehose = on(http.live(orderStatus).gate(member));
|
|
53
52
|
```
|
|
54
53
|
|
|
55
|
-
</Step>
|
|
56
|
-
|
|
57
|
-
<Step>
|
|
58
54
|
### Emit and subscribe
|
|
59
55
|
|
|
60
56
|
```typescript
|
|
@@ -71,10 +67,6 @@ curl -N http://localhost:6530/_oke/live/order-status \
|
|
|
71
67
|
Response `Content-Type` is `text/event-stream`. Frames are JSON `data:` lines
|
|
72
68
|
(optional `id:` for resume), then `data: [DONE]`.
|
|
73
69
|
|
|
74
|
-
</Step>
|
|
75
|
-
|
|
76
|
-
</Steps>
|
|
77
|
-
|
|
78
70
|
<Callout title="Pathless firehose">
|
|
79
71
|
`on(http.live(signal))` always mounts `GET /_oke/live/{name}` — there is no pathless file-tree
|
|
80
72
|
stamp for live. Custom paths use `http.get(path).live(signal)`. See [Exposure](#exposure).
|