okengine 0.19.1 → 0.19.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/elements/clock/index.mdx +162 -11
  3. package/site/content/docs/elements/clock/schedules.mdx +6 -3
  4. package/site/content/docs/elements/flow/consumers.mdx +137 -79
  5. package/site/content/docs/elements/flow/index.mdx +14 -14
  6. package/site/content/docs/elements/flow/routing.mdx +18 -2
  7. package/site/content/docs/elements/signal/broadcast.mdx +9 -13
  8. package/site/content/docs/elements/signal/index.mdx +245 -13
  9. package/site/content/docs/elements/signal/live.mdx +5 -13
  10. package/site/content/docs/elements/signal/once.mdx +83 -28
  11. package/site/content/docs/elements/store/sql.mdx +2 -2
  12. package/site/content/docs/plugins/anonymous.mdx +1 -1
  13. package/site/content/docs/plugins/cors.mdx +1 -1
  14. package/site/content/docs/plugins/csrf.mdx +1 -1
  15. package/site/content/docs/plugins/headers.mdx +1 -1
  16. package/site/content/docs/plugins/ip-allowlist.mdx +1 -1
  17. package/site/content/docs/plugins/maintenance-mode.mdx +1 -1
  18. package/site/content/docs/reference/errors.mdx +34 -26
  19. package/site/content/docs/reference/fx.mdx +6 -6
  20. package/site/content/docs/reference/okid.mdx +1 -1
  21. package/site/content/docs/reference/plugins.mdx +1 -1
  22. package/site/content/docs/understand/the-architecture.mdx +1 -1
  23. package/src/compiler/extract.test.ts +123 -1
  24. package/src/compiler/extract.ts +134 -7
  25. package/src/compiler/search-writer-isolation.test.ts +0 -1
  26. package/src/console/ui-next/dist/assets/{access-page-C_qLDhTq.js → access-page-BpugjHHY.js} +1 -1
  27. package/src/console/ui-next/dist/assets/{agent-disclosure-BHVqr3TN.js → agent-disclosure-qnsmtJhf.js} +1 -1
  28. package/src/console/ui-next/dist/assets/{cache-glyph-CKe92lRQ.js → cache-glyph-BcpWUM98.js} +1 -1
  29. package/src/console/ui-next/dist/assets/{call-pii-button-DEwTl8ZX.js → call-pii-button-Bz5xiNmR.js} +1 -1
  30. package/src/console/ui-next/dist/assets/{collapsible-BCBtDrCt.js → collapsible-Cuxn2WH8.js} +1 -1
  31. package/src/console/ui-next/dist/assets/{duration-tone-JroqeuCp.js → duration-tone-Bjnl3EaM.js} +1 -1
  32. package/src/console/ui-next/dist/assets/{flows-page-CVHa0RTt.js → flows-page-DVujp-T2.js} +1 -1
  33. package/src/console/ui-next/dist/assets/{highlighted-json-DjJW6hqe.js → highlighted-json-Bql0qlqW.js} +1 -1
  34. package/src/console/ui-next/dist/assets/{http-method-DC5HBdLU.js → http-method-BljvrfRg.js} +1 -1
  35. package/src/console/ui-next/dist/assets/{index-CYjiZ3WO.js → index-D1vE656k.js} +3 -3
  36. package/src/console/ui-next/dist/assets/{observability-page-CAYMyKb3.js → observability-page-Bdi0bLJl.js} +1 -1
  37. package/src/console/ui-next/dist/assets/{replica-lag-yAQYLv75.js → replica-lag-CpkPfITG.js} +1 -1
  38. package/src/console/ui-next/dist/assets/{request-meta-D0yusGxJ.js → request-meta-C4ZVNFVt.js} +1 -1
  39. package/src/console/ui-next/dist/assets/{store-page-BTKJeJ02.js → store-page-DAHtnesC.js} +1 -1
  40. package/src/console/ui-next/dist/assets/{trace-detail-sheet-Bp-Yygs5.js → trace-detail-sheet-B2c9QRBw.js} +1 -1
  41. package/src/console/ui-next/dist/assets/{tree-expand-toggle-DoaVDfAM.js → tree-expand-toggle-BP4tRC98.js} +1 -1
  42. package/src/console/ui-next/dist/assets/{units-page-BRz7xyYL.js → units-page-Oi6--n09.js} +1 -1
  43. package/src/console/ui-next/dist/assets/{vault-page-3jQt-bOJ.js → vault-page-Dt-cPkiU.js} +1 -1
  44. package/src/console/ui-next/dist/index.html +1 -1
  45. package/src/elements/store/live-default.test.ts +8 -0
  46. package/src/elements/store/search-embed-flow.ts +2 -2
  47. package/src/full.ts +3 -0
  48. package/src/http.ts +3 -0
  49. package/src/index.ts +3 -0
  50. package/src/kernel/app.ts +94 -10
  51. package/src/kernel/boot.ts +1 -1
  52. package/src/kernel/cdc-payload.test.ts +224 -0
  53. package/src/kernel/cdc-payload.ts +146 -0
  54. package/src/kernel/errors-flow-name.ts +17 -0
  55. package/src/kernel/errors-once-signal.ts +25 -0
  56. package/src/kernel/errors.registry.test.ts +14 -4
  57. package/src/kernel/flow-name.test.ts +104 -0
  58. package/src/kernel/flow.ts +3 -2
  59. package/src/kernel/fx-emit-types.test.ts +31 -0
  60. package/src/kernel/fx.test.ts +2 -1
  61. package/src/kernel/fx.ts +13 -3
  62. package/src/kernel/index.ts +2 -0
  63. package/src/kernel/on.ts +5 -0
  64. package/src/kernel/once-signal.test.ts +71 -0
  65. package/src/kernel/stamp-http.test.ts +13 -0
  66. package/src/kernel/stamp-http.ts +21 -3
  67. package/src/kernel/unit.ts +4 -2
  68. package/src/kernel-entry.ts +3 -0
@@ -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
- Pass `flow("notes.create", { … })` only for control. Nameless HTTP after adopt
349
- fails **OKE1045** — see [Routing](/docs/elements/flow/routing#when-to-omit--when-to-pass).
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()` | — | UUID |
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
- `db.table(…).changed()`, `internal`, or `mcp.tool(…)`. A bare interval string is not
529
- a trigger — wrap it in `clock("name", { every: "1h" })`.
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
- HTTP flows that remain nameless after adopt fail **OKE1045**.
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
- <Steps>
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
- <Step>
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
- </Step>
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
- <Step>
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
- <Steps>
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
- <Step>
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
- </Step>
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
- <Steps>
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
- <Step>
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).