okengine 0.19.3 → 0.19.4
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/channel/index.mdx +1 -1
- package/site/content/docs/elements/clock/index.mdx +53 -105
- package/site/content/docs/elements/clock/schedules.mdx +39 -11
- package/site/content/docs/elements/flow/consumers.mdx +54 -31
- package/site/content/docs/elements/flow/index.mdx +3 -3
- package/site/content/docs/elements/flow/routing.mdx +11 -15
- package/site/content/docs/elements/signal/broadcast.mdx +6 -1
- package/site/content/docs/elements/signal/index.mdx +21 -177
- package/site/content/docs/elements/signal/once.mdx +6 -1
- package/site/content/docs/reference/errors.mdx +7 -0
- package/site/content/docs/understand/the-architecture.mdx +2 -2
- package/src/cli/competitor-mention-removal.test.ts +3 -3
- package/src/compiler/extract.test.ts +190 -15
- package/src/compiler/extract.ts +47 -4
- package/src/compiler/flow-path.test.ts +23 -0
- package/src/compiler/flow-path.ts +17 -1
- package/src/console/ui-next/dist/assets/{access-page-BpugjHHY.js → access-page-rO2HLPab.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-qnsmtJhf.js → agent-disclosure-ae8w8JLx.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-BcpWUM98.js → cache-glyph-C-uHdh5d.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-Bz5xiNmR.js → call-pii-button-BJH54w4s.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-Cuxn2WH8.js → collapsible-CaE8cs9p.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-Bjnl3EaM.js → duration-tone-DdOQkQVK.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-DVujp-T2.js → flows-page-DWeBszQQ.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-Bql0qlqW.js → highlighted-json-B2gBsC4B.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-BljvrfRg.js → http-method-BnMl0dJt.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-D1vE656k.js → index-CBAP48v5.js} +3 -3
- package/src/console/ui-next/dist/assets/index-VxoEz295.css +2 -0
- package/src/console/ui-next/dist/assets/{observability-page-Bdi0bLJl.js → observability-page-DVpcJEnW.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-CpkPfITG.js → replica-lag-BUQf2Nf6.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-C4ZVNFVt.js → request-meta-CPkGDQgT.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-DAHtnesC.js → store-page-CwS_cY4V.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-B2c9QRBw.js → trace-detail-sheet-DNX7Y2bd.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-BP4tRC98.js → tree-expand-toggle-Bhdk27aF.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-Oi6--n09.js → units-page-CsBh0XbI.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-Dt-cPkiU.js → vault-page-CsTlPtK9.js} +1 -1
- package/src/console/ui-next/dist/index.html +2 -2
- package/src/kernel/app.ts +23 -1
- package/src/kernel/errors-flow-name.ts +20 -4
- package/src/kernel/errors.registry.test.ts +4 -2
- package/src/kernel/flow-name.test.ts +105 -20
- package/src/kernel/flow.ts +3 -3
- package/src/kernel/on.ts +0 -5
- package/src/kernel/stamp-http.test.ts +2 -1
- package/src/kernel/stamp-http.ts +5 -7
- package/src/kernel/unit.ts +1 -2
- package/src/console/ui-next/dist/assets/index-D0zS5rKO.css +0 -2
|
@@ -7,12 +7,12 @@ source: "docs/spec/unified-theory.md"
|
|
|
7
7
|
|
|
8
8
|
Signal is how your backend **moves data when the producer should not wait**. An email job, a cache bust across instances, and a browser status feed share one handle shape — only the helper changes: `signal.once`, `signal.broadcast`, or `signal.live`.
|
|
9
9
|
|
|
10
|
-
For developers wiring async work on okengine — declare the physics, emit with `fx.emit`, bind workers with `on(
|
|
10
|
+
For developers wiring async work on okengine — declare the physics, emit with `fx.emit`, bind workers with `on(handle, flow("name", { do }))`.
|
|
11
11
|
|
|
12
12
|
<Callout title="The one rule">
|
|
13
|
-
Declare every signal with `signal.once`, `signal.broadcast`, or `signal.live`
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
Declare every signal with `signal.once`, `signal.broadcast`, or `signal.live` as an
|
|
14
|
+
exported const. Bind with `on(handle, flow("name", { do }))`. Physics and the **emit**
|
|
15
|
+
`schema` live on the Signal; the worker inherits the payload type, not `flow.in`.
|
|
16
16
|
</Callout>
|
|
17
17
|
|
|
18
18
|
<SignalDelivery />
|
|
@@ -77,167 +77,8 @@ for the worker.
|
|
|
77
77
|
competing consumer.
|
|
78
78
|
</Callout>
|
|
79
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
80
|
A string `fx.emit("name", payload)` still runs (runtime `schema` still applies) but does not
|
|
91
|
-
type-check.
|
|
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>
|
|
81
|
+
type-check. Import the exported const.
|
|
241
82
|
|
|
242
83
|
## Progressive Patterns
|
|
243
84
|
|
|
@@ -451,14 +292,14 @@ Fix: `Use signal.broadcast if each flow should independently receive this event,
|
|
|
451
292
|
|
|
452
293
|
Optional second argument to `signal.once` / `signal.broadcast` / `signal.live`. Delivery is the helper name — not an option.
|
|
453
294
|
|
|
454
|
-
| Option | Type | Default | Meaning
|
|
455
|
-
| ------------- | ------------------------ | --------- |
|
|
456
|
-
| `schema` | Standard Schema | omitted | **Emit** contract — enforced at `fx.emit` (**OKE1250** on mismatch). Workers inherit the payload; do not put `in` on `flow(
|
|
457
|
-
| `retries` | `number` | `3` | Extra attempts after the first (`retries + 1` total) — `once` path
|
|
458
|
-
| `deadLetter` | `boolean` | `true` | Keep exhausted `once` messages; `false` marks them delivered
|
|
459
|
-
| `optional` | `boolean` | `false` | Allow emit with zero subscribers
|
|
460
|
-
| `retention` | `{ maxAge?, maxCount? }` | unbounded | **`signal.live` only** — type error on `once` / `broadcast`
|
|
461
|
-
| `description` | `string` | the name | Console / docs blurb
|
|
295
|
+
| Option | Type | Default | Meaning |
|
|
296
|
+
| ------------- | ------------------------ | --------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
|
297
|
+
| `schema` | Standard Schema | omitted | **Emit** contract — enforced at `fx.emit` (**OKE1250** on mismatch). Workers inherit the payload; do not put `in` on `flow()`. |
|
|
298
|
+
| `retries` | `number` | `3` | Extra attempts after the first (`retries + 1` total) — `once` path |
|
|
299
|
+
| `deadLetter` | `boolean` | `true` | Keep exhausted `once` messages; `false` marks them delivered |
|
|
300
|
+
| `optional` | `boolean` | `false` | Allow emit with zero subscribers |
|
|
301
|
+
| `retention` | `{ maxAge?, maxCount? }` | unbounded | **`signal.live` only** — type error on `once` / `broadcast` |
|
|
302
|
+
| `description` | `string` | the name | Console / docs blurb |
|
|
462
303
|
|
|
463
304
|
**Consequence:** `deadLetter` is a boolean flag, not a queue name string.
|
|
464
305
|
|
|
@@ -527,9 +368,13 @@ export default defineConfig({
|
|
|
527
368
|
</Accordion>
|
|
528
369
|
|
|
529
370
|
<Accordion title="OKE1070 — flow name defined twice">
|
|
530
|
-
Cause: `Flow "{flow}" is defined twice.` Two
|
|
531
|
-
|
|
532
|
-
|
|
371
|
+
Cause: `Flow "{flow}" is defined twice.` Two `flow("…")` strings collide. Give at least one a
|
|
372
|
+
distinct name.
|
|
373
|
+
</Accordion>
|
|
374
|
+
|
|
375
|
+
<Accordion title="OKE1072 — Signal flow unnamed">
|
|
376
|
+
Cause: `A signal flow on "{trigger}" has no name.`
|
|
377
|
+
Fix: pass an explicit name — `on(handle, flow("orders.fulfill", { do }))`.
|
|
533
378
|
</Accordion>
|
|
534
379
|
|
|
535
380
|
<Accordion title="OKE1071 — once signal bound to more than one Flow">
|
|
@@ -557,11 +402,10 @@ export default defineConfig({
|
|
|
557
402
|
- [Broadcast](/docs/elements/signal/broadcast) — ephemeral fan-out
|
|
558
403
|
- [Live](/docs/elements/signal/live) — SSE tapes and retention
|
|
559
404
|
- [Consumers](/docs/elements/flow/consumers) — `on(signal)` workers next to Clock / CDC
|
|
560
|
-
- [Routing](/docs/elements/flow/routing#names) — tree `unit.export` vs inherit
|
|
561
405
|
- [HTTP · Live Streams](/docs/elements/flow/http#live-streams) — `http.live` exposure
|
|
562
406
|
- [fx](/docs/reference/fx) — `fx.emit`, `fx.deadLetters`, `fx.live`
|
|
563
407
|
- [Client](/docs/client/live) — `api.live` for browsers
|
|
564
|
-
- [Errors](/docs/reference/errors) — OKE1070 · OKE1071 · OKE1240 · OKE1250 · OKE1210
|
|
408
|
+
- [Errors](/docs/reference/errors) — OKE1070 · OKE1071 · OKE1072 · OKE1240 · OKE1250 · OKE1210
|
|
565
409
|
|
|
566
410
|
## Next
|
|
567
411
|
|
|
@@ -591,6 +591,11 @@ See [Durable Workflows](/docs/elements/flow/workflows).
|
|
|
591
591
|
are still one `on()` in source.
|
|
592
592
|
</Accordion>
|
|
593
593
|
|
|
594
|
+
<Accordion title="OKE1072 — Signal flow unnamed">
|
|
595
|
+
Cause: `A signal flow on "{trigger}" has no name.`
|
|
596
|
+
Fix: pass an explicit name — `on(handle, flow("workers.email", { do }))`.
|
|
597
|
+
</Accordion>
|
|
598
|
+
|
|
594
599
|
<Accordion title="Messages stuck inflight">
|
|
595
600
|
Wait for the 30s lease and the next drain/claim. There is no separate timeout daemon. A handler
|
|
596
601
|
still running past the lease can overlap with a reclaim — shorten the work or journal it.
|
|
@@ -629,7 +634,7 @@ See [Durable Workflows](/docs/elements/flow/workflows).
|
|
|
629
634
|
- [Consumers](/docs/elements/flow/consumers) — `on(signal)` next to Clock / CDC
|
|
630
635
|
- [Workflows](/docs/elements/flow/workflows) — `durable` + `fx.step` for idempotent side effects
|
|
631
636
|
- [fx](/docs/reference/fx) — `fx.emit`, `fx.deadLetters`
|
|
632
|
-
- [Errors](/docs/reference/errors) — OKE1071 · OKE1240 · OKE1250 · OKE1001
|
|
637
|
+
- [Errors](/docs/reference/errors) — OKE1071 · OKE1072 · OKE1240 · OKE1250 · OKE1001
|
|
633
638
|
|
|
634
639
|
## Next
|
|
635
640
|
|
|
@@ -98,6 +98,7 @@ string. Custom app codes stay message-less until registered. Full catalogs:
|
|
|
98
98
|
| `1060` | MCP tool duplicate | Two MCP tool bindings share the same tool name | Give each MCP tool exposure a unique name |
|
|
99
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
100
|
| `1071` | once-signal multi-flow | Two different Flows bound to the same `signal.once` | Use `signal.broadcast`, or bind only one Flow |
|
|
101
|
+
| `1072` | flow unnamed | Signal / Clock consumer still has no `unit.export` | Export from `flows/<unit>/` or pass a named `flow` |
|
|
101
102
|
| `1110` | schema missing | Domain table absent in `prod` — no auto-DDL | Run `oke db migrate` against this environment |
|
|
102
103
|
| `1210` | live resume gap | `Last-Event-ID` is not on the retained tape | Reconnect without the cursor; remaining tape replays |
|
|
103
104
|
| `1240` | orphan emit | Emit with zero subscribers and `optional` false | Add `on(signal, …)` or declare `optional: true` |
|
|
@@ -173,6 +174,12 @@ Thrown by specific subsystems — each names its own cause:
|
|
|
173
174
|
A resource mount and a handwritten route share the same method + path. Drop one binding.
|
|
174
175
|
</Accordion>
|
|
175
176
|
|
|
177
|
+
<Accordion title="OKE1072 Signal or Clock flow unnamed">
|
|
178
|
+
Cause: `A {kind} flow on "{trigger}" has no name.`
|
|
179
|
+
Fix: pass an explicit name — `on(handle, flow("unit.export", { do }))`. See
|
|
180
|
+
[Signal](/docs/elements/signal) · [Clock](/docs/elements/clock).
|
|
181
|
+
</Accordion>
|
|
182
|
+
|
|
176
183
|
<Accordion title="OKE1071 once signal bound to more than one Flow">
|
|
177
184
|
Cause: `Once signal "{signal}" is bound to more than one Flow ({flows}).` Use `signal.broadcast`
|
|
178
185
|
if each Flow should get a copy, or bind only one Flow. See [Once · Competing
|
|
@@ -168,8 +168,8 @@ flow({
|
|
|
168
168
|
```
|
|
169
169
|
|
|
170
170
|
Omit the name on tree files — the compiler stamps `unit.export` (e.g. `users.signup`).
|
|
171
|
-
Pass `flow("users.signup", { … })`
|
|
172
|
-
|
|
171
|
+
Pass `flow("users.signup", { … })` outside a unit folder, or for barrels / `fx.call`.
|
|
172
|
+
Nameless Signal / Clock consumers outside a unit fail **OKE1072**.
|
|
173
173
|
|
|
174
174
|
### `do` — the code that actually runs
|
|
175
175
|
|
|
@@ -45,9 +45,9 @@ const EXPRESS_ALLOW = ["src/plugins/headers.test.ts:"];
|
|
|
45
45
|
const OKID_PROMPT_ALLOW = [
|
|
46
46
|
"okid.md:",
|
|
47
47
|
"bun.lock:",
|
|
48
|
-
// Minified zod-core inside
|
|
49
|
-
// format validators whose names collide with peer library
|
|
50
|
-
// identifiers, not authored comparisons.
|
|
48
|
+
// Minified zod-core inside a locally built Console bundle (gitignored)
|
|
49
|
+
// registers string-format validators whose names collide with peer library
|
|
50
|
+
// names — vendor identifiers, not authored comparisons.
|
|
51
51
|
"src/console/ui-next/dist/",
|
|
52
52
|
];
|
|
53
53
|
|
|
@@ -2069,21 +2069,21 @@ export const app = oke({
|
|
|
2069
2069
|
});
|
|
2070
2070
|
});
|
|
2071
2071
|
|
|
2072
|
-
describe("extractManifest — inline Signal/Clock +
|
|
2072
|
+
describe("extractManifest — inline Signal/Clock + explicit / tree names", () => {
|
|
2073
2073
|
test("inline signal.once / clock.every stamp element maps and flow.trigger", async () => {
|
|
2074
2074
|
const source = `
|
|
2075
2075
|
import { on, flow, signal, clock } from "okengine";
|
|
2076
2076
|
|
|
2077
2077
|
on(
|
|
2078
2078
|
signal.once("link-clicked", { retries: 3, deadLetter: true }),
|
|
2079
|
-
flow({ do: async (input, fx) => {
|
|
2079
|
+
flow("hooks.linkClicked", { do: async (input, fx) => {
|
|
2080
2080
|
await fx.store("sql:links").set("x", input);
|
|
2081
2081
|
} }),
|
|
2082
2082
|
);
|
|
2083
2083
|
|
|
2084
2084
|
on(
|
|
2085
2085
|
clock.every("cleanup", "10m"),
|
|
2086
|
-
flow({ do: async (_input, fx) => {
|
|
2086
|
+
flow("ops.cleanup", { do: async (_input, fx) => {
|
|
2087
2087
|
await fx.store("sql:links").set("y", 1);
|
|
2088
2088
|
} }),
|
|
2089
2089
|
);
|
|
@@ -2095,16 +2095,16 @@ on(
|
|
|
2095
2095
|
deadLetter: true,
|
|
2096
2096
|
});
|
|
2097
2097
|
expect(manifest.clocks?.cleanup).toMatchObject({ every: "10m" });
|
|
2098
|
-
expect(manifest.flows?.["
|
|
2099
|
-
expect(manifest.flows?.cleanup?.trigger).toEqual({ every: "10m" });
|
|
2098
|
+
expect(manifest.flows?.["hooks.linkClicked"]?.trigger).toEqual({ signal: "link-clicked" });
|
|
2099
|
+
expect(manifest.flows?.["ops.cleanup"]?.trigger).toEqual({ every: "10m" });
|
|
2100
2100
|
});
|
|
2101
2101
|
|
|
2102
|
-
test("
|
|
2102
|
+
test("explicit flow name is independent of the trigger name", async () => {
|
|
2103
2103
|
const source = `
|
|
2104
2104
|
import { on, flow, signal, clock } from "okengine";
|
|
2105
2105
|
|
|
2106
2106
|
export const orderPlaced = signal.once("order-placed");
|
|
2107
|
-
on(orderPlaced, flow({ do: () => ({ ok: true }) }));
|
|
2107
|
+
on(orderPlaced, flow("orders.fulfill", { do: () => ({ ok: true }) }));
|
|
2108
2108
|
|
|
2109
2109
|
on(
|
|
2110
2110
|
clock.every("metrics.cleanup", "1h"),
|
|
@@ -2112,12 +2112,13 @@ on(
|
|
|
2112
2112
|
);
|
|
2113
2113
|
`;
|
|
2114
2114
|
const manifest = await extractFromSources({ "workers.ts": source });
|
|
2115
|
-
expect(manifest.flows?.["
|
|
2115
|
+
expect(manifest.flows?.["orders.fulfill"]?.trigger).toEqual({ signal: "order-placed" });
|
|
2116
2116
|
expect(manifest.flows?.["ops.sweep"]?.trigger).toEqual({ every: "1h" });
|
|
2117
2117
|
expect(manifest.flows?.["metrics.cleanup"]).toBeUndefined();
|
|
2118
|
+
expect(manifest.flows?.["order-placed"]).toBeUndefined();
|
|
2118
2119
|
});
|
|
2119
2120
|
|
|
2120
|
-
test("file-tree unit.export
|
|
2121
|
+
test("file-tree unit.export stamps a nameless Signal consumer", async () => {
|
|
2121
2122
|
const source = `
|
|
2122
2123
|
import { on, flow, signal } from "okengine";
|
|
2123
2124
|
export const onCreated = on(
|
|
@@ -2132,15 +2133,189 @@ export const onCreated = on(
|
|
|
2132
2133
|
expect(manifest.flows?.["note-created"]).toBeUndefined();
|
|
2133
2134
|
});
|
|
2134
2135
|
|
|
2135
|
-
test("two
|
|
2136
|
+
test("two differently-named flows on the same clock extract cleanly", async () => {
|
|
2137
|
+
const source = `
|
|
2138
|
+
import { on, flow, clock } from "okengine";
|
|
2139
|
+
export const tick = clock.every("metrics.tick", "1h");
|
|
2140
|
+
on(tick, flow("ops.sweep", { do: () => ({ a: true }) }));
|
|
2141
|
+
on(tick, flow("ops.report", { do: () => ({ b: true }) }));
|
|
2142
|
+
`;
|
|
2143
|
+
const manifest = await extractFromSources({ "clock-fanout.ts": source });
|
|
2144
|
+
expect(manifest.clocks?.["metrics.tick"]).toMatchObject({ every: "1h" });
|
|
2145
|
+
expect(manifest.flows?.["ops.sweep"]?.trigger).toEqual({ every: "1h" });
|
|
2146
|
+
expect(manifest.flows?.["ops.report"]?.trigger).toEqual({ every: "1h" });
|
|
2147
|
+
});
|
|
2148
|
+
|
|
2149
|
+
// Exact Clock docs Inline sample — explicit flow name, trigger written inline.
|
|
2150
|
+
const clockInlineExplicitDocsSample = `
|
|
2151
|
+
import { on, flow, clock } from "okengine";
|
|
2152
|
+
|
|
2153
|
+
export const pingExternal = on(
|
|
2154
|
+
clock.every("health.pingExternal", "30s"),
|
|
2155
|
+
flow("health.pingExternal", {
|
|
2156
|
+
plane: "operator",
|
|
2157
|
+
do: async (_, fx) => {
|
|
2158
|
+
await fx.call(pingUpstream);
|
|
2159
|
+
},
|
|
2160
|
+
}),
|
|
2161
|
+
);
|
|
2162
|
+
`;
|
|
2163
|
+
|
|
2164
|
+
test("docs Clock Inline (explicit name) extracts outside src/flows/<unit>/", async () => {
|
|
2165
|
+
const manifest = await extractFromSources({ "ping.ts": clockInlineExplicitDocsSample });
|
|
2166
|
+
expect(manifest.clocks?.["health.pingExternal"]?.every).toBe("30s");
|
|
2167
|
+
expect(manifest.flows?.["health.pingExternal"]?.trigger).toEqual({ every: "30s" });
|
|
2168
|
+
expect(manifest.flows?.["pingExternal"]).toBeUndefined();
|
|
2169
|
+
});
|
|
2170
|
+
|
|
2171
|
+
test("docs Clock named-export (shared schedule) extracts two explicit flows", async () => {
|
|
2172
|
+
const clockSource = `
|
|
2173
|
+
import { clock } from "okengine";
|
|
2174
|
+
|
|
2175
|
+
export const tickClock = clock.every("metrics.tick", "1h");
|
|
2176
|
+
`;
|
|
2177
|
+
const flowsSource = `
|
|
2178
|
+
import { on, flow } from "okengine";
|
|
2179
|
+
import { tickClock } from "@/clocks/metrics";
|
|
2180
|
+
|
|
2181
|
+
export const sweep = on(
|
|
2182
|
+
tickClock,
|
|
2183
|
+
flow("ops.sweep", {
|
|
2184
|
+
plane: "operator",
|
|
2185
|
+
do: async (_, fx) => {
|
|
2186
|
+
await fx.call(sweepMetrics);
|
|
2187
|
+
},
|
|
2188
|
+
}),
|
|
2189
|
+
);
|
|
2190
|
+
|
|
2191
|
+
export const report = on(
|
|
2192
|
+
tickClock,
|
|
2193
|
+
flow("ops.report", {
|
|
2194
|
+
plane: "operator",
|
|
2195
|
+
do: async (_, fx) => {
|
|
2196
|
+
await fx.call(reportMetrics);
|
|
2197
|
+
},
|
|
2198
|
+
}),
|
|
2199
|
+
);
|
|
2200
|
+
`;
|
|
2201
|
+
const manifest = await extractFromSources({
|
|
2202
|
+
"src/clocks/metrics.ts": clockSource,
|
|
2203
|
+
"src/flows/ops/metrics.ts": flowsSource,
|
|
2204
|
+
});
|
|
2205
|
+
expect(manifest.clocks?.["metrics.tick"]).toMatchObject({ every: "1h" });
|
|
2206
|
+
expect(manifest.flows?.["ops.sweep"]?.trigger).toEqual({ every: "1h" });
|
|
2207
|
+
expect(manifest.flows?.["ops.report"]?.trigger).toEqual({ every: "1h" });
|
|
2208
|
+
});
|
|
2209
|
+
});
|
|
2210
|
+
|
|
2211
|
+
describe("extractManifest — nameless Signal/Clock consumers fail OKE1072", () => {
|
|
2212
|
+
test("nameless flow({ do }) on signal.once fails OKE1072", async () => {
|
|
2213
|
+
const source = `
|
|
2214
|
+
import { on, flow, signal } from "okengine";
|
|
2215
|
+
on(signal.once("link-clicked"), flow({ do: () => ({ ok: true }) }));
|
|
2216
|
+
`;
|
|
2217
|
+
await expect(extractFromSources({ "once.ts": source })).rejects.toThrow(/OKE1072/);
|
|
2218
|
+
});
|
|
2219
|
+
|
|
2220
|
+
test("nameless flow({ do }) on signal.broadcast fails OKE1072", async () => {
|
|
2221
|
+
const source = `
|
|
2222
|
+
import { on, flow, signal } from "okengine";
|
|
2223
|
+
on(signal.broadcast("catalog.changed"), flow({ do: () => ({ ok: true }) }));
|
|
2224
|
+
`;
|
|
2225
|
+
await expect(extractFromSources({ "broadcast.ts": source })).rejects.toThrow(/OKE1072/);
|
|
2226
|
+
});
|
|
2227
|
+
|
|
2228
|
+
test("nameless flow({ do }) on signal.live fails OKE1072", async () => {
|
|
2136
2229
|
const source = `
|
|
2137
2230
|
import { on, flow, signal } from "okengine";
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2231
|
+
on(signal.live("order-status", { optional: true }), flow({ do: () => ({ ok: true }) }));
|
|
2232
|
+
`;
|
|
2233
|
+
await expect(extractFromSources({ "live.ts": source })).rejects.toThrow(/OKE1072/);
|
|
2234
|
+
});
|
|
2235
|
+
|
|
2236
|
+
test("nameless flow({ do }) on clock.every fails OKE1072", async () => {
|
|
2237
|
+
const source = `
|
|
2238
|
+
import { on, flow, clock } from "okengine";
|
|
2239
|
+
on(clock.every("cleanup", "10m"), flow({ do: () => ({ ok: true }) }));
|
|
2141
2240
|
`;
|
|
2142
|
-
await expect(extractFromSources({ "
|
|
2143
|
-
|
|
2241
|
+
await expect(extractFromSources({ "clock.ts": source })).rejects.toThrow(/OKE1072/);
|
|
2242
|
+
});
|
|
2243
|
+
|
|
2244
|
+
// Exact Signal docs Inline sample — tree path vs anywhere else.
|
|
2245
|
+
const signalInlineDocsSample = `
|
|
2246
|
+
import { on, flow, signal } from "okengine";
|
|
2247
|
+
import { z } from "zod";
|
|
2248
|
+
|
|
2249
|
+
export const ingestWebhook = on(
|
|
2250
|
+
signal.once("hooks.inbound", {
|
|
2251
|
+
schema: z.object({ id: z.string() }),
|
|
2252
|
+
retries: 3,
|
|
2253
|
+
deadLetter: true,
|
|
2254
|
+
}),
|
|
2255
|
+
flow({
|
|
2256
|
+
do: async ({ id }, fx) => {
|
|
2257
|
+
await fx.call(persistHook, { id });
|
|
2258
|
+
},
|
|
2259
|
+
}),
|
|
2260
|
+
);
|
|
2261
|
+
`;
|
|
2262
|
+
|
|
2263
|
+
test("docs Inline sample at src/flows/hooks/inbound.ts stamps hooks.ingestWebhook", async () => {
|
|
2264
|
+
const manifest = await extractFromSources({
|
|
2265
|
+
"src/flows/hooks/inbound.ts": signalInlineDocsSample,
|
|
2266
|
+
});
|
|
2267
|
+
expect(manifest.flows?.["hooks.ingestWebhook"]?.trigger).toEqual({ signal: "hooks.inbound" });
|
|
2268
|
+
expect(manifest.signals?.["hooks.inbound"]?.delivery).toBe("once");
|
|
2269
|
+
expect(manifest.flows?.["ingestWebhook"]).toBeUndefined();
|
|
2270
|
+
expect(manifest.flows?.["hooks.inbound"]).toBeUndefined();
|
|
2271
|
+
});
|
|
2272
|
+
|
|
2273
|
+
test("docs Inline sample outside src/flows/<unit>/ fails OKE1072", async () => {
|
|
2274
|
+
try {
|
|
2275
|
+
await extractFromSources({ "inbound.ts": signalInlineDocsSample });
|
|
2276
|
+
expect.unreachable("extract should throw OKE1072");
|
|
2277
|
+
} catch (err) {
|
|
2278
|
+
expect(err).toBeInstanceOf(Error);
|
|
2279
|
+
const message = (err as Error).message;
|
|
2280
|
+
expect(message).toMatch(/OKE1072/);
|
|
2281
|
+
expect(message).toContain("hooks.inbound");
|
|
2282
|
+
expect(message).toContain('flow("unit.export"');
|
|
2283
|
+
expect(message).toContain("src/flows/<unit>/");
|
|
2284
|
+
}
|
|
2285
|
+
});
|
|
2286
|
+
|
|
2287
|
+
test("docs Inline sample under src/signals/ fails OKE1072", async () => {
|
|
2288
|
+
await expect(
|
|
2289
|
+
extractFromSources({ "src/signals/inbound.ts": signalInlineDocsSample }),
|
|
2290
|
+
).rejects.toThrow(/OKE1072/);
|
|
2291
|
+
});
|
|
2292
|
+
|
|
2293
|
+
const clockInlineDocsSample = `
|
|
2294
|
+
import { on, flow, clock } from "okengine";
|
|
2295
|
+
|
|
2296
|
+
export const pingExternal = on(
|
|
2297
|
+
clock.every("health.pingExternal", "30s"),
|
|
2298
|
+
flow({
|
|
2299
|
+
plane: "operator",
|
|
2300
|
+
do: async (_, fx) => {
|
|
2301
|
+
await fx.call(pingUpstream);
|
|
2302
|
+
},
|
|
2303
|
+
}),
|
|
2304
|
+
);
|
|
2305
|
+
`;
|
|
2306
|
+
|
|
2307
|
+
test("docs Clock Inline sample at src/flows/health/ping.ts stamps health.pingExternal", async () => {
|
|
2308
|
+
const manifest = await extractFromSources({
|
|
2309
|
+
"src/flows/health/ping.ts": clockInlineDocsSample,
|
|
2310
|
+
});
|
|
2311
|
+
expect(manifest.flows?.["health.pingExternal"]?.trigger).toEqual({ every: "30s" });
|
|
2312
|
+
expect(manifest.clocks?.["health.pingExternal"]?.every).toBe("30s");
|
|
2313
|
+
expect(manifest.flows?.["pingExternal"]).toBeUndefined();
|
|
2314
|
+
});
|
|
2315
|
+
|
|
2316
|
+
test("docs Clock Inline sample outside src/flows/<unit>/ fails OKE1072", async () => {
|
|
2317
|
+
await expect(extractFromSources({ "ping.ts": clockInlineDocsSample })).rejects.toThrow(
|
|
2318
|
+
/OKE1072/,
|
|
2144
2319
|
);
|
|
2145
2320
|
});
|
|
2146
2321
|
});
|
package/src/compiler/extract.ts
CHANGED
|
@@ -55,7 +55,7 @@ import {
|
|
|
55
55
|
type InferBinding,
|
|
56
56
|
type Literal,
|
|
57
57
|
} from "./effects-infer.ts";
|
|
58
|
-
import { nameFromFlowFile, pathFromFlowFile } from "./flow-path.ts";
|
|
58
|
+
import { isFlowsTreeFile, nameFromFlowFile, pathFromFlowFile } from "./flow-path.ts";
|
|
59
59
|
import {
|
|
60
60
|
defaultListInSchema,
|
|
61
61
|
jsonSchemaFromAst,
|
|
@@ -1852,13 +1852,21 @@ function registerFlow(args: {
|
|
|
1852
1852
|
const trigger = args.triggerNode
|
|
1853
1853
|
? parseTrigger(args.triggerNode, args.scope, args.file.path)
|
|
1854
1854
|
: undefined;
|
|
1855
|
-
const inherited = elementTriggerName(args.triggerNode, args.scope);
|
|
1856
1855
|
|
|
1856
|
+
const signalOrClock = isSignalOrClockTrigger(trigger);
|
|
1857
|
+
const treeName =
|
|
1858
|
+
!signalOrClock || isFlowsTreeFile(args.file.path)
|
|
1859
|
+
? nameFromFlowFile(args.file.path, args.exportName)
|
|
1860
|
+
: undefined;
|
|
1861
|
+
|
|
1862
|
+
// Signal / Clock: explicit `flow("name")` or a real `src/flows/<unit>/`
|
|
1863
|
+
// tree stamp. A bare `export const` outside that folder is not a name —
|
|
1864
|
+
// **OKE1072**, same as `oke()`. HTTP still falls through to exportName.
|
|
1857
1865
|
const name =
|
|
1858
1866
|
stringArg(args.flowCall.arguments[0]) ??
|
|
1859
|
-
|
|
1867
|
+
treeName ??
|
|
1868
|
+
(signalOrClock ? unnamedElementFlowName(args.triggerNode, args.scope, trigger) : undefined) ??
|
|
1860
1869
|
args.exportName ??
|
|
1861
|
-
inherited ??
|
|
1862
1870
|
`flow_${Object.keys(args.scope.flows).length + 1}`;
|
|
1863
1871
|
|
|
1864
1872
|
if (args.exportName) {
|
|
@@ -2414,10 +2422,22 @@ function assertOnceSignalSingleFlow(scope: ProjectScope): void {
|
|
|
2414
2422
|
}
|
|
2415
2423
|
}
|
|
2416
2424
|
|
|
2425
|
+
/**
|
|
2426
|
+
* True when the parsed trigger is a Signal or Clock (cron / every).
|
|
2427
|
+
*
|
|
2428
|
+
* @param parsed - Parsed `on()` trigger
|
|
2429
|
+
*/
|
|
2430
|
+
function isSignalOrClockTrigger(parsed: ParsedTrigger | undefined): boolean {
|
|
2431
|
+
const t = parsed?.trigger;
|
|
2432
|
+
return Boolean(t && (t.signal || t.cron || t.every));
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2417
2435
|
/**
|
|
2418
2436
|
* Stable Signal / Clock name on `on()`'s first argument, when one exists.
|
|
2419
2437
|
* HTTP has no inherent name of this kind — returns undefined.
|
|
2420
2438
|
*
|
|
2439
|
+
* Used only for **OKE1072** diagnostics — never as a Flow name.
|
|
2440
|
+
*
|
|
2421
2441
|
* @param node - Trigger AST
|
|
2422
2442
|
* @param scope - Project scope
|
|
2423
2443
|
*/
|
|
@@ -2450,6 +2470,29 @@ function elementTriggerName(node: AstNode | undefined, scope: ProjectScope): str
|
|
|
2450
2470
|
return undefined;
|
|
2451
2471
|
}
|
|
2452
2472
|
|
|
2473
|
+
/**
|
|
2474
|
+
* Fail loud when a Signal / Clock consumer has no explicit or tree-derived name.
|
|
2475
|
+
* HTTP still falls through to the generic `flow_*` placeholder (OKE1045 at boot).
|
|
2476
|
+
*
|
|
2477
|
+
* @param triggerNode - Trigger AST
|
|
2478
|
+
* @param scope - Project scope
|
|
2479
|
+
* @param parsed - Parsed trigger
|
|
2480
|
+
* @returns Never for Signal / Clock; `undefined` otherwise
|
|
2481
|
+
*/
|
|
2482
|
+
function unnamedElementFlowName(
|
|
2483
|
+
triggerNode: AstNode | undefined,
|
|
2484
|
+
scope: ProjectScope,
|
|
2485
|
+
parsed: ParsedTrigger | undefined,
|
|
2486
|
+
): undefined {
|
|
2487
|
+
if (!isSignalOrClockTrigger(parsed)) return undefined;
|
|
2488
|
+
const triggerName = elementTriggerName(triggerNode, scope);
|
|
2489
|
+
const kind = parsed?.trigger?.signal ? "signal" : "clock";
|
|
2490
|
+
const target = triggerName ? `${kind} "${triggerName}"` : kind;
|
|
2491
|
+
throw new Error(
|
|
2492
|
+
`OKE1072: nameless flow({ do }) bound to ${target}. Use flow("unit.export", { do }) or export it from a src/flows/<unit>/ file so the tree can stamp unit.export.`,
|
|
2493
|
+
);
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2453
2496
|
/**
|
|
2454
2497
|
* Inline `signal.once(…)` / `clock.every(…)` as `on()`'s first argument.
|
|
2455
2498
|
*
|