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
@@ -3,8 +3,12 @@
3
3
  * - durable flow killed mid-execution resumes at the failed step
4
4
  * - a 7-day sleep survives a restart
5
5
  * - three instances run a cron once (leader election)
6
+ * - catch-up `"one"` after multi-slot downtime
7
+ * - DST gap/overlap detect-only; overlap civil physics
8
+ * - overridable edit accept / reject
6
9
  * - reconciliation marks a removed cron orphaned without deleting it
7
- * - DST ambiguity detected from expression + zone
10
+ *
11
+ * Multi-process / SIGKILL proofs live in `clock/chaos.test.ts`.
8
12
  */
9
13
 
10
14
  import { describe, expect, test } from "bun:test";
@@ -20,10 +24,14 @@ import {
20
24
  createMemoryCronStore,
21
25
  createTestClockRuntime,
22
26
  createTimeTravel,
27
+ cronHealth,
23
28
  detectDstAmbiguity,
29
+ editSchedule,
30
+ nextOccurrences,
24
31
  parseDurationMs,
25
32
  reconcileClocks,
26
33
  runDurable,
34
+ ScheduleNotOverridableError,
27
35
  tryAcquireLease,
28
36
  } from "./clock.ts";
29
37
 
@@ -323,6 +331,41 @@ describe("reconciliation", () => {
323
331
  });
324
332
  });
325
333
 
334
+ describe("catch-up policy one", () => {
335
+ test("5h downtime on hourly clock: one fire, missedRuns reflects the gap", async () => {
336
+ const store = createMemoryCronStore();
337
+ const rt = createTestClockRuntime(0, {
338
+ store,
339
+ instanceId: "catchup",
340
+ leaseMs: 60_000,
341
+ });
342
+ rt.register(clock("hourly", { every: "1h" }));
343
+ await rt.reconcile();
344
+ await store.put({
345
+ ...(await store.get("hourly"))!,
346
+ lastRunAt: 0,
347
+ });
348
+
349
+ rt.advance("5h");
350
+ const before = cronHealth((await store.get("hourly"))!, rt.now());
351
+ expect(before.overdue).toBe(true);
352
+ expect(before.missedRuns).toBe(5);
353
+ expect(before.catchUp).toBe("one");
354
+
355
+ const fired: number[] = [];
356
+ rt.onCron("hourly", () => {
357
+ fired.push(rt.now());
358
+ });
359
+ const { ran } = await rt.tick();
360
+ expect(ran).toEqual(["hourly"]);
361
+ expect(fired).toHaveLength(1);
362
+
363
+ // Not five catch-up runs — and not silently zero.
364
+ await rt.tick();
365
+ expect(fired).toHaveLength(1);
366
+ });
367
+ });
368
+
326
369
  describe("DST ambiguity", () => {
327
370
  test("detected from expression plus zone", () => {
328
371
  // 02:00 America/New_York sits in the spring-forward gap / fall-back overlap.
@@ -340,7 +383,17 @@ describe("DST ambiguity", () => {
340
383
  expect(detectDstAmbiguity("0 12 * * *", "America/New_York", Date.UTC(2026, 0, 1))).toBeNull();
341
384
  });
342
385
 
343
- test("reconcile attaches dstAmbiguity on the store row", async () => {
386
+ test("gap vs overlap kinds are distinct for US transitions", () => {
387
+ // 2026-03-08 spring forward → 02:00 skipped (gap).
388
+ const gap = detectDstAmbiguity("0 2 * * *", "America/New_York", Date.UTC(2026, 2, 1));
389
+ expect(gap?.kind).toBe("gap");
390
+
391
+ // 2026-11-01 fall back → 01:30 occurs twice (overlap).
392
+ const overlap = detectDstAmbiguity("30 1 * * *", "America/New_York", Date.UTC(2026, 10, 1));
393
+ expect(overlap?.kind).toBe("overlap");
394
+ });
395
+
396
+ test("reconcile attaches dstAmbiguity on the store row (passive warning)", async () => {
344
397
  const store = createMemoryCronStore();
345
398
  const rt = createTestClockRuntime(Date.UTC(2026, 0, 1), {
346
399
  store,
@@ -352,9 +405,118 @@ describe("DST ambiguity", () => {
352
405
  timezone: "America/New_York",
353
406
  }),
354
407
  );
408
+ // Detection never throws — warn-only, no schedule rewrite.
355
409
  await rt.reconcile();
356
410
  const row = await store.get("nightly");
357
411
  expect(row?.dstAmbiguity).toBeDefined();
358
412
  expect(row?.dstAmbiguity?.localTime).toBe("02:00");
413
+ expect(row?.status).toBe("active");
414
+ expect(row?.effectiveCron).toBe("0 2 * * *");
415
+ });
416
+
417
+ test("doctor does not enforce or surface DST (detection is Console-only)", async () => {
418
+ const doctorSrc = await Bun.file(new URL("../cli/doctor.ts", import.meta.url)).text();
419
+ expect(doctorSrc.toLowerCase()).not.toContain("dst");
420
+ expect(doctorSrc).not.toMatch(/detectDstAmbiguity/);
421
+ });
422
+
423
+ test("overlap day: two civil instants; lease does not span the gap; stub isDue avoids walk", async () => {
424
+ // US fall-back 2026-11-01: 01:30 local occurs twice (~1h apart in UTC).
425
+ const from = Date.UTC(2026, 10, 1, 0, 0, 0);
426
+ const until = Date.UTC(2026, 10, 2, 0, 0, 0);
427
+ const fires = nextOccurrences(
428
+ {
429
+ name: "ambig",
430
+ effectiveCron: "30 1 * * *",
431
+ timezone: "America/New_York",
432
+ overridable: false,
433
+ status: "active",
434
+ },
435
+ from,
436
+ until,
437
+ );
438
+ expect(fires.length).toBe(2);
439
+ expect(fires[1]! - fires[0]!).toBe(3_600_000);
440
+
441
+ // Stub isDue (nextRunAt unset after first fire): does not walk civil fires →
442
+ // advancing across the overlap gap does not double-fire.
443
+ const store = createMemoryCronStore();
444
+ const rt = createTestClockRuntime(0, {
445
+ store,
446
+ instanceId: "dst-fire",
447
+ leaseMs: 1_000,
448
+ });
449
+ rt.register(
450
+ clock("ambig", {
451
+ cron: "30 1 * * *",
452
+ timezone: "America/New_York",
453
+ }),
454
+ );
455
+ await rt.reconcile();
456
+ const ran: number[] = [];
457
+ rt.onCron("ambig", () => {
458
+ ran.push(rt.now());
459
+ });
460
+ expect((await rt.tick()).ran).toEqual(["ambig"]);
461
+ rt.advance(3_600_000);
462
+ expect((await rt.tick()).ran).toEqual([]);
463
+ expect(ran).toHaveLength(1);
464
+
465
+ // Lease TTL << 1h overlap gap: if nextRunAt is driven to the second civil
466
+ // instant, a second fire CAN occur. Leader lease is not a DST policy.
467
+ const store2 = createMemoryCronStore();
468
+ const rt2 = createTestClockRuntime(fires[0]! - 1, {
469
+ store: store2,
470
+ instanceId: "dst-risk",
471
+ leaseMs: 1_000,
472
+ });
473
+ rt2.register(
474
+ clock("ambig2", {
475
+ cron: "30 1 * * *",
476
+ timezone: "America/New_York",
477
+ }),
478
+ );
479
+ await rt2.reconcile();
480
+ await store2.put({
481
+ ...(await store2.get("ambig2"))!,
482
+ nextRunAt: fires[0],
483
+ });
484
+ const ran2: number[] = [];
485
+ rt2.onCron("ambig2", () => {
486
+ ran2.push(rt2.now());
487
+ });
488
+ rt2.advance(2);
489
+ expect((await rt2.tick()).ran).toEqual(["ambig2"]);
490
+ rt2.advance(fires[1]! - rt2.now());
491
+ await store2.put({
492
+ ...(await store2.get("ambig2"))!,
493
+ nextRunAt: fires[1],
494
+ });
495
+ expect((await rt2.tick()).ran).toEqual(["ambig2"]);
496
+ expect(ran2).toHaveLength(2);
497
+ });
498
+ });
499
+
500
+ describe("overridable Console edit", () => {
501
+ test("editSchedule accepts overridable and rejects locked clocks", async () => {
502
+ const store = createMemoryCronStore();
503
+ await reconcileClocks(
504
+ [
505
+ clock("tunable", { every: "1h", overridable: true }),
506
+ clock("locked", { cron: "0 2 * * *", overridable: false }),
507
+ ],
508
+ store,
509
+ );
510
+
511
+ const edited = await editSchedule(store, { name: "tunable", every: "5m" });
512
+ expect(edited.effectiveEvery).toBe("5m");
513
+ expect(edited.overrideEvery).toBe("5m");
514
+
515
+ await expect(editSchedule(store, { name: "locked", cron: "0 3 * * *" })).rejects.toBeInstanceOf(
516
+ ScheduleNotOverridableError,
517
+ );
518
+ await expect(editSchedule(store, { name: "locked", cron: "0 3 * * *" })).rejects.toThrow(
519
+ /not overridable/,
520
+ );
359
521
  });
360
522
  });
@@ -29,6 +29,7 @@ export {
29
29
  reconcileClocks,
30
30
  effectiveSchedule,
31
31
  createMemoryCronStore,
32
+ createFileCronStore,
32
33
  type CronRow,
33
34
  type CronStatus,
34
35
  type CronStore,
@@ -8,6 +8,8 @@ import { takeRate } from "./strategies.ts";
8
8
 
9
9
  /** KV surface required for rate gates. */
10
10
  export interface GateKv {
11
+ /** Driver id when known (`memory` · `redis`). */
12
+ readonly driverId?: "memory" | "redis";
11
13
  eval<T = unknown>(script: string, keys: readonly string[], args?: readonly string[]): Promise<T>;
12
14
  }
13
15
 
@@ -35,6 +37,8 @@ export interface GateEvaluation {
35
37
  export interface GateRuntime {
36
38
  /** Registered declarations by name. */
37
39
  readonly gates: ReadonlyMap<string, GateDecl>;
40
+ /** KV backend used for rate limits (`memory` · `redis`, or unset). */
41
+ readonly kvDriverId: "memory" | "redis" | undefined;
38
42
  /**
39
43
  * Evaluate a gate chain in order; stop on first denial.
40
44
  *
@@ -64,6 +68,7 @@ export function createGateRuntime(options: CreateGateRuntimeOptions = {}): GateR
64
68
  map.set(g.name, g);
65
69
  }
66
70
  const now = options.now ?? (() => Date.now());
71
+ const kvDriverId = options.kv?.driverId;
67
72
 
68
73
  async function evaluateOne(name: string, ctx: GatePolicyContext): Promise<GateEvaluation> {
69
74
  const decl = map.get(name);
@@ -125,6 +130,7 @@ export function createGateRuntime(options: CreateGateRuntimeOptions = {}): GateR
125
130
 
126
131
  return {
127
132
  gates: map,
133
+ kvDriverId,
128
134
  async check(names, ctx) {
129
135
  const out: GateEvaluation[] = [];
130
136
  for (const name of names) {
@@ -132,6 +132,11 @@ export {
132
132
  RetryTransport,
133
133
  FallbackTransport,
134
134
  FallbackError,
135
+ parseSndrWebhook,
136
+ verifySndrSignature,
137
+ parseUnifonicWebhook,
138
+ toChannelSendResult,
139
+ toDeliveryEvent,
135
140
  } from "./channel.ts";
136
141
  export type {
137
142
  ChannelTemplateDecl,
@@ -150,6 +155,10 @@ export type {
150
155
  Transport,
151
156
  MediumCosts,
152
157
  EmailAuthResult,
158
+ EmailEvent,
159
+ DeliveryEvent,
160
+ AnySendResult,
161
+ SentlyChannelSendResult,
153
162
  } from "./channel.ts";
154
163
 
155
164
  export {
@@ -2,9 +2,11 @@
2
2
  * Chaos child — begins a transaction, stages write + emit, then exits
3
3
  * before commit (or is SIGKILL'd by the parent). Used by signal.test.ts.
4
4
  *
5
- * Args: <durablePath> <mode>
6
- * mode = "mid-txn" → write + emit, then exit(99) without commit
7
- * mode = "commit" → write + emit + commit, then exit(0)
5
+ * Args: <durablePath> <mode> [markerPath] [leaseMs]
6
+ * mode = "mid-txn" → write + emit, then exit(99) without commit
7
+ * mode = "commit" → write + emit + commit, then exit(0)
8
+ * mode = "consume-hang" → subscribe, drain one message, write marker, hang
9
+ * (parent SIGKILL after claim; lease reclaim tested)
8
10
  */
9
11
 
10
12
  import { join } from "node:path";
@@ -14,9 +16,13 @@ import { createSignalRuntime } from "./runtime.ts";
14
16
 
15
17
  const durablePath = process.argv[2];
16
18
  const mode = process.argv[3] ?? "mid-txn";
19
+ const markerPath = process.argv[4];
20
+ const leaseMs = process.argv[5] !== undefined ? Number(process.argv[5]) : undefined;
17
21
 
18
22
  if (!durablePath) {
19
- console.error("usage: chaos-child <durablePath> <mid-txn|commit>");
23
+ console.error(
24
+ "usage: chaos-child <durablePath> <mid-txn|commit|consume-hang> [markerPath] [leaseMs]",
25
+ );
20
26
  process.exit(2);
21
27
  }
22
28
 
@@ -24,8 +30,39 @@ const orderPlaced = signal("order-placed", {
24
30
  delivery: "once",
25
31
  retries: 3,
26
32
  deadLetter: true,
33
+ // Producer-only / cross-process consumer: no local subscriber at emit time.
34
+ optional: true,
27
35
  });
28
36
 
37
+ if (mode === "consume-hang") {
38
+ if (!markerPath) {
39
+ console.error("consume-hang requires markerPath");
40
+ process.exit(2);
41
+ }
42
+ const runtime = createSignalRuntime({
43
+ driver: memorySignalDriver,
44
+ durablePath: join(durablePath),
45
+ leaseMs: Number.isFinite(leaseMs) ? leaseMs : 50,
46
+ });
47
+ runtime.register(orderPlaced);
48
+ const bus = await runtime.start();
49
+ let claimed = false;
50
+ await bus.subscribe("order-placed", "chaos-consumer", async () => {
51
+ claimed = true;
52
+ await Bun.write(markerPath, "claimed");
53
+ // Hang while holding the claim — parent will SIGKILL before ack.
54
+ await Bun.sleep(60_000);
55
+ });
56
+ await bus.drain();
57
+ if (!claimed) {
58
+ console.error("consume-hang: no message claimed");
59
+ process.exit(3);
60
+ }
61
+ // Should be unreachable — parent kills us while sleeping in the handler.
62
+ await Bun.sleep(60_000);
63
+ process.exit(0);
64
+ }
65
+
29
66
  const runtime = createSignalRuntime({
30
67
  driver: memorySignalDriver,
31
68
  durablePath: join(durablePath),
@@ -23,7 +23,10 @@ export interface SignalOptions {
23
23
  readonly retries?: number;
24
24
  /** Preserve exhausted messages in the DLQ (once). */
25
25
  readonly deadLetter?: boolean;
26
- /** Optional payload schema (Standard Schema / opaque). */
26
+ /**
27
+ * Optional payload schema (Standard Schema).
28
+ * Enforced at `emit` via the same `validate()` path as Flow `in`.
29
+ */
27
30
  readonly schema?: unknown;
28
31
  /** Allow emit with zero subscribers (skip orphan check). */
29
32
  readonly optional?: boolean;
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Signal delivery-mode adversarial proofs:
3
+ * - once: one message → exactly one of two competing consumers
4
+ * - once: retries then DLQ (and deadLetter: false)
5
+ * - broadcast: every subscriber gets a copy
6
+ * - live: late subscriber replays full retained history (unbounded)
7
+ */
8
+
9
+ import { afterEach, describe, expect, test } from "bun:test";
10
+
11
+ import {
12
+ createPostgresSignalFake,
13
+ createSignalNatsFake,
14
+ createSignalRedisFake,
15
+ memorySignalDriver,
16
+ natsSignalDriver,
17
+ postgresSignalDriver,
18
+ redisSignalDriver,
19
+ type SignalBus,
20
+ type SignalDriver,
21
+ } from "../../drivers/index.ts";
22
+ import { signal, type SignalDecl } from "./declare.ts";
23
+
24
+ const drivers: Array<{
25
+ label: string;
26
+ driver: SignalDriver;
27
+ setup?: () => Record<string, unknown>;
28
+ }> = [
29
+ { label: "memory", driver: memorySignalDriver },
30
+ {
31
+ label: "postgres",
32
+ driver: postgresSignalDriver,
33
+ setup: () => ({ sql: createPostgresSignalFake() }),
34
+ },
35
+ {
36
+ label: "redis",
37
+ driver: redisSignalDriver,
38
+ setup: () => ({ redis: createSignalRedisFake() }),
39
+ },
40
+ {
41
+ label: "nats",
42
+ driver: natsSignalDriver,
43
+ setup: () => ({ nats: createSignalNatsFake() }),
44
+ },
45
+ ];
46
+
47
+ const openBuses: SignalBus[] = [];
48
+
49
+ afterEach(async () => {
50
+ while (openBuses.length) {
51
+ await openBuses.pop()!.close();
52
+ }
53
+ });
54
+
55
+ async function openBus(
56
+ driver: SignalDriver,
57
+ decls: readonly SignalDecl[],
58
+ extra: Record<string, unknown> = {},
59
+ ): Promise<SignalBus> {
60
+ const signals = new Map(decls.map((d) => [d.name, d]));
61
+ const bus = await driver.open({
62
+ signals,
63
+ ...extra,
64
+ });
65
+ openBuses.push(bus);
66
+ return bus;
67
+ }
68
+
69
+ for (const { label, driver, setup } of drivers) {
70
+ describe(`signal delivery modes · ${label}`, () => {
71
+ test("once: one message reaches exactly one of two competing consumers", async () => {
72
+ // Shape matches Linkly linkClicked / Provisions order-placed.
73
+ const once = signal("link-clicked", {
74
+ delivery: "once",
75
+ retries: 3,
76
+ deadLetter: true,
77
+ });
78
+ const bus = await openBus(driver, [once], setup?.() ?? {});
79
+ const got: string[] = [];
80
+
81
+ await bus.subscribe("link-clicked", "worker-a", async (m) => {
82
+ got.push(`a:${(m.payload as { code: string }).code}`);
83
+ });
84
+ await bus.subscribe("link-clicked", "worker-b", async (m) => {
85
+ got.push(`b:${(m.payload as { code: string }).code}`);
86
+ });
87
+
88
+ await bus.emit("link-clicked", { code: "abc" });
89
+ await bus.drain();
90
+
91
+ expect(got).toHaveLength(1);
92
+ expect(got[0] === "a:abc" || got[0] === "b:abc").toBe(true);
93
+ });
94
+
95
+ test("once: retries+1 attempts then dead-letter when deadLetter: true", async () => {
96
+ const once = signal("flaky", {
97
+ delivery: "once",
98
+ retries: 2,
99
+ deadLetter: true,
100
+ });
101
+ const bus = await openBus(driver, [once], setup?.() ?? {});
102
+ let attempts = 0;
103
+ await bus.subscribe("flaky", "c1", async () => {
104
+ attempts += 1;
105
+ throw new Error(`boom-${attempts}`);
106
+ });
107
+
108
+ await bus.emit("flaky", { x: 1 });
109
+ await bus.drain();
110
+
111
+ // attempts > retries ⇒ retries + 1 total handler invocations.
112
+ expect(attempts).toBe(3);
113
+ const dlq = await bus.deadLetters("flaky");
114
+ expect(dlq).toHaveLength(1);
115
+ expect(dlq[0]!.failures.map((f) => f.attempt)).toEqual([1, 2, 3]);
116
+ });
117
+
118
+ test("once: deadLetter: false marks delivered after exhausting retries", async () => {
119
+ const once = signal("flaky-drop", {
120
+ delivery: "once",
121
+ retries: 1,
122
+ deadLetter: false,
123
+ });
124
+ const bus = await openBus(driver, [once], setup?.() ?? {});
125
+ let attempts = 0;
126
+ await bus.subscribe("flaky-drop", "c1", async () => {
127
+ attempts += 1;
128
+ throw new Error("nope");
129
+ });
130
+
131
+ await bus.emit("flaky-drop", { x: 1 });
132
+ await bus.drain();
133
+
134
+ expect(attempts).toBe(2);
135
+ expect(await bus.deadLetters("flaky-drop")).toHaveLength(0);
136
+ const stats = await bus.inspect("flaky-drop");
137
+ expect(stats[0]?.dead).toBe(0);
138
+ expect(stats[0]?.delivered).toBe(1);
139
+ });
140
+
141
+ test("broadcast: every subscriber receives its own copy", async () => {
142
+ const bcast = signal("news", { delivery: "broadcast" });
143
+ const bus = await openBus(driver, [bcast], setup?.() ?? {});
144
+ const a: unknown[] = [];
145
+ const b: unknown[] = [];
146
+ await bus.subscribe("news", "sub-a", async (m) => {
147
+ a.push(m.payload);
148
+ });
149
+ await bus.subscribe("news", "sub-b", async (m) => {
150
+ b.push(m.payload);
151
+ });
152
+
153
+ await bus.emit("news", { headline: "hi" });
154
+ await bus.drain();
155
+
156
+ expect(a).toEqual([{ headline: "hi" }]);
157
+ expect(b).toEqual([{ headline: "hi" }]);
158
+ });
159
+
160
+ test("live: late subscriber replays full retained history (unbounded)", async () => {
161
+ // Shape matches Skyport seat-feed.
162
+ const live = signal("seat-feed", { delivery: "live", optional: true });
163
+ const bus = await openBus(driver, [live], setup?.() ?? {});
164
+
165
+ await bus.emit("seat-feed", { seat: "12A" });
166
+ await bus.emit("seat-feed", { seat: "12B" });
167
+ await bus.emit("seat-feed", { seat: "12C" });
168
+ await bus.drain();
169
+
170
+ const late: unknown[] = [];
171
+ await bus.live("seat-feed", (payload) => {
172
+ late.push(payload);
173
+ });
174
+
175
+ // All retained live messages replay — not the Console recentLive cap of 50.
176
+ expect(late).toEqual([{ seat: "12A" }, { seat: "12B" }, { seat: "12C" }]);
177
+ });
178
+ });
179
+ }