okengine 0.19.2 → 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 (35) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/elements/clock/index.mdx +144 -3
  3. package/site/content/docs/elements/flow/consumers.mdx +79 -59
  4. package/site/content/docs/elements/flow/index.mdx +2 -2
  5. package/site/content/docs/elements/flow/routing.mdx +12 -5
  6. package/site/content/docs/elements/signal/broadcast.mdx +9 -13
  7. package/site/content/docs/elements/signal/index.mdx +236 -17
  8. package/site/content/docs/elements/signal/live.mdx +5 -13
  9. package/site/content/docs/elements/signal/once.mdx +83 -28
  10. package/site/content/docs/reference/errors.mdx +34 -27
  11. package/src/compiler/extract.test.ts +47 -0
  12. package/src/compiler/extract.ts +28 -0
  13. package/src/console/ui-next/dist/assets/{access-page-DceEWH9u.js → access-page-BpugjHHY.js} +1 -1
  14. package/src/console/ui-next/dist/assets/{agent-disclosure-tC9s2VFd.js → agent-disclosure-qnsmtJhf.js} +1 -1
  15. package/src/console/ui-next/dist/assets/{cache-glyph-C-naNQSR.js → cache-glyph-BcpWUM98.js} +1 -1
  16. package/src/console/ui-next/dist/assets/{call-pii-button-DnZ_MlDn.js → call-pii-button-Bz5xiNmR.js} +1 -1
  17. package/src/console/ui-next/dist/assets/{collapsible-RekgR6Qz.js → collapsible-Cuxn2WH8.js} +1 -1
  18. package/src/console/ui-next/dist/assets/{duration-tone-DugtWBS0.js → duration-tone-Bjnl3EaM.js} +1 -1
  19. package/src/console/ui-next/dist/assets/{flows-page-DVmn1ZuQ.js → flows-page-DVujp-T2.js} +1 -1
  20. package/src/console/ui-next/dist/assets/{highlighted-json-CvBGaiSD.js → highlighted-json-Bql0qlqW.js} +1 -1
  21. package/src/console/ui-next/dist/assets/{http-method-D7_OXbdC.js → http-method-BljvrfRg.js} +1 -1
  22. package/src/console/ui-next/dist/assets/{index-DH0K2f6N.js → index-D1vE656k.js} +3 -3
  23. package/src/console/ui-next/dist/assets/{observability-page-qJzF2nSN.js → observability-page-Bdi0bLJl.js} +1 -1
  24. package/src/console/ui-next/dist/assets/{replica-lag-Vqk0pUBA.js → replica-lag-CpkPfITG.js} +1 -1
  25. package/src/console/ui-next/dist/assets/{request-meta-BtShi4sG.js → request-meta-C4ZVNFVt.js} +1 -1
  26. package/src/console/ui-next/dist/assets/{store-page-CMsYH_vH.js → store-page-DAHtnesC.js} +1 -1
  27. package/src/console/ui-next/dist/assets/{trace-detail-sheet-ycFB2uua.js → trace-detail-sheet-B2c9QRBw.js} +1 -1
  28. package/src/console/ui-next/dist/assets/{tree-expand-toggle-iG1jcWgU.js → tree-expand-toggle-BP4tRC98.js} +1 -1
  29. package/src/console/ui-next/dist/assets/{units-page-Bavchke4.js → units-page-Oi6--n09.js} +1 -1
  30. package/src/console/ui-next/dist/assets/{vault-page-DxUFhiZI.js → vault-page-Dt-cPkiU.js} +1 -1
  31. package/src/console/ui-next/dist/index.html +1 -1
  32. package/src/kernel/app.ts +64 -0
  33. package/src/kernel/errors-once-signal.ts +25 -0
  34. package/src/kernel/errors.registry.test.ts +8 -0
  35. package/src/kernel/once-signal.test.ts +71 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okengine",
3
- "version": "0.19.2",
3
+ "version": "0.19.3",
4
4
  "description": "One law. Eight elements. One contract. The backend model stays small; operational surfaces are derived from it instead of maintained separately.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -80,8 +80,142 @@ Under `drivers.clock.test = "frozen"`, advance time in tests instead of waiting
80
80
  | `on(clock.every("name", "1h"), flow({ do }))` | Self-contained — nothing else needs the Clock handle |
81
81
  | `export const x = clock.every("name", "1h")` | Another file (or a second `on()`) must reuse the same declaration |
82
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**.
83
+ Both styles stamp the same Manifest `flow.trigger`. The choice is where the
84
+ declaration lives, not two runtimes.
85
+
86
+ What the Flow is called is a separate choice — [Flow name](#flow-name).
87
+
88
+ <Tabs items={["Inline", "Named"]}>
89
+
90
+ <Tab value="Inline">
91
+
92
+ One file — declare and bind together. The scheduler fires it; no other file
93
+ imports the handle:
94
+
95
+ ```typescript title="src/flows/health/ping.ts"
96
+ import { on, flow, clock } from "okengine";
97
+
98
+ export const pingExternal = on(
99
+ clock.every("health.pingExternal", "30s"),
100
+ flow({
101
+ plane: "operator",
102
+ do: async (_, fx) => {
103
+ await fx.call(pingUpstream);
104
+ },
105
+ }),
106
+ );
107
+ ```
108
+
109
+ </Tab>
110
+
111
+ <Tab value="Named">
112
+
113
+ Export the handle when another file (or a second `on()`) must reuse the same
114
+ declaration:
115
+
116
+ ```typescript title="src/clocks/digest.ts"
117
+ import { clock } from "okengine";
118
+
119
+ export const digestClock = clock.every("notes.digest", "1d");
120
+ ```
121
+
122
+ ```typescript title="src/flows/notes/digest.ts"
123
+ import { on, flow } from "okengine";
124
+ import { digestClock } from "@/clocks/digest";
125
+
126
+ export const digest = on(
127
+ digestClock,
128
+ flow("notes.digest", {
129
+ plane: "operator",
130
+ do: async (_, fx) => {
131
+ await fx.call(buildDigest, { at: fx.clock.now() });
132
+ },
133
+ }),
134
+ );
135
+ ```
136
+
137
+ </Tab>
138
+
139
+ </Tabs>
140
+
141
+ ## Flow name
142
+
143
+ | Style | When |
144
+ | ------------------------- | ---------------------------------------------------------------- |
145
+ | `flow({ do })` | No unit folder — Flow name is the Clock name (`metrics.cleanup`) |
146
+ | `flow("ops.sweep")` | Manifest / `fx.call` name must differ from the Clock |
147
+ | Tree `export const sweep` | `src/flows/ops/sweep.ts` stamps `ops.sweep` — overwrites inherit |
148
+
149
+ Explicit `flow("…")` and the file tree overwrite inherit. HTTP does not inherit a
150
+ name from the path — nameless HTTP stays for the tree or fails **OKE1045**. Two
151
+ Flows that land on the same name fail **OKE1070** (`Flow "{flow}" is defined twice.`).
152
+
153
+ <Tabs items={["Inherit", "Explicit", "Tree"]}>
154
+
155
+ <Tab value="Inherit">
156
+
157
+ A file directly in `src/flows/` (no unit folder) has nothing to stamp. The Flow
158
+ is named `metrics.cleanup` — same as the Clock:
159
+
160
+ ```typescript title="src/flows/cleanup.ts"
161
+ import { on, flow, clock } from "okengine";
162
+
163
+ export const cleanupMetrics = on(
164
+ clock.every("metrics.cleanup", "1h"),
165
+ flow({
166
+ plane: "operator",
167
+ do: async (_, fx) => {
168
+ await fx.call(sweepMetrics);
169
+ },
170
+ }),
171
+ );
172
+ ```
173
+
174
+ </Tab>
175
+
176
+ <Tab value="Explicit">
177
+
178
+ The Clock stays `metrics.cleanup`. The Flow is `ops.sweep` — that is the
179
+ Manifest / `fx.call` name:
180
+
181
+ ```typescript title="src/flows/sweep.ts"
182
+ import { on, flow, clock } from "okengine";
183
+
184
+ export const sweep = on(
185
+ clock.every("metrics.cleanup", "1h"),
186
+ flow("ops.sweep", {
187
+ plane: "operator",
188
+ do: async (_, fx) => {
189
+ await fx.call(sweepMetrics);
190
+ },
191
+ }),
192
+ );
193
+ ```
194
+
195
+ </Tab>
196
+
197
+ <Tab value="Tree">
198
+
199
+ Unit folder + `export const` stamps `unit.export`. The Flow is `ops.sweep`, not
200
+ the Clock name `cleanup`:
201
+
202
+ ```typescript title="src/flows/ops/sweep.ts"
203
+ import { on, flow, clock } from "okengine";
204
+
205
+ export const sweep = on(
206
+ clock.every("cleanup", "10m"),
207
+ flow({
208
+ plane: "operator",
209
+ do: async (_, fx) => {
210
+ await fx.call(sweepMetrics);
211
+ },
212
+ }),
213
+ );
214
+ ```
215
+
216
+ </Tab>
217
+
218
+ </Tabs>
85
219
 
86
220
  ## Progressive Patterns
87
221
 
@@ -279,6 +413,12 @@ export default defineConfig({
279
413
  declaration in source.
280
414
  </Accordion>
281
415
 
416
+ <Accordion title="OKE1070 — flow name defined twice">
417
+ Cause: `Flow "{flow}" is defined twice.` Two nameless consumers inherited the same Clock name, or
418
+ two explicit `flow("…")` calls collide. Give at least one a distinct `flow("…")` or tree export —
419
+ [Flow name](#flow-name).
420
+ </Accordion>
421
+
282
422
  </Accordions>
283
423
 
284
424
  ## Learn more
@@ -288,7 +428,8 @@ export default defineConfig({
288
428
  - [Consumers · Clock Jobs](/docs/elements/flow/consumers#clock-jobs) — bind with `on(clockDecl, flow)`
289
429
  - [Workflows](/docs/elements/flow/workflows) — `durable: true` + `fx.step` around sleeps
290
430
  - [fx](/docs/reference/fx) — full `fx.clock` table
291
- - [Errors](/docs/reference/errors) — `ScheduleNotOverridableError` · `ClockResourceNotFoundError`
431
+ - [Routing](/docs/elements/flow/routing#names) — tree `unit.export` vs inherit
432
+ - [Errors](/docs/reference/errors) — `ScheduleNotOverridableError` · `ClockResourceNotFoundError` · OKE1070
292
433
 
293
434
  ## Next
294
435
 
@@ -17,9 +17,11 @@ For developers wiring background work on okengine — bind the trigger, keep `do
17
17
 
18
18
  ## Smallest Example
19
19
 
20
- <Steps>
20
+ <Callout title="Bind and emit are independent">
21
+ The consumer and the producer share one Signal handle. They can live in different files, written
22
+ in any order — emit is not "step 2" after bind.
23
+ </Callout>
21
24
 
22
- <Step>
23
25
  ### Bind a Signal consumer
24
26
 
25
27
  ```typescript title="src/flows/notifications/welcome.ts"
@@ -40,9 +42,6 @@ export const sendWelcome = on(
40
42
  );
41
43
  ```
42
44
 
43
- </Step>
44
-
45
- <Step>
46
45
  ### Emit from any Flow
47
46
 
48
47
  ```typescript
@@ -52,10 +51,6 @@ await fx.emit(userSignedUp, { userId: "usr_123", email: "alice@example.com" });
52
51
  The compiler records `emits: ["users.signed-up"]` on the producer. The consumer runs after the
53
52
  emit commits — the HTTP request does not wait for the welcome mail.
54
53
 
55
- </Step>
56
-
57
- </Steps>
58
-
59
54
  <Callout title="Jobs are consumers">
60
55
  A named Clock bound with `on(clockDecl, flow)` is the same species — an asynchronous Flow. There
61
56
  is no separate job runner. See [Clock jobs](#clock-jobs).
@@ -195,8 +190,9 @@ Omit `key` for competing consumers with no ordering.
195
190
  `signal.live` is an HTTP SSE tape — bind it with [`http.live`](/docs/elements/flow/http#live-streams),
196
191
  not as a worker. A Flow with no trigger is [call-only](/docs/elements/flow).
197
192
 
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**.
193
+ One-file inline vs exported handle: [Signal](/docs/elements/signal#inline-or-named-export) ·
194
+ [Clock](/docs/elements/clock#inline-or-named-export). Flow name (inherit / explicit / tree):
195
+ [Signal](/docs/elements/signal#flow-name) · [Clock](/docs/elements/clock#flow-name). Collision is **OKE1070**.
200
196
 
201
197
  ## Signal Consumers
202
198
 
@@ -212,8 +208,12 @@ Each emit is handled according to the Signal helper you declared. The Flow is th
212
208
 
213
209
  <Tab value="Once">
214
210
 
215
- Competing workers — exactly one consumer processes each message. Failed attempts retry, then
216
- dead-letter when `deadLetter` is true (default):
211
+ Competing workers — exactly one consumer claims each message. Failed attempts retry, then
212
+ dead-letter when `deadLetter` is true (default).
213
+
214
+ Two different Flows on one `once` signal fail **OKE1071** — see
215
+ [Once · Competing consumers](/docs/elements/signal/once#competing-consumers-once-vs-broadcast).
216
+ For every bound Flow to run, use [`signal.broadcast`](/docs/elements/signal/broadcast).
217
217
 
218
218
  ```typescript title="src/signals/orders.ts"
219
219
  import { signal } from "okengine";
@@ -489,49 +489,34 @@ do not fire.
489
489
  ## CDC
490
490
 
491
491
  <Callout title="Detailed section">
492
- If you only need any-write, jump to the example below. The handle is
492
+ If you only need any-write, jump to Bare or enriched below. The handle is
493
493
  `db.table(table).changed(column?)` — `db` is a `store.sql` declaration, `table` is a schema
494
494
  handle. `changed("insert")` is **not** an op filter; it stamps a column named `insert`.
495
495
  </Callout>
496
496
 
497
497
  SQL writes through `fx.store` notify CDC after commit. The Flow input is always
498
- `{ before, after }` plus `table`, `action`, and `id` (`CdcPayload`). Destructure
499
- only `{ before, after }` when that's all you need.
498
+ `{ before, after, table, action, id }` (`CdcPayload`).
500
499
 
501
- <Tabs items={["Audit log", "Bare", "Images"]}>
500
+ ### Bare or enriched
502
501
 
503
- <Tab value="Audit log">
502
+ | Style | When |
503
+ | ------------------------- | ---------------------------------------------------------------------- |
504
+ | `({ before, after })` | Bound to one table — images are enough (search reindex, listing cache) |
505
+ | `({ table, action, id })` | Log, route, or branch — kind of change and which record (audit log) |
504
506
 
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
+ Both styles receive the same object. There is no second dispatch path, no
508
+ performance difference, and no correctness difference — the choice is which
509
+ fields this handler destructures.
507
510
 
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
- ```
511
+ `{ table, action, id }` are always populated; omitting them from `do` does not
512
+ drop them from the payload.
529
513
 
530
- </Tab>
514
+ <Tabs items={["Bare", "Enriched", "Images"]}>
531
515
 
532
516
  <Tab value="Bare">
533
517
 
534
- Omit `table` / `action` / `id` when you only need the row images:
518
+ Bound to `notes` — the table is already in the trigger. Images decide upsert vs
519
+ drop; the row's `id` is on the surviving image:
535
520
 
536
521
  ```typescript title="src/flows/search/reindex.ts"
537
522
  import { on, flow } from "okengine";
@@ -553,25 +538,30 @@ export const reindexNotes = on(
553
538
  );
554
539
  ```
555
540
 
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:
541
+ </Tab>
558
542
 
559
- ```typescript title="src/flows/tasks/on-status.ts"
543
+ <Tab value="Enriched">
544
+
545
+ `table` is the real table name. `action` is `"created"` / `"updated"` / `"deleted"`.
546
+ `id` is the declared primary-key value — not a hardcoded `"id"` column:
547
+
548
+ ```typescript title="src/flows/audit/users.ts"
560
549
  import { on, flow } from "okengine";
561
550
  import { db } from "@/core";
562
- import { tasks } from "@/schema";
551
+ import { users, auditLogs } from "@/schema";
563
552
 
564
- export const onStatus = on(
565
- db.table(tasks).changed("status"),
566
- flow("tasks.onStatus", {
567
- plane: "operator",
568
- do: async ({ before, after, id }, fx) => {
569
- if (before?.status === after?.status) return;
570
- await fx.emit(taskStatusChanged, {
571
- id,
572
- from: before?.status ?? null,
573
- to: after?.status ?? null,
574
- });
553
+ export const onUserWrite = on(
554
+ db.table(users).changed(),
555
+ flow("audit.users", {
556
+ do: async ({ table, action, id }, fx) => {
557
+ await fx
558
+ .store(db)
559
+ .insert(auditLogs)
560
+ .values({
561
+ table,
562
+ recordId: String(id),
563
+ action,
564
+ });
575
565
  },
576
566
  }),
577
567
  );
@@ -605,6 +595,30 @@ do: async ({ before, after, action }, fx) => {
605
595
 
606
596
  </Tabs>
607
597
 
598
+ `changed("status")` stamps `trigger.cdc.column` on the Manifest. Still the same
599
+ payload — filter in `do` when you only care about that field:
600
+
601
+ ```typescript title="src/flows/tasks/on-status.ts"
602
+ import { on, flow } from "okengine";
603
+ import { db } from "@/core";
604
+ import { tasks } from "@/schema";
605
+
606
+ export const onStatus = on(
607
+ db.table(tasks).changed("status"),
608
+ flow("tasks.onStatus", {
609
+ plane: "operator",
610
+ do: async ({ before, after, id }, fx) => {
611
+ if (before?.status === after?.status) return;
612
+ await fx.emit(taskStatusChanged, {
613
+ id,
614
+ from: before?.status ?? null,
615
+ to: after?.status ?? null,
616
+ });
617
+ },
618
+ }),
619
+ );
620
+ ```
621
+
608
622
  <Accordions>
609
623
 
610
624
  <Accordion title="CDC payload">
@@ -665,6 +679,12 @@ Clock drivers: **postgres** in `dev`/`prod`, **frozen** in `test`. Signal driver
665
679
  explicit `flow("…")` or a distinct tree export.
666
680
  </Accordion>
667
681
 
682
+ <Accordion title="OKE1071 — once signal bound to more than one Flow">
683
+ Cause: `Once signal "{signal}" is bound to more than one Flow ({flows}).` Use `signal.broadcast`
684
+ if each Flow should independently receive this event, or bind only one Flow. See [Once · Competing
685
+ consumers](/docs/elements/signal/once#competing-consumers-once-vs-broadcast).
686
+ </Accordion>
687
+
668
688
  <Accordion title="OKE1240 — emit with no subscriber">
669
689
  Cause: `Flow "{flow}" emits signal "{resource}" with no subscriber.` Add `on(signal, flow)` or
670
690
  set `{ optional: true }` on the Signal (live firehoses, unused hooks).
@@ -716,7 +736,7 @@ Clock drivers: **postgres** in `dev`/`prod`, **frozen** in `test`. Signal driver
716
736
  - [Store · SQL](/docs/elements/store/sql) — tables CDC watches
717
737
  - [HTTP · Live Streams](/docs/elements/flow/http#live-streams) — `signal.live` SSE
718
738
  - [fx](/docs/reference/fx) — `fx.emit`, `fx.deadLetters`, `fx.clock`
719
- - [Errors](/docs/reference/errors) — OKE1070 · OKE1240 · OKE1250
739
+ - [Errors](/docs/reference/errors) — OKE1070 · OKE1071 · OKE1240 · OKE1250
720
740
  - [Workflows](/docs/elements/flow/workflows) — `durable: true` + `fx.step` on a consumer
721
741
 
722
742
  ## Next
@@ -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>
@@ -455,11 +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
- 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
-
462
- 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**.
463
470
 
464
471
  ## Runtime Matching
465
472
 
@@ -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.