okengine 0.6.0 → 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 (162) hide show
  1. package/AGENTS.md +2 -2
  2. package/README.md +98 -71
  3. package/package.json +4 -4
  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 +90 -20
  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 +18 -17
  19. package/site/content/docs/reference/environment-variables.mdx +44 -18
  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/openbao-restart.integration.test.ts +106 -97
  48. package/src/cli/registry.ts +79 -1
  49. package/src/client/live-gap.test.ts +35 -0
  50. package/src/client/transport.test.ts +21 -0
  51. package/src/client/transport.ts +27 -5
  52. package/src/compiler/fixtures/skyport/oke.config.ts +1 -1
  53. package/src/config/define-config.test.ts +6 -6
  54. package/src/config/index.ts +1 -1
  55. package/src/console/server/console.test.ts +45 -0
  56. package/src/console/server/flows.ts +39 -9
  57. package/src/console/server/i18n.ts +41 -0
  58. package/src/console/server/vault.ts +12 -38
  59. package/src/console/ui/dist/assets/index-ClX0ZCe5.js +10 -0
  60. package/src/console/ui/dist/assets/{panel-access-BGv45snf.js → panel-access-ri1X9Otj.js} +1 -1
  61. package/src/console/ui/dist/assets/{panel-ai-B2S7LEii.js → panel-ai-DLHjRoJk.js} +1 -1
  62. package/src/console/ui/dist/assets/{panel-architecture-D7UJh91v.js → panel-architecture-DI3bQF3J.js} +1 -1
  63. package/src/console/ui/dist/assets/{panel-channels-9T3ybqRu.js → panel-channels-BDYWX29p.js} +1 -1
  64. package/src/console/ui/dist/assets/{panel-clock-Cb1UXGRQ.js → panel-clock-i-JpTjVB.js} +1 -1
  65. package/src/console/ui/dist/assets/{panel-diff-DmYbKWmN.js → panel-diff-CFuWxiXy.js} +1 -1
  66. package/src/console/ui/dist/assets/{panel-flows-PiHwT55z.js → panel-flows-B8TRhcoe.js} +1 -1
  67. package/src/console/ui/dist/assets/{panel-gates-BQGYXvjT.js → panel-gates-CfkBgco7.js} +1 -1
  68. package/src/console/ui/dist/assets/{panel-overview-BBnRO18l.js → panel-overview-DONDzBd2.js} +1 -1
  69. package/src/console/ui/dist/assets/{panel-plugins-D0PsmVw2.js → panel-plugins-DDJwTehL.js} +1 -1
  70. package/src/console/ui/dist/assets/{panel-runs-CWuRDe0r.js → panel-runs-D5zf-D9c.js} +1 -1
  71. package/src/console/ui/dist/assets/{panel-signals-Bbg4ewpP.js → panel-signals-Dsxdu_AR.js} +1 -1
  72. package/src/console/ui/dist/assets/{panel-store-CPCbsDRa.js → panel-store-hy7O8HOs.js} +1 -1
  73. package/src/console/ui/dist/assets/{panel-traces-DVAzuA_S.js → panel-traces-CBiAe5go.js} +1 -1
  74. package/src/console/ui/dist/assets/{panel-vault-D1_MvOmo.js → panel-vault-Za1GyJfM.js} +1 -1
  75. package/src/console/ui/dist/index.html +1 -1
  76. package/src/console/ui/shell/components/ui.tsx +5 -1
  77. package/src/console/ui/shell/setup/Wizard.tsx +26 -5
  78. package/src/docker/cleanup.test.ts +193 -0
  79. package/src/docker/cleanup.ts +355 -0
  80. package/src/docker/docker.test.ts +4 -0
  81. package/src/docker/dockerfile.integration.test.ts +126 -119
  82. package/src/docker/index.ts +19 -0
  83. package/src/docker/recipes/ollama.ts +10 -5
  84. package/src/docker/stack.integration.test.ts +118 -102
  85. package/src/drivers/ai-ollama-tools.integration.test.ts +8 -6
  86. package/src/drivers/ai-ollama.integration.test.ts +3 -19
  87. package/src/drivers/ai-ollama.ts +45 -2
  88. package/src/drivers/ai-providers.test.ts +31 -0
  89. package/src/drivers/channel-fcm.ts +49 -53
  90. package/src/drivers/channel-msegat.ts +61 -0
  91. package/src/drivers/channel-sently-map.ts +57 -0
  92. package/src/drivers/channel-sently.test.ts +99 -0
  93. package/src/drivers/channel-sndr.ts +28 -0
  94. package/src/drivers/channel-taqnyat.ts +57 -0
  95. package/src/drivers/channel-types.ts +79 -2
  96. package/src/drivers/channel-unifonic.ts +26 -43
  97. package/src/drivers/channel-wa-cloud.ts +33 -47
  98. package/src/drivers/channel-webpush.ts +39 -239
  99. package/src/drivers/index.ts +7 -0
  100. package/src/drivers/signal-engine.ts +98 -25
  101. package/src/drivers/signal-nats.ts +4 -4
  102. package/src/drivers/signal-postgres.ts +158 -64
  103. package/src/drivers/signal-redis.ts +4 -4
  104. package/src/drivers/signal-types.ts +61 -2
  105. package/src/drivers/vault-driver-removal.test.ts +5 -1
  106. package/src/elements/channel/costs.test.ts +2 -2
  107. package/src/elements/channel/costs.ts +14 -2
  108. package/src/elements/channel/mime.ts +11 -0
  109. package/src/elements/channel/runtime.ts +94 -0
  110. package/src/elements/channel/sndr-webhooks.test.ts +26 -0
  111. package/src/elements/channel.ts +10 -1
  112. package/src/elements/clock/chaos-child.ts +150 -0
  113. package/src/elements/clock/chaos.test.ts +204 -0
  114. package/src/elements/clock/reconcile.ts +110 -0
  115. package/src/elements/clock/runtime.ts +11 -0
  116. package/src/elements/clock.test.ts +164 -2
  117. package/src/elements/clock.ts +1 -0
  118. package/src/elements/gate/runtime.ts +6 -0
  119. package/src/elements/index.ts +9 -0
  120. package/src/elements/signal/chaos-child.ts +41 -4
  121. package/src/elements/signal/declare.ts +4 -1
  122. package/src/elements/signal/delivery-modes.test.ts +179 -0
  123. package/src/elements/signal/key-ordering.test.ts +306 -0
  124. package/src/elements/signal/lease-reclaim.test.ts +123 -0
  125. package/src/elements/signal/optional-emit.test.ts +76 -0
  126. package/src/elements/signal/order-lifecycle.test.ts +130 -0
  127. package/src/elements/signal/orphan-messages.test.ts +152 -0
  128. package/src/elements/signal/runtime.ts +11 -4
  129. package/src/elements/signal/schema-emit.test.ts +107 -0
  130. package/src/elements/signal.test.ts +89 -5
  131. package/src/elements/store/files-fx.ts +104 -0
  132. package/src/elements/store/files-image.test.ts +251 -0
  133. package/src/elements/store/files-image.ts +494 -0
  134. package/src/elements/store/runtime.ts +60 -0
  135. package/src/elements/store.ts +24 -0
  136. package/src/elements/vault/boot-chain.ts +150 -0
  137. package/src/elements/vault/runtime.ts +8 -0
  138. package/src/kernel/app.ts +3 -1
  139. package/src/kernel/boot-bind/channel.test.ts +68 -3
  140. package/src/kernel/boot-bind/channel.ts +93 -2
  141. package/src/kernel/boot-bind/clock.ts +52 -7
  142. package/src/kernel/boot-bind/gate.ts +71 -3
  143. package/src/kernel/boot-bind/honor-config.test.ts +247 -0
  144. package/src/kernel/boot-bind/signal.ts +64 -7
  145. package/src/kernel/boot-bind/vault.ts +27 -8
  146. package/src/kernel/boot.ts +10 -2
  147. package/src/kernel/errors.ts +8 -2
  148. package/src/kernel/fx.test.ts +13 -0
  149. package/src/kernel/fx.ts +27 -4
  150. package/src/plugins/auth-delivery.mailpit.integration.test.ts +10 -4
  151. package/src/release/exports.test.ts +26 -0
  152. package/src/release/exports.ts +64 -5
  153. package/src/release/index.ts +5 -0
  154. package/src/release/measure.exports.test.ts +13 -1
  155. package/src/release/measure.ts +76 -13
  156. package/src/release/official-plugins.ts +46 -0
  157. package/src/release/readme.test.ts +30 -2
  158. package/src/runtime/dev-request-log.test.ts +20 -1
  159. package/src/runtime/dev-request-log.ts +38 -0
  160. package/src/term.test.ts +15 -0
  161. package/src/term.ts +8 -3
  162. package/src/console/ui/dist/assets/index-CjxwRGVv.js +0 -10
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Per-key serialization for `once` — same (signal, key) never claimed
3
+ * concurrently; emission order preserved; lease reclaim is the unlock.
4
+ */
5
+
6
+ import { afterEach, describe, expect, test } from "bun:test";
7
+ import { mkdtemp, rm } from "node:fs/promises";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ import {
12
+ createPostgresSignalFake,
13
+ memorySignalDriver,
14
+ postgresSignalDriver,
15
+ type SignalBus,
16
+ } from "../../drivers/index.ts";
17
+ import { signal } from "./declare.ts";
18
+ import { createSignalRuntime } from "./runtime.ts";
19
+
20
+ const openBuses: SignalBus[] = [];
21
+ const closers: Array<{ close(): Promise<void> }> = [];
22
+
23
+ afterEach(async () => {
24
+ while (openBuses.length) {
25
+ await openBuses.pop()!.close();
26
+ }
27
+ while (closers.length) {
28
+ await closers.pop()!.close();
29
+ }
30
+ });
31
+
32
+ describe("signal key ordering · memory", () => {
33
+ test("same key: never concurrent; complete in emission order", async () => {
34
+ const once = signal("order-events", {
35
+ delivery: "once",
36
+ retries: 3,
37
+ deadLetter: true,
38
+ });
39
+ const bus = await memorySignalDriver.open({
40
+ signals: new Map([[once.name, once]]),
41
+ });
42
+ openBuses.push(bus);
43
+
44
+ let inflight = 0;
45
+ let maxInflight = 0;
46
+ const completed: number[] = [];
47
+ const started = Promise.withResolvers<void>();
48
+ let startedCount = 0;
49
+
50
+ const handler = async (m: { payload: unknown }) => {
51
+ inflight += 1;
52
+ maxInflight = Math.max(maxInflight, inflight);
53
+ startedCount += 1;
54
+ if (startedCount === 1) started.resolve();
55
+ // Hold long enough for a concurrent drain to attempt the sibling.
56
+ await Bun.sleep(40);
57
+ completed.push((m.payload as { seq: number }).seq);
58
+ inflight -= 1;
59
+ };
60
+
61
+ await bus.subscribe("order-events", "worker-a", handler);
62
+ await bus.subscribe("order-events", "worker-b", handler);
63
+
64
+ await bus.emit("order-events", { seq: 1 }, { key: "ord_42" });
65
+ await bus.emit("order-events", { seq: 2 }, { key: "ord_42" });
66
+
67
+ // Concurrent drains: without key locking both messages would be inflight.
68
+ const d1 = bus.drain();
69
+ const d2 = bus.drain();
70
+ await started.promise;
71
+ await Promise.all([d1, d2]);
72
+
73
+ expect(maxInflight).toBe(1);
74
+ expect(completed).toEqual([1, 2]);
75
+ });
76
+
77
+ test("different keys: may process concurrently", async () => {
78
+ const once = signal("order-events", {
79
+ delivery: "once",
80
+ retries: 3,
81
+ deadLetter: true,
82
+ });
83
+ const bus = await memorySignalDriver.open({
84
+ signals: new Map([[once.name, once]]),
85
+ });
86
+ openBuses.push(bus);
87
+
88
+ let inflight = 0;
89
+ let maxInflight = 0;
90
+ const gate = Promise.withResolvers<void>();
91
+ let waiting = 0;
92
+
93
+ const handler = async () => {
94
+ inflight += 1;
95
+ maxInflight = Math.max(maxInflight, inflight);
96
+ waiting += 1;
97
+ if (waiting === 2) gate.resolve();
98
+ await gate.promise;
99
+ inflight -= 1;
100
+ };
101
+
102
+ await bus.subscribe("order-events", "worker-a", handler);
103
+ await bus.subscribe("order-events", "worker-b", handler);
104
+
105
+ await bus.emit("order-events", { seq: 1 }, { key: "ord_a" });
106
+ await bus.emit("order-events", { seq: 2 }, { key: "ord_b" });
107
+
108
+ await Promise.all([bus.drain(), bus.drain()]);
109
+ expect(maxInflight).toBe(2);
110
+ });
111
+
112
+ test("no key: unchanged competing-consumer (both may run concurrent)", async () => {
113
+ const once = signal("order-events", {
114
+ delivery: "once",
115
+ retries: 3,
116
+ deadLetter: true,
117
+ });
118
+ const bus = await memorySignalDriver.open({
119
+ signals: new Map([[once.name, once]]),
120
+ });
121
+ openBuses.push(bus);
122
+
123
+ let inflight = 0;
124
+ let maxInflight = 0;
125
+ const gate = Promise.withResolvers<void>();
126
+ let waiting = 0;
127
+
128
+ const handler = async () => {
129
+ inflight += 1;
130
+ maxInflight = Math.max(maxInflight, inflight);
131
+ waiting += 1;
132
+ if (waiting === 2) gate.resolve();
133
+ await gate.promise;
134
+ inflight -= 1;
135
+ };
136
+
137
+ await bus.subscribe("order-events", "worker-a", handler);
138
+ await bus.subscribe("order-events", "worker-b", handler);
139
+
140
+ await bus.emit("order-events", { seq: 1 });
141
+ await bus.emit("order-events", { seq: 2 });
142
+
143
+ await Promise.all([bus.drain(), bus.drain()]);
144
+ expect(maxInflight).toBe(2);
145
+ });
146
+ });
147
+
148
+ describe("signal key ordering · postgres", () => {
149
+ test("same-key pending blocked while sibling holds unexpired lease", async () => {
150
+ let t = 10_000;
151
+ const once = signal("order-events", { delivery: "once", retries: 3 });
152
+ const sql = createPostgresSignalFake({ now: () => t });
153
+ const runtime = createSignalRuntime({
154
+ driver: postgresSignalDriver,
155
+ sql,
156
+ now: () => t,
157
+ leaseMs: 50,
158
+ });
159
+ closers.push(runtime);
160
+ runtime.register(once);
161
+ const bus = await runtime.start();
162
+
163
+ // msg1 holds the key via message-level lease; msg2 is pending same key.
164
+ await sql.exec(
165
+ `INSERT INTO oke_signal_messages (id, signal, payload, ordering_key, delivery, attempts, failures, created_at, available_at, status, locked_by, lease_expires_at, delivered_to) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
166
+ [
167
+ "m1",
168
+ "order-events",
169
+ JSON.stringify({ seq: 1 }),
170
+ "ord_42",
171
+ "once",
172
+ 1,
173
+ "[]",
174
+ t - 2_000,
175
+ t - 2_000,
176
+ "inflight",
177
+ "dead-worker",
178
+ t + 1_000,
179
+ "[]",
180
+ ],
181
+ );
182
+ await sql.exec(
183
+ `INSERT INTO oke_signal_messages (id, signal, payload, ordering_key, delivery, attempts, failures, created_at, available_at, status, locked_by, lease_expires_at, delivered_to) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
184
+ [
185
+ "m2",
186
+ "order-events",
187
+ JSON.stringify({ seq: 2 }),
188
+ "ord_42",
189
+ "once",
190
+ 0,
191
+ "[]",
192
+ t - 1_000,
193
+ t - 1_000,
194
+ "pending",
195
+ null,
196
+ null,
197
+ "[]",
198
+ ],
199
+ );
200
+
201
+ const got: number[] = [];
202
+ await bus.subscribe("order-events", "rescue", async (m) => {
203
+ got.push((m.payload as { seq: number }).seq);
204
+ });
205
+
206
+ await bus.drain();
207
+ expect(got).toEqual([]);
208
+
209
+ // Same lease clock unlocks the key — no second timeout system.
210
+ t = 10_000 + 1_001;
211
+ await bus.drain();
212
+ expect(got).toEqual([1, 2]);
213
+ });
214
+
215
+ test("emit with key then drain preserves emission order", async () => {
216
+ const once = signal("order-events", {
217
+ delivery: "once",
218
+ retries: 3,
219
+ deadLetter: true,
220
+ });
221
+ const bus = await postgresSignalDriver.open({
222
+ signals: new Map([[once.name, once]]),
223
+ sql: createPostgresSignalFake(),
224
+ });
225
+ openBuses.push(bus);
226
+
227
+ const completed: number[] = [];
228
+ await bus.subscribe("order-events", "worker-a", async (m) => {
229
+ completed.push((m.payload as { seq: number }).seq);
230
+ });
231
+ await bus.subscribe("order-events", "worker-b", async (m) => {
232
+ completed.push((m.payload as { seq: number }).seq);
233
+ });
234
+
235
+ await bus.emit("order-events", { seq: 1 }, { key: "ord_42" });
236
+ await bus.emit("order-events", { seq: 2 }, { key: "ord_42" });
237
+ await bus.drain();
238
+ expect(completed).toEqual([1, 2]);
239
+ });
240
+ });
241
+
242
+ describe("signal key ordering · lease reclaim", () => {
243
+ test("memory: expired same-key inflight reclaims; successor then runs", async () => {
244
+ const dir = await mkdtemp(join(tmpdir(), "oke-key-lease-"));
245
+ const durablePath = join(dir, "bus.json");
246
+ const marker = join(dir, "claimed");
247
+ try {
248
+ let t = 5_000;
249
+ const once = signal("order-events", {
250
+ delivery: "once",
251
+ retries: 3,
252
+ deadLetter: true,
253
+ });
254
+ const r1 = createSignalRuntime({
255
+ driver: memorySignalDriver,
256
+ durablePath,
257
+ now: () => t,
258
+ leaseMs: 50,
259
+ });
260
+ closers.push(r1);
261
+ r1.register(once);
262
+ const b1 = await r1.start();
263
+
264
+ await b1.subscribe("order-events", "slow", async (m) => {
265
+ if ((m.payload as { seq: number }).seq === 1) {
266
+ await Bun.write(marker, "1");
267
+ await Bun.sleep(60_000);
268
+ }
269
+ });
270
+
271
+ await b1.emit("order-events", { seq: 1 }, { key: "ord_42" });
272
+ await b1.emit("order-events", { seq: 2 }, { key: "ord_42" });
273
+
274
+ const drainPromise = b1.drain();
275
+ for (let i = 0; i < 80; i++) {
276
+ if (await Bun.file(marker).exists()) break;
277
+ await Bun.sleep(5);
278
+ }
279
+ expect(await Bun.file(marker).exists()).toBe(true);
280
+
281
+ // Crash while holding the key lock (message-level lease).
282
+ await r1.close();
283
+ void drainPromise.catch(() => {});
284
+ closers.pop();
285
+
286
+ t = 5_000 + 100; // past leaseMs — same reclaim clock as unkeyed once
287
+ const r2 = createSignalRuntime({
288
+ driver: memorySignalDriver,
289
+ durablePath,
290
+ now: () => t,
291
+ leaseMs: 50,
292
+ });
293
+ closers.push(r2);
294
+ r2.register(once);
295
+ const b2 = await r2.start();
296
+ const got: number[] = [];
297
+ await b2.subscribe("order-events", "rescue", async (m) => {
298
+ got.push((m.payload as { seq: number }).seq);
299
+ });
300
+ await b2.drain();
301
+ expect(got).toEqual([1, 2]);
302
+ } finally {
303
+ await rm(dir, { recursive: true, force: true });
304
+ }
305
+ });
306
+ });
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Lazy lease reclaim at claim time — no background sweeper.
3
+ */
4
+
5
+ import { afterEach, describe, expect, test } from "bun:test";
6
+ import { mkdtemp, rm } from "node:fs/promises";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+
10
+ import {
11
+ createPostgresSignalFake,
12
+ memorySignalDriver,
13
+ postgresSignalDriver,
14
+ } from "../../drivers/index.ts";
15
+ import { signal } from "./declare.ts";
16
+ import { createSignalRuntime } from "./runtime.ts";
17
+
18
+ const closers: Array<{ close(): Promise<void> }> = [];
19
+
20
+ afterEach(async () => {
21
+ while (closers.length) {
22
+ await closers.pop()!.close();
23
+ }
24
+ });
25
+
26
+ describe("signal lease reclaim", () => {
27
+ test("memory: expired inflight is reclaimed at next claim after crash", async () => {
28
+ const dir = await mkdtemp(join(tmpdir(), "oke-lease-"));
29
+ const durablePath = join(dir, "bus.json");
30
+ const marker = join(dir, "claimed");
31
+ try {
32
+ let t = 5_000;
33
+ const once = signal("order-placed", {
34
+ delivery: "once",
35
+ retries: 3,
36
+ deadLetter: true,
37
+ });
38
+ const r1 = createSignalRuntime({
39
+ driver: memorySignalDriver,
40
+ durablePath,
41
+ now: () => t,
42
+ leaseMs: 50,
43
+ });
44
+ closers.push(r1);
45
+ r1.register(once);
46
+ const b1 = await r1.start();
47
+ await b1.subscribe("order-placed", "slow", async () => {
48
+ await Bun.write(marker, "1");
49
+ await Bun.sleep(60_000);
50
+ });
51
+ await b1.emit("order-placed", { n: 42 });
52
+ const drainPromise = b1.drain();
53
+ for (let i = 0; i < 80; i++) {
54
+ if (await Bun.file(marker).exists()) break;
55
+ await Bun.sleep(5);
56
+ }
57
+ expect(await Bun.file(marker).exists()).toBe(true);
58
+ // Strand the inflight claim (persisted before handler) by closing the bus.
59
+ await r1.close();
60
+ void drainPromise.catch(() => {});
61
+ closers.pop();
62
+
63
+ t = 5_000 + 100; // past leaseMs
64
+ const r2 = createSignalRuntime({
65
+ driver: memorySignalDriver,
66
+ durablePath,
67
+ now: () => t,
68
+ leaseMs: 50,
69
+ });
70
+ closers.push(r2);
71
+ r2.register(once);
72
+ const b2 = await r2.start();
73
+ const got: unknown[] = [];
74
+ await b2.subscribe("order-placed", "rescue", async (m) => {
75
+ got.push(m.payload);
76
+ });
77
+ await b2.drain();
78
+ expect(got).toEqual([{ n: 42 }]);
79
+ } finally {
80
+ await rm(dir, { recursive: true, force: true });
81
+ }
82
+ });
83
+
84
+ test("postgres: expired inflight reclaimed in the same claim query", async () => {
85
+ let t = 10_000;
86
+ const once = signal("order-placed", { delivery: "once", retries: 3 });
87
+ const sql = createPostgresSignalFake({ now: () => t });
88
+ const runtime = createSignalRuntime({
89
+ driver: postgresSignalDriver,
90
+ sql,
91
+ now: () => t,
92
+ leaseMs: 50,
93
+ });
94
+ closers.push(runtime);
95
+ runtime.register(once);
96
+ const bus = await runtime.start();
97
+
98
+ await sql.exec(
99
+ `INSERT INTO oke_signal_messages (id, signal, payload, delivery, attempts, failures, created_at, available_at, status, locked_by, lease_expires_at, delivered_to) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
100
+ [
101
+ "stranded-1",
102
+ "order-placed",
103
+ JSON.stringify({ id: "stranded" }),
104
+ "once",
105
+ 1,
106
+ "[]",
107
+ t - 1_000,
108
+ t - 1_000,
109
+ "inflight",
110
+ "dead-worker",
111
+ t - 1,
112
+ "[]",
113
+ ],
114
+ );
115
+
116
+ const got: unknown[] = [];
117
+ await bus.subscribe("order-placed", "rescue", async (m) => {
118
+ got.push(m.payload);
119
+ });
120
+ await bus.drain();
121
+ expect(got).toEqual([{ id: "stranded" }]);
122
+ });
123
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Zero-subscriber emit — OKE1042 when optional is unset/false; allow when true.
3
+ */
4
+
5
+ import { afterEach, describe, expect, test } from "bun:test";
6
+
7
+ import {
8
+ createPostgresSignalFake,
9
+ memorySignalDriver,
10
+ postgresSignalDriver,
11
+ type SignalBus,
12
+ type SignalDriver,
13
+ } from "../../drivers/index.ts";
14
+ import { OkeError } from "../../kernel/errors.ts";
15
+ import { signal, type SignalDecl } from "./declare.ts";
16
+
17
+ const openBuses: SignalBus[] = [];
18
+
19
+ afterEach(async () => {
20
+ while (openBuses.length) {
21
+ await openBuses.pop()!.close();
22
+ }
23
+ });
24
+
25
+ async function openBus(
26
+ driver: SignalDriver,
27
+ decls: readonly SignalDecl[],
28
+ extra: Record<string, unknown> = {},
29
+ ): Promise<SignalBus> {
30
+ const bus = await driver.open({
31
+ signals: new Map(decls.map((d) => [d.name, d])),
32
+ ...extra,
33
+ });
34
+ openBuses.push(bus);
35
+ return bus;
36
+ }
37
+
38
+ for (const { label, driver, setup } of [
39
+ { label: "memory", driver: memorySignalDriver, setup: () => ({}) },
40
+ {
41
+ label: "postgres",
42
+ driver: postgresSignalDriver,
43
+ setup: () => ({ sql: createPostgresSignalFake() }),
44
+ },
45
+ ] as const) {
46
+ describe(`signal optional emit · ${label}`, () => {
47
+ test("optional unset/false: emit with zero subscribers throws OKE1042", async () => {
48
+ const once = signal("order-placed", { delivery: "once" });
49
+ expect(once.optional).toBe(false);
50
+ const bus = await openBus(driver, [once], setup());
51
+
52
+ let err: unknown;
53
+ try {
54
+ await bus.emit("order-placed", { id: "1" });
55
+ } catch (e) {
56
+ err = e;
57
+ }
58
+ expect(err).toBeInstanceOf(OkeError);
59
+ expect((err as OkeError).code).toBe(1042);
60
+ expect((err as Error).message).toContain("OKE1042");
61
+ expect((err as Error).message).toContain("no subscriber");
62
+ });
63
+
64
+ test("optional: true allows emit with zero subscribers", async () => {
65
+ const once = signal("hook", {
66
+ delivery: "once",
67
+ optional: true,
68
+ });
69
+ const bus = await openBus(driver, [once], setup());
70
+ await bus.emit("hook", { ok: true });
71
+ await bus.drain();
72
+ const stats = await bus.inspect("hook");
73
+ expect(stats[0]?.pending).toBe(1);
74
+ });
75
+ });
76
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * One order domain — all three delivery modes together.
3
+ *
4
+ * Teaching scenario (mirrored on the Signal docs page):
5
+ * - `once` order-placed → competing fulfillment workers (+ retry/DLQ)
6
+ * - `broadcast` order-changed → cache invalidation AND notification (fan-out)
7
+ * - `live` order-status → client-visible feed via bus.live() replay
8
+ *
9
+ * Exclusive claim / retries / DLQ / live replay physics are proven in
10
+ * delivery-modes.test.ts; this file only asserts the coherent wiring.
11
+ */
12
+
13
+ import { afterEach, describe, expect, test } from "bun:test";
14
+
15
+ import { memorySignalDriver, type SignalBus } from "../../drivers/index.ts";
16
+ import { signal } from "./declare.ts";
17
+
18
+ const openBuses: SignalBus[] = [];
19
+
20
+ afterEach(async () => {
21
+ while (openBuses.length) {
22
+ await openBuses.pop()!.close();
23
+ }
24
+ });
25
+
26
+ describe("signal order lifecycle · once + broadcast + live", () => {
27
+ test("one placed order: exclusive fulfill, fan-out side effects, live status replay", async () => {
28
+ // once — queued job: exactly one competing consumer fulfills
29
+ const orderPlaced = signal("order-placed", {
30
+ delivery: "once",
31
+ retries: 2,
32
+ deadLetter: true,
33
+ });
34
+ // broadcast — same domain event fan-out: every subscriber gets a copy
35
+ const orderChanged = signal("order-changed", {
36
+ delivery: "broadcast",
37
+ });
38
+ // live — client-visible status feed (bus.live; createClient has no subscribe yet)
39
+ const orderStatus = signal("order-status", {
40
+ delivery: "live",
41
+ optional: true,
42
+ });
43
+
44
+ const bus = await memorySignalDriver.open({
45
+ signals: new Map([
46
+ [orderPlaced.name, orderPlaced],
47
+ [orderChanged.name, orderChanged],
48
+ [orderStatus.name, orderStatus],
49
+ ]),
50
+ });
51
+ openBuses.push(bus);
52
+
53
+ const fulfilled: string[] = [];
54
+ const cacheHits: string[] = [];
55
+ const notifyHits: string[] = [];
56
+
57
+ await bus.subscribe("order-placed", "fulfillment-a", async (m) => {
58
+ const { orderId } = m.payload as { orderId: string };
59
+ fulfilled.push(`a:${orderId}`);
60
+ await bus.emit("order-changed", { orderId, kind: "placed" });
61
+ await bus.emit("order-status", { orderId, status: "fulfilling" });
62
+ await bus.emit("order-status", { orderId, status: "shipped" });
63
+ });
64
+ await bus.subscribe("order-placed", "fulfillment-b", async (m) => {
65
+ const { orderId } = m.payload as { orderId: string };
66
+ fulfilled.push(`b:${orderId}`);
67
+ await bus.emit("order-changed", { orderId, kind: "placed" });
68
+ await bus.emit("order-status", { orderId, status: "fulfilling" });
69
+ await bus.emit("order-status", { orderId, status: "shipped" });
70
+ });
71
+
72
+ await bus.subscribe("order-changed", "cache-invalidate", async (m) => {
73
+ cacheHits.push((m.payload as { orderId: string }).orderId);
74
+ });
75
+ await bus.subscribe("order-changed", "notify-customer", async (m) => {
76
+ notifyHits.push((m.payload as { orderId: string }).orderId);
77
+ });
78
+
79
+ await bus.emit("order-placed", { orderId: "ord_42", total: 49.5 });
80
+ await bus.emit("order-status", { orderId: "ord_42", status: "placed" });
81
+ await bus.drain();
82
+
83
+ // once: exactly one competing worker claimed the job
84
+ expect(fulfilled).toHaveLength(1);
85
+ expect(fulfilled[0] === "a:ord_42" || fulfilled[0] === "b:ord_42").toBe(true);
86
+
87
+ // broadcast: both independent subscribers received the fan-out
88
+ expect(cacheHits).toEqual(["ord_42"]);
89
+ expect(notifyHits).toEqual(["ord_42"]);
90
+
91
+ // live: late subscriber replays the full retained status history
92
+ const feed: Array<{ orderId: string; status: string }> = [];
93
+ await bus.live("order-status", (payload) => {
94
+ feed.push(payload as { orderId: string; status: string });
95
+ });
96
+ expect(feed).toEqual([
97
+ { orderId: "ord_42", status: "placed" },
98
+ { orderId: "ord_42", status: "fulfilling" },
99
+ { orderId: "ord_42", status: "shipped" },
100
+ ]);
101
+ });
102
+
103
+ test("once retries then DLQ still apply inside the same domain", async () => {
104
+ // Light reuse of once retry/DLQ — full matrix lives in delivery-modes.test.ts.
105
+ const orderPlaced = signal("order-placed", {
106
+ delivery: "once",
107
+ retries: 1,
108
+ deadLetter: true,
109
+ });
110
+ const bus = await memorySignalDriver.open({
111
+ signals: new Map([[orderPlaced.name, orderPlaced]]),
112
+ });
113
+ openBuses.push(bus);
114
+
115
+ let attempts = 0;
116
+ await bus.subscribe("order-placed", "fulfillment", async () => {
117
+ attempts += 1;
118
+ throw new Error(`payment-gateway-${attempts}`);
119
+ });
120
+
121
+ await bus.emit("order-placed", { orderId: "ord_fail" });
122
+ await bus.drain();
123
+
124
+ expect(attempts).toBe(2); // retries + 1
125
+ const dlq = await bus.deadLetters("order-placed");
126
+ expect(dlq).toHaveLength(1);
127
+ expect(dlq[0]!.payload).toEqual({ orderId: "ord_fail" });
128
+ expect(dlq[0]!.failures.map((f) => f.attempt)).toEqual([1, 2]);
129
+ });
130
+ });