okengine 0.6.1 → 0.7.0
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/AGENTS.md +2 -2
- package/README.md +73 -181
- package/package.json +2 -2
- package/site/content/docs/console/clock.mdx +25 -5
- package/site/content/docs/console/signals.mdx +13 -4
- package/site/content/docs/elements/ai.mdx +29 -2
- package/site/content/docs/elements/channel.mdx +19 -13
- package/site/content/docs/elements/clock.mdx +56 -24
- package/site/content/docs/elements/flow.mdx +2 -0
- package/site/content/docs/elements/gate.mdx +8 -1
- package/site/content/docs/elements/signal.mdx +173 -29
- package/site/content/docs/elements/store.mdx +464 -73
- package/site/content/docs/elements/vault.mdx +15 -5
- package/site/content/docs/get-started/basic-usage.mdx +13 -6
- package/site/content/docs/get-started/installation.mdx +49 -13
- package/site/content/docs/get-started/introduction.mdx +1 -1
- package/site/content/docs/reference/cli.md +30 -2
- package/site/content/docs/reference/configuration.mdx +13 -13
- package/site/content/docs/reference/environment-variables.mdx +12 -10
- package/site/content/docs/reference/errors.mdx +12 -11
- package/site/content/docs/reference/fx.mdx +15 -13
- package/site/content/docs/reference/security.md +3 -1
- package/src/cli/ai-setup/ai-setup.test.ts +144 -0
- package/src/cli/ai-setup/apply.ts +214 -0
- package/src/cli/ai-setup/catalog.ts +263 -0
- package/src/cli/ai-setup/detect-ollama.ts +166 -0
- package/src/cli/ai-setup/index.ts +228 -0
- package/src/cli/ai-setup/prompts.ts +649 -0
- package/src/cli/ai-setup/recommend.test.ts +100 -0
- package/src/cli/ai-setup/recommend.ts +203 -0
- package/src/cli/ai.ts +38 -0
- package/src/cli/db-auto-push.test.ts +6 -1
- package/src/cli/db-auto-push.ts +8 -3
- package/src/cli/dev-db-push.test.ts +59 -0
- package/src/cli/dev.test.ts +40 -0
- package/src/cli/dev.ts +55 -1
- package/src/cli/docker-clean.test.ts +179 -0
- package/src/cli/docker-clean.ts +406 -0
- package/src/cli/docker.ts +16 -4
- package/src/cli/ensure-drizzle-config.ts +1 -1
- package/src/cli/hero-meta.test.ts +6 -5
- package/src/cli/hero-meta.ts +7 -1
- package/src/cli/index.ts +5 -0
- package/src/cli/load-config.ts +4 -4
- package/src/cli/openbao-bootstrap.test.ts +1 -0
- package/src/cli/openbao-bootstrap.ts +9 -2
- package/src/cli/registry.ts +79 -1
- package/src/client/live-gap.test.ts +35 -0
- package/src/client/transport.test.ts +21 -0
- package/src/client/transport.ts +27 -5
- package/src/compiler/fixtures/skyport/oke.config.ts +1 -1
- package/src/config/define-config.test.ts +6 -6
- package/src/config/index.ts +1 -1
- package/src/console/server/console.test.ts +45 -0
- package/src/console/server/flows.ts +39 -9
- package/src/console/server/i18n.ts +41 -0
- package/src/console/server/vault.ts +12 -38
- package/src/console/ui/dist/assets/index-ClX0ZCe5.js +10 -0
- package/src/console/ui/dist/assets/{panel-access-BGv45snf.js → panel-access-ri1X9Otj.js} +1 -1
- package/src/console/ui/dist/assets/{panel-ai-B2S7LEii.js → panel-ai-DLHjRoJk.js} +1 -1
- package/src/console/ui/dist/assets/{panel-architecture-D7UJh91v.js → panel-architecture-DI3bQF3J.js} +1 -1
- package/src/console/ui/dist/assets/{panel-channels-9T3ybqRu.js → panel-channels-BDYWX29p.js} +1 -1
- package/src/console/ui/dist/assets/{panel-clock-Cb1UXGRQ.js → panel-clock-i-JpTjVB.js} +1 -1
- package/src/console/ui/dist/assets/{panel-diff-DmYbKWmN.js → panel-diff-CFuWxiXy.js} +1 -1
- package/src/console/ui/dist/assets/{panel-flows-PiHwT55z.js → panel-flows-B8TRhcoe.js} +1 -1
- package/src/console/ui/dist/assets/{panel-gates-BQGYXvjT.js → panel-gates-CfkBgco7.js} +1 -1
- package/src/console/ui/dist/assets/{panel-overview-BBnRO18l.js → panel-overview-DONDzBd2.js} +1 -1
- package/src/console/ui/dist/assets/{panel-plugins-D0PsmVw2.js → panel-plugins-DDJwTehL.js} +1 -1
- package/src/console/ui/dist/assets/{panel-runs-CWuRDe0r.js → panel-runs-D5zf-D9c.js} +1 -1
- package/src/console/ui/dist/assets/{panel-signals-Bbg4ewpP.js → panel-signals-Dsxdu_AR.js} +1 -1
- package/src/console/ui/dist/assets/{panel-store-CPCbsDRa.js → panel-store-hy7O8HOs.js} +1 -1
- package/src/console/ui/dist/assets/{panel-traces-DVAzuA_S.js → panel-traces-CBiAe5go.js} +1 -1
- package/src/console/ui/dist/assets/{panel-vault-D1_MvOmo.js → panel-vault-Za1GyJfM.js} +1 -1
- package/src/console/ui/dist/index.html +1 -1
- package/src/console/ui/shell/components/ui.tsx +5 -1
- package/src/console/ui/shell/setup/Wizard.tsx +26 -5
- package/src/docker/cleanup.test.ts +193 -0
- package/src/docker/cleanup.ts +355 -0
- package/src/docker/docker.test.ts +4 -0
- package/src/docker/index.ts +19 -0
- package/src/docker/recipes/ollama.ts +10 -5
- package/src/drivers/ai-ollama.ts +45 -2
- package/src/drivers/ai-providers.test.ts +31 -0
- package/src/drivers/index.ts +3 -0
- package/src/drivers/signal-engine.ts +98 -25
- package/src/drivers/signal-nats.ts +4 -4
- package/src/drivers/signal-postgres.ts +158 -64
- package/src/drivers/signal-redis.ts +4 -4
- package/src/drivers/signal-types.ts +61 -2
- package/src/drivers/vault-driver-removal.test.ts +5 -1
- package/src/elements/clock/chaos-child.ts +150 -0
- package/src/elements/clock/chaos.test.ts +204 -0
- package/src/elements/clock/reconcile.ts +110 -0
- package/src/elements/clock/runtime.ts +11 -0
- package/src/elements/clock.test.ts +164 -2
- package/src/elements/clock.ts +1 -0
- package/src/elements/gate/runtime.ts +6 -0
- package/src/elements/signal/chaos-child.ts +41 -4
- package/src/elements/signal/declare.ts +4 -1
- package/src/elements/signal/delivery-modes.test.ts +179 -0
- package/src/elements/signal/key-ordering.test.ts +306 -0
- package/src/elements/signal/lease-reclaim.test.ts +123 -0
- package/src/elements/signal/optional-emit.test.ts +76 -0
- package/src/elements/signal/order-lifecycle.test.ts +130 -0
- package/src/elements/signal/orphan-messages.test.ts +152 -0
- package/src/elements/signal/runtime.ts +11 -4
- package/src/elements/signal/schema-emit.test.ts +107 -0
- package/src/elements/signal.test.ts +89 -5
- package/src/elements/store/files-fx.ts +104 -0
- package/src/elements/store/files-image.test.ts +251 -0
- package/src/elements/store/files-image.ts +494 -0
- package/src/elements/store/runtime.ts +60 -0
- package/src/elements/store.ts +24 -0
- package/src/elements/vault/boot-chain.ts +150 -0
- package/src/elements/vault/runtime.ts +8 -0
- package/src/kernel/app.ts +3 -1
- package/src/kernel/boot-bind/clock.ts +52 -7
- package/src/kernel/boot-bind/gate.ts +71 -3
- package/src/kernel/boot-bind/honor-config.test.ts +247 -0
- package/src/kernel/boot-bind/signal.ts +64 -7
- package/src/kernel/boot-bind/vault.ts +27 -8
- package/src/kernel/boot.ts +10 -2
- package/src/kernel/errors.ts +8 -2
- package/src/kernel/fx.test.ts +13 -0
- package/src/kernel/fx.ts +27 -4
- package/src/runtime/dev-request-log.test.ts +20 -1
- package/src/runtime/dev-request-log.ts +38 -0
- package/src/term.test.ts +15 -0
- package/src/term.ts +8 -3
- package/src/console/ui/dist/assets/index-CjxwRGVv.js +0 -10
|
@@ -85,52 +85,79 @@ do: async (input, fx) => {
|
|
|
85
85
|
|
|
86
86
|
<ClockSchedules />
|
|
87
87
|
|
|
88
|
-
Both are triggers consumed with the same `on(trigger, flow)` — the flow underneath does not know the
|
|
88
|
+
Both are triggers consumed with the same `on(trigger, flow)` — the flow underneath does not know the
|
|
89
|
+
difference.
|
|
89
90
|
|
|
90
91
|
### `clock()` options
|
|
91
92
|
|
|
92
|
-
| Option | Type | Default | Meaning
|
|
93
|
-
| ------------- | ------- | ------- |
|
|
94
|
-
| `cron` | string | — | Cron expression `m h dom mon dow` (this or `every` required)
|
|
95
|
-
| `every` | string | — | Fixed interval: `"30s"` · `"10m"` · `"1h"` · `"7d"`
|
|
96
|
-
| `timezone` | string | `"UTC"` | IANA timezone for cron evaluation
|
|
97
|
-
| `overridable` | boolean | `false` | Allow the Console to edit
|
|
98
|
-
| `description` | string | — | Human title in the Console (falls back to the clock name)
|
|
93
|
+
| Option | Type | Default | Meaning |
|
|
94
|
+
| ------------- | ------- | ------- | -------------------------------------------------------------- |
|
|
95
|
+
| `cron` | string | — | Cron expression `m h dom mon dow` (this or `every` required) |
|
|
96
|
+
| `every` | string | — | Fixed interval: `"30s"` · `"10m"` · `"1h"` · `"7d"` |
|
|
97
|
+
| `timezone` | string | `"UTC"` | IANA timezone for cron evaluation |
|
|
98
|
+
| `overridable` | boolean | `false` | Allow the Console to **edit** the schedule (pause is separate) |
|
|
99
|
+
| `description` | string | — | Human title in the Console (falls back to the clock name) |
|
|
99
100
|
|
|
100
101
|
## Sleeping inside a flow
|
|
101
102
|
|
|
102
|
-
|
|
103
|
+
<ClockSleep />
|
|
104
|
+
|
|
105
|
+
`fx.clock.sleep(label, duration)` is a **durable** sleep: in a `durable: true` flow the wake time is
|
|
106
|
+
journaled, so the flow resumes after restarts and deploys instead of losing its place. The `label`
|
|
107
|
+
names the step in the journal — it is what the Console shows when you inspect a sleeping run.
|
|
103
108
|
|
|
104
109
|
In a non-durable flow the same call resolves immediately, so code reads identically in tests.
|
|
105
110
|
|
|
111
|
+
Completed journal steps never re-run on resume — a process killed mid-flow continues at the next
|
|
112
|
+
unfinished step.
|
|
113
|
+
|
|
106
114
|
## What the runtime guarantees
|
|
107
115
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
|
111
|
-
|
|
|
112
|
-
|
|
|
113
|
-
|
|
|
116
|
+
<ClockCatchUp />
|
|
117
|
+
|
|
118
|
+
| Guarantee | What it means |
|
|
119
|
+
| ------------------ | -------------------------------------------------------------------------------------------------- |
|
|
120
|
+
| Leader election | Instances that share one CronStore fire a given tick once (lease TTL, default 30s) |
|
|
121
|
+
| Catch-up `"one"` | Health counts every missed slot; the runtime still fires **once** when overdue — not once per miss |
|
|
122
|
+
| Reconciled at boot | Named clocks land in `oke_crons`; the scheduler reads the Store, not the source |
|
|
123
|
+
| DST detection | Ambiguous local times get a Console warning — schedules are not rewritten or blocked |
|
|
124
|
+
|
|
125
|
+
**Leader election needs a shared store.** The default in-memory CronStore is per process. Multi-replica
|
|
126
|
+
exclusivity only holds when every instance opens the same durable CronStore (file-backed store is
|
|
127
|
+
available for that; a Postgres cron driver is not wired yet).
|
|
128
|
+
|
|
129
|
+
**Catch-up example:** an hourly clock down for five hours reports `missedRuns: 5` and
|
|
130
|
+
`catchUp: "one"`, then a single tick runs the handler once.
|
|
131
|
+
|
|
132
|
+
## DST warnings (detect only)
|
|
133
|
+
|
|
134
|
+
Simple daily crons (`M H * * *`) in a DST-observing zone can hit a **gap** (spring forward — local time skipped) or an **overlap** (fall back — local time occurs twice).
|
|
135
|
+
|
|
136
|
+
Detection attaches `dstAmbiguity` on the cron row and the Console shows it. `oke doctor` does not check DST. The scheduler does not rewrite the expression.
|
|
137
|
+
|
|
138
|
+
**Consequence:** fall-back days can list two civil instants an hour apart. The default leader lease (30s) is shorter than that gap, so a lease alone is not a “fire once on overlap” policy. Prefer UTC, or a wall time outside the transition window, when double-fire would hurt.
|
|
114
139
|
|
|
115
140
|
## Per-environment drivers
|
|
116
141
|
|
|
117
142
|
```typescript title="oke.config.ts"
|
|
118
143
|
drivers: {
|
|
119
|
-
clock: { local: "memory", docker: "
|
|
144
|
+
clock: { local: "memory", docker: "file", test: "frozen", prod: "file" },
|
|
120
145
|
},
|
|
121
146
|
```
|
|
122
147
|
|
|
123
|
-
| Driver
|
|
124
|
-
|
|
|
125
|
-
| `memory`
|
|
126
|
-
| `
|
|
127
|
-
| `frozen`
|
|
148
|
+
| Driver | Behavior |
|
|
149
|
+
| -------- | ---------------------------------------------------------------------------------------- |
|
|
150
|
+
| `memory` | In-process CronStore + timers — single process; lost on exit |
|
|
151
|
+
| `file` | On-disk CronStore (`.oke/crons.json`) — multi-process leader election on a shared volume |
|
|
152
|
+
| `frozen` | Deterministic test clock — time advances only when the test says so |
|
|
128
153
|
|
|
129
154
|
`frozen` is why the no-`Date.now()` rule pays off: tests inject time travel through `fx.clock` and every flow obeys it automatically.
|
|
130
155
|
|
|
131
156
|
## Operating schedules from the Console
|
|
132
157
|
|
|
133
|
-
The Console (`:6533` → Clock) lists every named clock with
|
|
158
|
+
The Console (`:6533` → Clock) lists every named clock with health — drift, overdue, missed runs + catch-up `"one"`, and which replica holds the leader lease.
|
|
159
|
+
|
|
160
|
+
From there you can **pause** any cron, **wake early** a sleeping durable run, or **edit the schedule** when `overridable: true`. Edit on a locked clock throws `ScheduleNotOverridableError` (`clock "…" is not overridable`).
|
|
134
161
|
|
|
135
162
|
## Troubleshooting
|
|
136
163
|
|
|
@@ -142,18 +169,23 @@ Replace it with `fx.clock.now()`. Direct time calls bypass the injected clock, s
|
|
|
142
169
|
</Accordion>
|
|
143
170
|
<Accordion title="The server was down and the cron didn't catch up">
|
|
144
171
|
|
|
145
|
-
That is by design: catch-up policy is `"one"` — the schedule fires a single time after downtime, never a storm of one-run-per-missed-tick. If you genuinely need backfill, trigger the flow from the Console.
|
|
172
|
+
That is by design: catch-up policy is `"one"` — the schedule fires a single time after downtime, never a storm of one-run-per-missed-tick. Health still shows how many slots were missed. If you genuinely need backfill, trigger the flow from the Console.
|
|
146
173
|
|
|
147
174
|
</Accordion>
|
|
148
175
|
<Accordion title="Console won't let me edit a schedule">
|
|
149
176
|
|
|
150
|
-
The clock was declared without `overridable: true`. Add it and redeploy — the restriction is deliberate, so only schedules you marked as operator-tunable can drift from code.
|
|
177
|
+
The clock was declared without `overridable: true`. Add it and redeploy — the restriction is deliberate, so only schedules you marked as operator-tunable can drift from code. Pause does not require `overridable`.
|
|
151
178
|
|
|
152
179
|
</Accordion>
|
|
153
180
|
<Accordion title="How do I run something once, later — not recurring?">
|
|
154
181
|
|
|
155
182
|
Emit it from inside a flow with `fx.clock.sleep(label, duration)` before the work, in a `durable: true` flow. The sleep survives restarts, so "remind me in 7 days" is one line, not a cron row.
|
|
156
183
|
|
|
184
|
+
</Accordion>
|
|
185
|
+
<Accordion title="Two replicas both ran my cron">
|
|
186
|
+
|
|
187
|
+
They are not sharing one CronStore. In-memory leases are per process. Point every replica at the same durable cron store, or run a single scheduler instance until the Postgres clock driver ships.
|
|
188
|
+
|
|
157
189
|
</Accordion>
|
|
158
190
|
</Accordions>
|
|
159
191
|
|
|
@@ -205,6 +205,8 @@ Everything a flow may touch, on one object:
|
|
|
205
205
|
|
|
206
206
|
## Durability — flows that survive the process
|
|
207
207
|
|
|
208
|
+
<FlowDurable />
|
|
209
|
+
|
|
208
210
|
Set `durable: true` and every `fx` call is journaled. Wrap side effects in `fx.step` and they never re-run on replay:
|
|
209
211
|
|
|
210
212
|
```typescript
|
|
@@ -104,8 +104,14 @@ A denied request never reaches `do`. It returns one of three typed failures:
|
|
|
104
104
|
|
|
105
105
|
</Steps>
|
|
106
106
|
|
|
107
|
+
## First denial wins
|
|
108
|
+
|
|
107
109
|
<GatePipeline />
|
|
108
110
|
|
|
111
|
+
`.gate(member, canBook, fair)` evaluates left to right — first denial wins
|
|
112
|
+
(`Unauthorized` · `Forbidden` · `RateLimited`); later gates are skipped. `do`
|
|
113
|
+
runs only when every gate passed.
|
|
114
|
+
|
|
109
115
|
## `oke({ gate })` bag
|
|
110
116
|
|
|
111
117
|
| Option | Type | Default | Meaning |
|
|
@@ -159,7 +165,8 @@ Fine-grained authorization (`gate.scope`, roles) stays opt-in — attach it only
|
|
|
159
165
|
| `leaky-bucket` | Smooths output to a constant rate |
|
|
160
166
|
|
|
161
167
|
All five run as atomic Lua on the kv driver — correct under concurrency, identical on the memory
|
|
162
|
-
driver in tests.
|
|
168
|
+
driver in tests. Boot opens a dedicated `oke:gates` namespace on **`drivers.store.kv`**
|
|
169
|
+
(`memory` locally · `redis` in docker/prod) so multi-instance rate limits share counters.
|
|
163
170
|
|
|
164
171
|
## Auth posture at boot
|
|
165
172
|
|
|
@@ -43,9 +43,8 @@ Producers never touch a broker client — they call `fx.emit`:
|
|
|
43
43
|
```typescript title="src/flows/orders/place.ts"
|
|
44
44
|
do: async (input, fx) => {
|
|
45
45
|
await fx.store(db).insert(orders).values({ id: input.id /* … */ });
|
|
46
|
-
await fx.emit(orderPlaced, { orderId: input.id, total: input.total });
|
|
47
|
-
//
|
|
48
|
-
// the message is only ever sent if the row is committed.
|
|
46
|
+
await fx.emit(orderPlaced, { orderId: input.id, total: input.total }, { key: input.id });
|
|
47
|
+
// optional `key` keeps same-order events serialized under `once`
|
|
49
48
|
};
|
|
50
49
|
```
|
|
51
50
|
|
|
@@ -62,7 +61,7 @@ export const sendConfirmation = on(
|
|
|
62
61
|
flow({
|
|
63
62
|
in: z.object({ orderId: z.string(), total: z.number() }),
|
|
64
63
|
do: async (input, fx) => {
|
|
65
|
-
await fx.
|
|
64
|
+
await fx.send(orderConfirmed, { to: "user-1", data: input });
|
|
66
65
|
},
|
|
67
66
|
}),
|
|
68
67
|
);
|
|
@@ -82,14 +81,105 @@ The declaration is identical in shape for all three — switching physics later
|
|
|
82
81
|
|
|
83
82
|
### Options
|
|
84
83
|
|
|
85
|
-
| Option | Type | Default | Meaning
|
|
86
|
-
| ------------- | --------------------------------- | ---------------- |
|
|
87
|
-
| `delivery` | `"once" \| "broadcast" \| "live"` | — (**required**) | Delivery physics
|
|
88
|
-
| `description` | string | — | Human title in the Console (falls back to the signal name)
|
|
89
|
-
| `schema` | zod / Standard Schema | — | Payload contract
|
|
90
|
-
| `retries` | number | `3` |
|
|
91
|
-
| `deadLetter` | boolean | `true` | Preserve exhausted messages in the DLQ (`once`)
|
|
92
|
-
| `optional` | boolean | `false` | Allow emitting while nobody subscribes (skip the orphan check)
|
|
84
|
+
| Option | Type | Default | Meaning |
|
|
85
|
+
| ------------- | --------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------- |
|
|
86
|
+
| `delivery` | `"once" \| "broadcast" \| "live"` | — (**required**) | Delivery physics |
|
|
87
|
+
| `description` | string | — | Human title in the Console (falls back to the signal name) |
|
|
88
|
+
| `schema` | zod / Standard Schema | — | Payload contract — validated at emit (**OKE1043** on failure); Manifest docs |
|
|
89
|
+
| `retries` | number | `3` | Retry budget for `once` — dead-letter when `attempts > retries` (`retries + 1` total invocations) |
|
|
90
|
+
| `deadLetter` | boolean | `true` | Preserve exhausted messages in the DLQ (`once`) |
|
|
91
|
+
| `optional` | boolean | `false` | Allow emitting while nobody subscribes (skip the orphan check) |
|
|
92
|
+
|
|
93
|
+
## One order, three modes
|
|
94
|
+
|
|
95
|
+
One placed order can need all three physics at once — pick a signal per job, not one signal stretched across modes:
|
|
96
|
+
|
|
97
|
+
| Signal | Mode | Job |
|
|
98
|
+
| --------------- | ----------- | ----------------------------------------------------------- |
|
|
99
|
+
| `order-placed` | `once` | Fulfillment job — competing workers, retries, DLQ |
|
|
100
|
+
| `order-changed` | `broadcast` | Fan-out — cache invalidation **and** customer notification |
|
|
101
|
+
| `order-status` | `live` | Status feed — retained history a late subscriber can replay |
|
|
102
|
+
|
|
103
|
+
```typescript title="src/signals.ts"
|
|
104
|
+
import { signal } from "okengine";
|
|
105
|
+
import { z } from "zod";
|
|
106
|
+
|
|
107
|
+
export const orderPlaced = signal("order-placed", {
|
|
108
|
+
schema: z.object({ orderId: z.string(), total: z.number() }),
|
|
109
|
+
delivery: "once",
|
|
110
|
+
retries: 2,
|
|
111
|
+
deadLetter: true,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
export const orderChanged = signal("order-changed", {
|
|
115
|
+
schema: z.object({ orderId: z.string(), kind: z.literal("placed") }),
|
|
116
|
+
delivery: "broadcast",
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
export const orderStatus = signal("order-status", {
|
|
120
|
+
schema: z.object({
|
|
121
|
+
orderId: z.string(),
|
|
122
|
+
status: z.enum(["placed", "fulfilling", "shipped"]),
|
|
123
|
+
}),
|
|
124
|
+
delivery: "live",
|
|
125
|
+
optional: true, // clients may not be connected yet
|
|
126
|
+
});
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`once` — two fulfillment workers compete; **exactly one** claims each message (at-least-once; make the handler idempotent):
|
|
130
|
+
|
|
131
|
+
```typescript title="src/flows/fulfillment/on-order.ts"
|
|
132
|
+
on(
|
|
133
|
+
orderPlaced,
|
|
134
|
+
flow({
|
|
135
|
+
name: "fulfillment.onOrder",
|
|
136
|
+
do: async ({ orderId }, fx) => {
|
|
137
|
+
await fx.emit(orderChanged, { orderId, kind: "placed" });
|
|
138
|
+
await fx.emit(orderStatus, { orderId, status: "fulfilling" });
|
|
139
|
+
await fx.emit(orderStatus, { orderId, status: "shipped" });
|
|
140
|
+
},
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`broadcast` — every subscriber gets its own copy of the same domain event:
|
|
146
|
+
|
|
147
|
+
```typescript title="src/flows/orders/side-effects.ts"
|
|
148
|
+
on(
|
|
149
|
+
orderChanged,
|
|
150
|
+
flow({
|
|
151
|
+
name: "cache.onOrderChanged",
|
|
152
|
+
do: async ({ orderId }, fx) => {
|
|
153
|
+
/* invalidate order:{orderId} */
|
|
154
|
+
},
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
on(
|
|
159
|
+
orderChanged,
|
|
160
|
+
flow({
|
|
161
|
+
name: "notify.onOrderChanged",
|
|
162
|
+
do: async ({ orderId }, fx) => {
|
|
163
|
+
await fx.send(orderConfirmed, { to: "user-1", data: { orderId } });
|
|
164
|
+
},
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`live` — retain status updates and replay the **full history** to a late `bus.live()` subscriber (tracking UI, ops feed).
|
|
170
|
+
|
|
171
|
+
Today that subscription is a **server-side** bus API. `createClient` does not yet expose SSE / WebSocket / `client.live` — see [Client](/docs/reference/client#signal-and-live-queries).
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
await fx.emit(orderStatus, { orderId, status: "placed" });
|
|
175
|
+
|
|
176
|
+
// Late subscriber (server / test harness) receives placed → fulfilling → shipped.
|
|
177
|
+
const unsub = await bus.live("order-status", (payload) => {
|
|
178
|
+
/* push to UI */
|
|
179
|
+
});
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
**Why separate signals:** delivery is fixed per declaration. Competing work stays on `once`; fan-out stays on `broadcast`; the client-visible timeline stays on `live`. Switching a word later is cheap; mixing physics on one name is not.
|
|
93
183
|
|
|
94
184
|
## When delivery fails
|
|
95
185
|
|
|
@@ -97,50 +187,104 @@ The declaration is identical in shape for all three — switching physics later
|
|
|
97
187
|
|
|
98
188
|
The **Console** (`:6533` → Signals) shows the topology (which flows emit and consume each signal), per-subscriber delivery stats, and the DLQ contents with replay / discard controls — no separate broker UI to run.
|
|
99
189
|
|
|
100
|
-
<Callout title="Orphan emits fail loudly">
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
190
|
+
<Callout title="Orphan emits and schema fail loudly">
|
|
191
|
+
Zero subscribers throws **OKE1042** unless `optional: true`. A payload that fails the signal's
|
|
192
|
+
Standard Schema throws **OKE1043** before the message is staged — same `validate()` path as Flow
|
|
193
|
+
`in`.
|
|
194
|
+
</Callout>
|
|
195
|
+
|
|
196
|
+
## Per-key ordering (`once`)
|
|
197
|
+
|
|
198
|
+
`fx.emit(signal, payload, { key })` is optional and additive. When `key` is set, no two messages
|
|
199
|
+
sharing the same `(signal, key)` are claimed concurrently — a per-key serialization lock, not a
|
|
200
|
+
partitioned log.
|
|
201
|
+
|
|
202
|
+
The lock **is** the in-flight message's visibility lease: while that row is `inflight` with an
|
|
203
|
+
unexpired `leaseExpiresAt`, same-key siblings stay unclaimable. Crash → lease expiry → reclaim
|
|
204
|
+
(same clock as unkeyed `once`). Omit `key` for pure competing-consumer behavior.
|
|
205
|
+
|
|
206
|
+
```typescript
|
|
207
|
+
await fx.emit(orderPlaced, { orderId, total }, { key: orderId });
|
|
208
|
+
await fx.emit(orderPlaced, { orderId, total: total + fee }, { key: orderId });
|
|
209
|
+
// Same key → never concurrent; complete in emission order across competing workers.
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Durability and leases (`once`)
|
|
213
|
+
|
|
214
|
+
<SignalOnceLease />
|
|
215
|
+
|
|
216
|
+
Committed `once` messages survive process death. A claim sets `lockedBy` and a visibility lease (`leaseExpiresAt`, default **30s**). There is no background sweeper: the next consumer reclaim query takes `pending` rows **or** `inflight` rows whose lease has expired. Per-key locks reuse this same lease — no second timeout.
|
|
217
|
+
|
|
218
|
+
<Callout title="At-least-once, not exactly-once">
|
|
219
|
+
Delivery is **at-least-once** — finish side effects then die before ack, and the message may run
|
|
220
|
+
again; make consumers idempotent. Handlers must finish within `leaseMs` (default 30s): a healthy
|
|
221
|
+
but slower handler can be reclaimed while still running. Lease heartbeat is not offered yet.
|
|
222
|
+
</Callout>
|
|
223
|
+
|
|
224
|
+
## Live replay
|
|
225
|
+
|
|
226
|
+
<SignalLiveReplay />
|
|
227
|
+
|
|
228
|
+
`live` retains every delivered message and replays the **full history** to a late `bus.live()` subscriber. There is no TTL or max-count window on that retention today (the Console payload monitor shows only the newest 50 for display).
|
|
229
|
+
|
|
230
|
+
<Callout title="Client subscription is not shipped yet">
|
|
231
|
+
`delivery: "live"` means the **driver** retains and replays. `createClient` has no SSE / WebSocket
|
|
232
|
+
/ `client.live` yet — poll an HTTP Flow or use `bus.live()` server-side. See [Client · Signal and
|
|
233
|
+
live queries](/docs/reference/client#signal-and-live-queries).
|
|
104
234
|
</Callout>
|
|
105
235
|
|
|
236
|
+
## Orphaned signal config
|
|
237
|
+
|
|
238
|
+
When a signal disappears from code, reconciliation marks its config row `orphaned` — it is never deleted. Pending and DLQ messages for that name stay in storage. Drain and replay require an active declaration again: restore the `signal(…)` (and a consumer), then replay from the Console or bus.
|
|
239
|
+
|
|
106
240
|
## Per-environment drivers
|
|
107
241
|
|
|
108
242
|
```typescript title="oke.config.ts"
|
|
109
243
|
drivers: {
|
|
110
|
-
signal: { local: "memory", docker: "
|
|
244
|
+
signal: { local: "memory", docker: "redis", test: "memory", prod: "redis" },
|
|
111
245
|
},
|
|
112
246
|
```
|
|
113
247
|
|
|
114
|
-
| Driver | Runs as
|
|
115
|
-
| ---------- |
|
|
116
|
-
| `memory` | in-process
|
|
117
|
-
| `
|
|
118
|
-
| `
|
|
119
|
-
| `nats` | NATS
|
|
248
|
+
| Driver | Runs as | Boot today |
|
|
249
|
+
| ---------- | -------------- | ------------------------------------------------------------------------- |
|
|
250
|
+
| `memory` | in-process | Local loop + tests — zero infrastructure |
|
|
251
|
+
| `redis` | Redis / Valkey | Default for docker/prod — Streams + pub/sub via `Bun.redis` |
|
|
252
|
+
| `postgres` | Postgres | Driver exists; boot **fails loud** until a LISTEN/NOTIFY SQL client binds |
|
|
253
|
+
| `nats` | NATS | Driver exists; boot **fails loud** until a production NATS client binds |
|
|
120
254
|
|
|
121
|
-
`
|
|
255
|
+
`redis` is the honestly boot-bound durable path today. Pairing an arbitrary Store insert with emit inside **one** shared SQL transaction is not what `fx.emit` does — keep consumers idempotent (at-least-once).
|
|
122
256
|
|
|
123
257
|
## Troubleshooting
|
|
124
258
|
|
|
125
259
|
<Accordions>
|
|
126
|
-
<Accordion title="Emit fails with
|
|
260
|
+
<Accordion title="Emit fails with OKE1042 (no subscriber)">
|
|
261
|
+
|
|
262
|
+
You emitted a signal that nobody currently subscribes to. Wire a consumer with `on(signal, flow)` before emit, or set `optional: true` when zero-subscriber emits are intentional.
|
|
263
|
+
|
|
264
|
+
</Accordion>
|
|
265
|
+
<Accordion title="Emit fails with OKE1043 (schema)">
|
|
127
266
|
|
|
128
|
-
|
|
267
|
+
The payload failed the signal's Standard Schema at emit time — nothing was staged. Fix the payload shape, or remove `schema` if the contract was intentional.
|
|
129
268
|
|
|
130
269
|
</Accordion>
|
|
131
270
|
<Accordion title="A message keeps retrying and then disappears">
|
|
132
271
|
|
|
133
|
-
|
|
272
|
+
When `attempts > retries` the message moves to the DLQ (if `deadLetter: true`) — it is not lost. Open Console → Signals, inspect the typed failure reasons, fix the consumer, then replay.
|
|
134
273
|
|
|
135
274
|
</Accordion>
|
|
136
275
|
<Accordion title="once vs broadcast vs live — how do I choose?">
|
|
137
276
|
|
|
138
|
-
Ask: _how many consumers should process each message?_ One → `once`. All of them → `broadcast`. Clients over time, with replay → `live`.
|
|
277
|
+
Ask: _how many consumers should process each message?_ One → `once`. All of them → `broadcast`. Clients over time, with full retained replay → `live`. Retries and a DLQ are `once` only.
|
|
139
278
|
|
|
140
279
|
</Accordion>
|
|
141
280
|
<Accordion title="My emit happened but the row didn't (or vice versa)">
|
|
142
281
|
|
|
143
|
-
|
|
282
|
+
`fx.emit` commits the signal outbox when it resolves; it does not wrap your Store insert. Treat delivery as at-least-once and make the consumer idempotent (or check the row before acting). Prefer `redis` (or a future LISTEN-capable postgres bind) when you want durable delivery rather than in-process memory.
|
|
283
|
+
|
|
284
|
+
</Accordion>
|
|
285
|
+
<Accordion title="The same once message ran twice">
|
|
286
|
+
|
|
287
|
+
At-least-once: crash-after-claim reclaims when the lease expires, and a handler slower than `leaseMs` can be reclaimed while still running. Idempotent consumers are required; there is no lease heartbeat yet.
|
|
144
288
|
|
|
145
289
|
</Accordion>
|
|
146
290
|
</Accordions>
|