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.
Files changed (130) hide show
  1. package/AGENTS.md +2 -2
  2. package/README.md +73 -181
  3. package/package.json +2 -2
  4. package/site/content/docs/console/clock.mdx +25 -5
  5. package/site/content/docs/console/signals.mdx +13 -4
  6. package/site/content/docs/elements/ai.mdx +29 -2
  7. package/site/content/docs/elements/channel.mdx +19 -13
  8. package/site/content/docs/elements/clock.mdx +56 -24
  9. package/site/content/docs/elements/flow.mdx +2 -0
  10. package/site/content/docs/elements/gate.mdx +8 -1
  11. package/site/content/docs/elements/signal.mdx +173 -29
  12. package/site/content/docs/elements/store.mdx +464 -73
  13. package/site/content/docs/elements/vault.mdx +15 -5
  14. package/site/content/docs/get-started/basic-usage.mdx +13 -6
  15. package/site/content/docs/get-started/installation.mdx +49 -13
  16. package/site/content/docs/get-started/introduction.mdx +1 -1
  17. package/site/content/docs/reference/cli.md +30 -2
  18. package/site/content/docs/reference/configuration.mdx +13 -13
  19. package/site/content/docs/reference/environment-variables.mdx +12 -10
  20. package/site/content/docs/reference/errors.mdx +12 -11
  21. package/site/content/docs/reference/fx.mdx +15 -13
  22. package/site/content/docs/reference/security.md +3 -1
  23. package/src/cli/ai-setup/ai-setup.test.ts +144 -0
  24. package/src/cli/ai-setup/apply.ts +214 -0
  25. package/src/cli/ai-setup/catalog.ts +263 -0
  26. package/src/cli/ai-setup/detect-ollama.ts +166 -0
  27. package/src/cli/ai-setup/index.ts +228 -0
  28. package/src/cli/ai-setup/prompts.ts +649 -0
  29. package/src/cli/ai-setup/recommend.test.ts +100 -0
  30. package/src/cli/ai-setup/recommend.ts +203 -0
  31. package/src/cli/ai.ts +38 -0
  32. package/src/cli/db-auto-push.test.ts +6 -1
  33. package/src/cli/db-auto-push.ts +8 -3
  34. package/src/cli/dev-db-push.test.ts +59 -0
  35. package/src/cli/dev.test.ts +40 -0
  36. package/src/cli/dev.ts +55 -1
  37. package/src/cli/docker-clean.test.ts +179 -0
  38. package/src/cli/docker-clean.ts +406 -0
  39. package/src/cli/docker.ts +16 -4
  40. package/src/cli/ensure-drizzle-config.ts +1 -1
  41. package/src/cli/hero-meta.test.ts +6 -5
  42. package/src/cli/hero-meta.ts +7 -1
  43. package/src/cli/index.ts +5 -0
  44. package/src/cli/load-config.ts +4 -4
  45. package/src/cli/openbao-bootstrap.test.ts +1 -0
  46. package/src/cli/openbao-bootstrap.ts +9 -2
  47. package/src/cli/registry.ts +79 -1
  48. package/src/client/live-gap.test.ts +35 -0
  49. package/src/client/transport.test.ts +21 -0
  50. package/src/client/transport.ts +27 -5
  51. package/src/compiler/fixtures/skyport/oke.config.ts +1 -1
  52. package/src/config/define-config.test.ts +6 -6
  53. package/src/config/index.ts +1 -1
  54. package/src/console/server/console.test.ts +45 -0
  55. package/src/console/server/flows.ts +39 -9
  56. package/src/console/server/i18n.ts +41 -0
  57. package/src/console/server/vault.ts +12 -38
  58. package/src/console/ui/dist/assets/index-ClX0ZCe5.js +10 -0
  59. package/src/console/ui/dist/assets/{panel-access-BGv45snf.js → panel-access-ri1X9Otj.js} +1 -1
  60. package/src/console/ui/dist/assets/{panel-ai-B2S7LEii.js → panel-ai-DLHjRoJk.js} +1 -1
  61. package/src/console/ui/dist/assets/{panel-architecture-D7UJh91v.js → panel-architecture-DI3bQF3J.js} +1 -1
  62. package/src/console/ui/dist/assets/{panel-channels-9T3ybqRu.js → panel-channels-BDYWX29p.js} +1 -1
  63. package/src/console/ui/dist/assets/{panel-clock-Cb1UXGRQ.js → panel-clock-i-JpTjVB.js} +1 -1
  64. package/src/console/ui/dist/assets/{panel-diff-DmYbKWmN.js → panel-diff-CFuWxiXy.js} +1 -1
  65. package/src/console/ui/dist/assets/{panel-flows-PiHwT55z.js → panel-flows-B8TRhcoe.js} +1 -1
  66. package/src/console/ui/dist/assets/{panel-gates-BQGYXvjT.js → panel-gates-CfkBgco7.js} +1 -1
  67. package/src/console/ui/dist/assets/{panel-overview-BBnRO18l.js → panel-overview-DONDzBd2.js} +1 -1
  68. package/src/console/ui/dist/assets/{panel-plugins-D0PsmVw2.js → panel-plugins-DDJwTehL.js} +1 -1
  69. package/src/console/ui/dist/assets/{panel-runs-CWuRDe0r.js → panel-runs-D5zf-D9c.js} +1 -1
  70. package/src/console/ui/dist/assets/{panel-signals-Bbg4ewpP.js → panel-signals-Dsxdu_AR.js} +1 -1
  71. package/src/console/ui/dist/assets/{panel-store-CPCbsDRa.js → panel-store-hy7O8HOs.js} +1 -1
  72. package/src/console/ui/dist/assets/{panel-traces-DVAzuA_S.js → panel-traces-CBiAe5go.js} +1 -1
  73. package/src/console/ui/dist/assets/{panel-vault-D1_MvOmo.js → panel-vault-Za1GyJfM.js} +1 -1
  74. package/src/console/ui/dist/index.html +1 -1
  75. package/src/console/ui/shell/components/ui.tsx +5 -1
  76. package/src/console/ui/shell/setup/Wizard.tsx +26 -5
  77. package/src/docker/cleanup.test.ts +193 -0
  78. package/src/docker/cleanup.ts +355 -0
  79. package/src/docker/docker.test.ts +4 -0
  80. package/src/docker/index.ts +19 -0
  81. package/src/docker/recipes/ollama.ts +10 -5
  82. package/src/drivers/ai-ollama.ts +45 -2
  83. package/src/drivers/ai-providers.test.ts +31 -0
  84. package/src/drivers/index.ts +3 -0
  85. package/src/drivers/signal-engine.ts +98 -25
  86. package/src/drivers/signal-nats.ts +4 -4
  87. package/src/drivers/signal-postgres.ts +158 -64
  88. package/src/drivers/signal-redis.ts +4 -4
  89. package/src/drivers/signal-types.ts +61 -2
  90. package/src/drivers/vault-driver-removal.test.ts +5 -1
  91. package/src/elements/clock/chaos-child.ts +150 -0
  92. package/src/elements/clock/chaos.test.ts +204 -0
  93. package/src/elements/clock/reconcile.ts +110 -0
  94. package/src/elements/clock/runtime.ts +11 -0
  95. package/src/elements/clock.test.ts +164 -2
  96. package/src/elements/clock.ts +1 -0
  97. package/src/elements/gate/runtime.ts +6 -0
  98. package/src/elements/signal/chaos-child.ts +41 -4
  99. package/src/elements/signal/declare.ts +4 -1
  100. package/src/elements/signal/delivery-modes.test.ts +179 -0
  101. package/src/elements/signal/key-ordering.test.ts +306 -0
  102. package/src/elements/signal/lease-reclaim.test.ts +123 -0
  103. package/src/elements/signal/optional-emit.test.ts +76 -0
  104. package/src/elements/signal/order-lifecycle.test.ts +130 -0
  105. package/src/elements/signal/orphan-messages.test.ts +152 -0
  106. package/src/elements/signal/runtime.ts +11 -4
  107. package/src/elements/signal/schema-emit.test.ts +107 -0
  108. package/src/elements/signal.test.ts +89 -5
  109. package/src/elements/store/files-fx.ts +104 -0
  110. package/src/elements/store/files-image.test.ts +251 -0
  111. package/src/elements/store/files-image.ts +494 -0
  112. package/src/elements/store/runtime.ts +60 -0
  113. package/src/elements/store.ts +24 -0
  114. package/src/elements/vault/boot-chain.ts +150 -0
  115. package/src/elements/vault/runtime.ts +8 -0
  116. package/src/kernel/app.ts +3 -1
  117. package/src/kernel/boot-bind/clock.ts +52 -7
  118. package/src/kernel/boot-bind/gate.ts +71 -3
  119. package/src/kernel/boot-bind/honor-config.test.ts +247 -0
  120. package/src/kernel/boot-bind/signal.ts +64 -7
  121. package/src/kernel/boot-bind/vault.ts +27 -8
  122. package/src/kernel/boot.ts +10 -2
  123. package/src/kernel/errors.ts +8 -2
  124. package/src/kernel/fx.test.ts +13 -0
  125. package/src/kernel/fx.ts +27 -4
  126. package/src/runtime/dev-request-log.test.ts +20 -1
  127. package/src/runtime/dev-request-log.ts +38 -0
  128. package/src/term.test.ts +15 -0
  129. package/src/term.ts +8 -3
  130. 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 difference.
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 / pause the schedule |
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
- `fx.clock.sleep(label, duration)` is a **durable** sleep: in a `durable: true` flow the wake time is journaled, so the flow resumes after restarts and deploys instead of losing its place. The `label` names the step in the journal — it is what the Console shows when you inspect a sleeping run.
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
- | Guarantee | What it means |
109
- | ------------------ | ------------------------------------------------------------------------------------------- |
110
- | Leader election | With several replicas, only one instance fires each cron tick |
111
- | Catch-up `"one"` | A schedule missed during downtime fires **once**, not once per missed tick |
112
- | Reconciled at boot | Named clocks are written into the store (`oke_crons`) the scheduler reads state, not code |
113
- | DST-aware | Ambiguous local times around DST transitions are detected and handled |
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: "postgres", test: "frozen", prod: "postgres" },
144
+ clock: { local: "memory", docker: "file", test: "frozen", prod: "file" },
120
145
  },
121
146
  ```
122
147
 
123
- | Driver | Behavior |
124
- | ---------- | ------------------------------------------------------------------- |
125
- | `memory` | In-process timers — fast local loop, lost on exit |
126
- | `postgres` | Schedules and wakes persisted in your Postgres survives restarts |
127
- | `frozen` | Deterministic test clock — time advances only when the test says so |
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 a four-number health view — drift, past-due, next run, and which replica holds the leader lease. From there you can **pause**, **edit the schedule**, or **wake early** — but only for clocks declared with `overridable: true`. A non-overridable clock rejects edits with `ScheduleNotOverridableError`, so a mistyped production change cannot silently reshape your cadence.
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
- // With the postgres driver this enrols in the same transaction as the insert —
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.channel(email).send(/* */);
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; typed emits and Manifest docs |
90
- | `retries` | number | `3` | Max delivery attempts before dead-letter (`once`) |
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
- Emitting a signal with zero subscribers is normally an error it almost always means a typo or a
102
- forgotten consumer. Set `optional: true` on the declaration only when zero-subscriber emits are
103
- genuinely expected (e.g. an integration hook).
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: "postgres", test: "memory", prod: "postgres" },
244
+ signal: { local: "memory", docker: "redis", test: "memory", prod: "redis" },
111
245
  },
112
246
  ```
113
247
 
114
- | Driver | Runs as | Best for |
115
- | ---------- | -------------------------------- | ------------------------------------------------------------------------- |
116
- | `memory` | in-process | Local loop + tests — zero infrastructure |
117
- | `postgres` | your existing Postgres container | Default for docker/prod — emits join your DB transaction (outbox pattern) |
118
- | `redis` | Redis container | Explicit alternative when throughput outgrows Postgres |
119
- | `nats` | NATS container | High-throughput fan-out |
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
- `postgres` is the default outside local dev for a reason: `fx.emit` enrols in the caller's transaction, so "write row + emit event" commits or rolls back **atomically** the classic dual-write bug is designed out.
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 a 'no subscribers' error">
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
- You emitted a signal that no flow consumes. Either wire a consumer with `on(signal, flow)`, or set `optional: true` on the declaration if zero-subscriber emits are intentional.
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
- After `retries` attempts the message moves to the DLQ — it is not lost. Open Console → Signals, inspect the typed failure reasons on each attempt, fix the consumer, then replay.
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`. If you need retries and a DLQ, you want `once`.
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
- That is the dual-write problem, and it means the signal driver is not `postgres`. With the `postgres` driver the emit joins your SQL transaction; with `redis` / `nats` an outbox relay keeps the guarantee, but `postgres` is the only mode where atomicity is literal.
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>