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
@@ -8,21 +8,25 @@
8
8
  import type { SignalDecl } from "../elements/signal/declare.ts";
9
9
  import type { SignalDelivery } from "../manifest/types.ts";
10
10
  import { DryRunWriteIsolationError, setDryRunMessageId, withDryRun } from "../kernel/dry-run.ts";
11
- import type {
12
- DeadLetter,
13
- LiveHandler,
14
- SignalBus,
15
- SignalDiscardOptions,
16
- SignalDriver,
17
- SignalFailureReason,
18
- SignalHandler,
19
- SignalMessage,
20
- SignalOpenOptions,
21
- SignalReplayOptions,
22
- SignalReplayResult,
23
- SignalStats,
24
- SignalTransaction,
25
- SignalUnsubscribe,
11
+ import { OkeError, OKE_ERRORS } from "../kernel/errors.ts";
12
+ import {
13
+ SIGNAL_DEFAULT_LEASE_MS,
14
+ validateSignalEmitPayload,
15
+ type DeadLetter,
16
+ type LiveHandler,
17
+ type SignalBus,
18
+ type SignalDiscardOptions,
19
+ type SignalDriver,
20
+ type SignalEmitOptions,
21
+ type SignalFailureReason,
22
+ type SignalHandler,
23
+ type SignalMessage,
24
+ type SignalOpenOptions,
25
+ type SignalReplayOptions,
26
+ type SignalReplayResult,
27
+ type SignalStats,
28
+ type SignalTransaction,
29
+ type SignalUnsubscribe,
26
30
  } from "./signal-types.ts";
27
31
 
28
32
  /** Row shape in `oke_signal_messages`. */
@@ -30,6 +34,7 @@ interface MsgRow {
30
34
  id: string;
31
35
  signal: string;
32
36
  payload: string;
37
+ ordering_key: string | null;
33
38
  delivery: SignalDelivery;
34
39
  attempts: number;
35
40
  failures: string;
@@ -37,6 +42,7 @@ interface MsgRow {
37
42
  available_at: number;
38
43
  status: "pending" | "inflight" | "delivered" | "dead";
39
44
  locked_by: string | null;
45
+ lease_expires_at: number | null;
40
46
  delivered_to: string;
41
47
  }
42
48
 
@@ -69,6 +75,9 @@ export interface PostgresSignalSql {
69
75
 
70
76
  const CHANNEL = "oke_signal";
71
77
 
78
+ /** Claim next once-row: pending/unlocked or lease-expired, blocked by same-key inflight. */
79
+ const CLAIM_ONCE_SQL = `SELECT * FROM oke_signal_messages WHERE signal=? AND delivery='once' AND available_at<=? AND ((status='pending' AND locked_by IS NULL) OR (status='inflight' AND lease_expires_at IS NOT NULL AND lease_expires_at<=?)) AND (ordering_key IS NULL OR NOT EXISTS (SELECT 1 FROM oke_signal_messages h WHERE h.signal=oke_signal_messages.signal AND h.ordering_key=oke_signal_messages.ordering_key AND h.id<>oke_signal_messages.id AND h.status='inflight' AND h.lease_expires_at IS NOT NULL AND h.lease_expires_at>?)) ORDER BY created_at ASC LIMIT 1 FOR UPDATE SKIP LOCKED`;
80
+
72
81
  /**
73
82
  * In-memory Postgres-protocol fake with transactions, SKIP LOCKED, LISTEN/NOTIFY.
74
83
  */
@@ -100,7 +109,11 @@ export function createPostgresSignalFake(options?: {
100
109
  writes: Array<[string, unknown]>;
101
110
  };
102
111
  committed = {
103
- messages: snap.messages,
112
+ messages: snap.messages.map((m) => ({
113
+ ...m,
114
+ ordering_key: m.ordering_key ?? null,
115
+ lease_expires_at: m.lease_expires_at ?? null,
116
+ })),
104
117
  writes: new Map(snap.writes),
105
118
  };
106
119
  }
@@ -150,26 +163,40 @@ export function createPostgresSignalFake(options?: {
150
163
  const text = sql.trim();
151
164
  const state = view();
152
165
 
153
- // Claim: SELECT … FOR UPDATE SKIP LOCKED
154
- const claim =
155
- /^SELECT\s+\*\s+FROM\s+oke_signal_messages\s+WHERE\s+signal\s*=\s*\?\s+AND\s+delivery\s*=\s*'once'\s+AND\s+status\s*=\s*'pending'\s+AND\s+available_at\s*<=\s*\?\s+AND\s+locked_by\s+IS\s+NULL\s+ORDER\s+BY\s+created_at\s+ASC\s+LIMIT\s+1\s+FOR\s+UPDATE\s+SKIP\s+LOCKED$/i.exec(
156
- text,
157
- );
158
- if (claim) {
166
+ // Claim: SELECT … FOR UPDATE SKIP LOCKED (pending or lease-expired inflight),
167
+ // with per-key serialization when ordering_key is set.
168
+ const isOnceClaim =
169
+ /FOR\s+UPDATE\s+SKIP\s+LOCKED/i.test(text) &&
170
+ /oke_signal_messages/i.test(text) &&
171
+ /delivery\s*=\s*'once'/i.test(text);
172
+ if (isOnceClaim) {
159
173
  const signal = String(params[0]);
160
174
  const t = Number(params[1]);
161
- const row = state.messages.find(
162
- (m) =>
163
- m.signal === signal &&
164
- m.delivery === "once" &&
165
- m.status === "pending" &&
166
- m.available_at <= t &&
167
- m.locked_by === null,
168
- );
175
+ const leaseCutoff = Number(params[2]);
176
+ const keyLeaseCutoff = params.length >= 4 ? Number(params[3]) : t;
177
+ const row = state.messages.find((m) => {
178
+ if (m.signal !== signal || m.delivery !== "once" || m.available_at > t) return false;
179
+ const pendingOk = m.status === "pending" && m.locked_by === null;
180
+ const leaseExpired =
181
+ m.status === "inflight" &&
182
+ m.lease_expires_at !== null &&
183
+ m.lease_expires_at <= leaseCutoff;
184
+ if (!pendingOk && !leaseExpired) return false;
185
+ if (m.ordering_key != null && m.ordering_key !== "") {
186
+ const blocked = state.messages.some(
187
+ (other) =>
188
+ other.id !== m.id &&
189
+ other.signal === m.signal &&
190
+ other.ordering_key === m.ordering_key &&
191
+ other.status === "inflight" &&
192
+ other.lease_expires_at !== null &&
193
+ other.lease_expires_at > keyLeaseCutoff,
194
+ );
195
+ if (blocked) return false;
196
+ }
197
+ return true;
198
+ });
169
199
  if (!row) return [];
170
- row.status = "inflight";
171
- row.locked_by = String(params[2] ?? "worker");
172
- row.attempts += 1;
173
200
  return [{ ...row }];
174
201
  }
175
202
 
@@ -261,6 +288,10 @@ export function createPostgresSignalFake(options?: {
261
288
  id: String(row.id),
262
289
  signal: String(row.signal),
263
290
  payload: String(row.payload),
291
+ ordering_key:
292
+ row.ordering_key === undefined || row.ordering_key === null
293
+ ? null
294
+ : String(row.ordering_key),
264
295
  delivery: row.delivery as SignalDelivery,
265
296
  attempts: Number(row.attempts ?? 0),
266
297
  failures: String(row.failures ?? "[]"),
@@ -268,6 +299,10 @@ export function createPostgresSignalFake(options?: {
268
299
  available_at: Number(row.available_at),
269
300
  status: (row.status as MsgRow["status"]) ?? "pending",
270
301
  locked_by: (row.locked_by as string | null) ?? null,
302
+ lease_expires_at:
303
+ row.lease_expires_at === undefined || row.lease_expires_at === null
304
+ ? null
305
+ : Number(row.lease_expires_at),
271
306
  delivered_to: String(row.delivered_to ?? "[]"),
272
307
  });
273
308
  return { changes: 1 };
@@ -308,6 +343,10 @@ export function createPostgresSignalFake(options?: {
308
343
  return { changes: 1 };
309
344
  }
310
345
 
346
+ if (/^ALTER\s+TABLE\s+oke_signal_messages\s+ADD\s+COLUMN/i.test(text)) {
347
+ return { changes: 0 };
348
+ }
349
+
311
350
  throw new Error(`postgres signal fake: unsupported exec: ${sql}`);
312
351
  },
313
352
  async begin(fn) {
@@ -361,6 +400,7 @@ async function ensureSchema(sql: PostgresSignalSql): Promise<void> {
361
400
  id TEXT PRIMARY KEY,
362
401
  signal TEXT,
363
402
  payload TEXT,
403
+ ordering_key TEXT,
364
404
  delivery TEXT,
365
405
  attempts INTEGER,
366
406
  failures TEXT,
@@ -368,8 +408,22 @@ async function ensureSchema(sql: PostgresSignalSql): Promise<void> {
368
408
  available_at BIGINT,
369
409
  status TEXT,
370
410
  locked_by TEXT,
411
+ lease_expires_at BIGINT,
371
412
  delivered_to TEXT
372
413
  )`);
414
+ // Existing tables created before leases / keys: add columns in place.
415
+ try {
416
+ await sql.exec(
417
+ `ALTER TABLE oke_signal_messages ADD COLUMN IF NOT EXISTS lease_expires_at BIGINT`,
418
+ );
419
+ } catch {
420
+ /* fake / older engines without IF NOT EXISTS — ignore */
421
+ }
422
+ try {
423
+ await sql.exec(`ALTER TABLE oke_signal_messages ADD COLUMN IF NOT EXISTS ordering_key TEXT`);
424
+ } catch {
425
+ /* fake / older engines without IF NOT EXISTS — ignore */
426
+ }
373
427
  await sql.exec(`CREATE TABLE IF NOT EXISTS oke_signal_writes (
374
428
  key TEXT PRIMARY KEY,
375
429
  value TEXT
@@ -377,10 +431,15 @@ async function ensureSchema(sql: PostgresSignalSql): Promise<void> {
377
431
  }
378
432
 
379
433
  function rowToMessage(row: Record<string, unknown>): SignalMessage {
434
+ const key =
435
+ row.ordering_key === undefined || row.ordering_key === null || row.ordering_key === ""
436
+ ? undefined
437
+ : String(row.ordering_key);
380
438
  return {
381
439
  id: String(row.id),
382
440
  signal: String(row.signal),
383
441
  payload: JSON.parse(String(row.payload)),
442
+ ...(key !== undefined ? { key } : {}),
384
443
  delivery: row.delivery as SignalDelivery,
385
444
  attempts: Number(row.attempts),
386
445
  failures: JSON.parse(String(row.failures ?? "[]")) as SignalFailureReason[],
@@ -398,6 +457,7 @@ function rowToMessage(row: Record<string, unknown>): SignalMessage {
398
457
  export async function openPostgresSignal(options: SignalOpenOptions): Promise<SignalBus> {
399
458
  const now = options.now ?? (() => Date.now());
400
459
  const signals = options.signals;
460
+ const leaseMs = options.leaseMs ?? SIGNAL_DEFAULT_LEASE_MS;
401
461
  const sql =
402
462
  (options.sql as PostgresSignalSql | undefined) ??
403
463
  createPostgresSignalFake({
@@ -468,19 +528,39 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
468
528
  return decl;
469
529
  }
470
530
 
531
+ function hasSubscriber(name: string): boolean {
532
+ if (consumers.some((c) => c.signal === name)) return true;
533
+ const live = liveHandlers.get(name);
534
+ return live !== undefined && live.size > 0;
535
+ }
536
+
537
+ function assertEmitAllowed(name: string): void {
538
+ const decl = requireDecl(name);
539
+ if (decl.optional) return;
540
+ if (hasSubscriber(name)) return;
541
+ throw new OkeError(OKE_ERRORS.ORPHAN_EMIT, {
542
+ flow: "unknown",
543
+ resource: name,
544
+ });
545
+ }
546
+
471
547
  async function insertEmit(
472
548
  tx: PostgresSignalSql,
473
549
  signal: string,
474
550
  payload: unknown,
551
+ options?: SignalEmitOptions,
475
552
  ): Promise<void> {
476
553
  const decl = requireDecl(signal);
477
554
  const t = now();
555
+ const orderingKey =
556
+ typeof options?.key === "string" && options.key.length > 0 ? options.key : null;
478
557
  await tx.exec(
479
- `INSERT INTO oke_signal_messages (id, signal, payload, delivery, attempts, failures, created_at, available_at, status, locked_by, delivered_to) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
558
+ `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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
480
559
  [
481
560
  crypto.randomUUID(),
482
561
  signal,
483
562
  JSON.stringify(payload ?? null),
563
+ orderingKey,
484
564
  decl.delivery,
485
565
  0,
486
566
  "[]",
@@ -488,6 +568,7 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
488
568
  t,
489
569
  "pending",
490
570
  null,
571
+ null,
491
572
  "[]",
492
573
  ],
493
574
  );
@@ -497,7 +578,11 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
497
578
  let finished = false;
498
579
  // Defer the real SQL begin until commit so staging is local,
499
580
  // then enrol everything in one postgres transaction.
500
- const stagedEmits: Array<{ signal: string; payload: unknown }> = [];
581
+ const stagedEmits: Array<{
582
+ signal: string;
583
+ payload: unknown;
584
+ options?: SignalEmitOptions;
585
+ }> = [];
501
586
  const stagedWrites = new Map<string, unknown>();
502
587
 
503
588
  return {
@@ -505,10 +590,12 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
505
590
  if (finished) throw new Error("transaction finished");
506
591
  stagedWrites.set(key, value);
507
592
  },
508
- async emit(signal, payload) {
593
+ async emit(signal, payload, options) {
509
594
  if (finished) throw new Error("transaction finished");
510
- requireDecl(signal);
511
- stagedEmits.push({ signal, payload });
595
+ assertEmitAllowed(signal);
596
+ // Validate before staging so commit never sees an invalid payload.
597
+ const value = await validateSignalEmitPayload(signal, requireDecl(signal), payload);
598
+ stagedEmits.push({ signal, payload: value, options });
512
599
  },
513
600
  async commit() {
514
601
  if (finished) throw new Error("transaction finished");
@@ -521,7 +608,7 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
521
608
  ]);
522
609
  }
523
610
  for (const e of stagedEmits) {
524
- await insertEmit(tx, e.signal, e.payload);
611
+ await insertEmit(tx, e.signal, e.payload, e.options);
525
612
  }
526
613
  });
527
614
  await sql.notify(CHANNEL, "commit");
@@ -541,18 +628,26 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
541
628
  * @param tx - Caller's postgres transaction
542
629
  * @param signal - Signal name
543
630
  * @param payload - Payload
631
+ * @param options - Optional emit options
544
632
  */
545
633
  async function emitInTransaction(
546
634
  tx: PostgresSignalSql,
547
635
  signal: string,
548
636
  payload?: unknown,
637
+ options?: SignalEmitOptions,
549
638
  ): Promise<void> {
550
- await insertEmit(tx, signal, payload);
639
+ assertEmitAllowed(signal);
640
+ const value = await validateSignalEmitPayload(signal, requireDecl(signal), payload);
641
+ await insertEmit(tx, signal, value, options);
551
642
  }
552
643
 
553
- async function emit(signal: string, payload?: unknown): Promise<void> {
644
+ async function emit(
645
+ signal: string,
646
+ payload?: unknown,
647
+ options?: SignalEmitOptions,
648
+ ): Promise<void> {
554
649
  const tx = await begin();
555
- await tx.emit(signal, payload);
650
+ await tx.emit(signal, payload, options);
556
651
  await tx.commit();
557
652
  }
558
653
 
@@ -601,21 +696,21 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
601
696
  const msg = rowToMessage(row);
602
697
  try {
603
698
  await consumer.handler(msg);
604
- await sql.exec(`UPDATE oke_signal_messages SET status = ?, locked_by = NULL WHERE id = ?`, [
605
- "delivered",
606
- msg.id,
607
- ]);
699
+ await sql.exec(
700
+ `UPDATE oke_signal_messages SET status = ?, locked_by = NULL, lease_expires_at = NULL WHERE id = ?`,
701
+ ["delivered", msg.id],
702
+ );
608
703
  noteDelivered();
609
704
  } catch (err) {
610
705
  const failures = [...msg.failures, failureFromError(err, msg.attempts)];
611
706
  if (msg.attempts > decl.retries) {
612
707
  await sql.exec(
613
- `UPDATE oke_signal_messages SET status = ?, locked_by = NULL, failures = ? WHERE id = ?`,
708
+ `UPDATE oke_signal_messages SET status = ?, locked_by = NULL, lease_expires_at = NULL, failures = ? WHERE id = ?`,
614
709
  [decl.deadLetter ? "dead" : "delivered", JSON.stringify(failures), msg.id],
615
710
  );
616
711
  } else {
617
712
  await sql.exec(
618
- `UPDATE oke_signal_messages SET status = ?, locked_by = NULL, failures = ?, available_at = ? WHERE id = ?`,
713
+ `UPDATE oke_signal_messages SET status = ?, locked_by = NULL, lease_expires_at = NULL, failures = ?, available_at = ? WHERE id = ?`,
619
714
  ["pending", JSON.stringify(failures), now(), msg.id],
620
715
  );
621
716
  }
@@ -632,25 +727,24 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
632
727
 
633
728
  async function drainOncePass(): Promise<boolean> {
634
729
  let progress = false;
730
+ const t = now();
635
731
  for (const consumer of consumers) {
636
732
  const decl = signals.get(consumer.signal);
637
733
  if (decl?.delivery !== "once") continue;
638
- const claimed = await sql.query(
639
- `SELECT * FROM oke_signal_messages WHERE signal = ? AND delivery = 'once' AND status = 'pending' AND available_at <= ? AND locked_by IS NULL ORDER BY created_at ASC LIMIT 1 FOR UPDATE SKIP LOCKED`,
640
- [consumer.signal, now(), consumer.subscriberId],
641
- );
734
+ const claimed = await sql.query(CLAIM_ONCE_SQL, [consumer.signal, t, t, t]);
642
735
  if (claimed[0]) {
643
736
  progress = true;
644
737
  const row = claimed[0];
645
- if (row.locked_by == null) {
646
- await sql.exec(
647
- `UPDATE oke_signal_messages SET status = 'inflight', locked_by = ?, attempts = ? WHERE id = ?`,
648
- [consumer.subscriberId, Number(row.attempts) + 1, row.id],
649
- );
650
- row.locked_by = consumer.subscriberId;
651
- row.attempts = Number(row.attempts) + 1;
652
- row.status = "inflight";
653
- }
738
+ const attempts = Number(row.attempts) + 1;
739
+ const leaseExpiresAt = t + leaseMs;
740
+ await sql.exec(
741
+ `UPDATE oke_signal_messages SET status = 'inflight', locked_by = ?, attempts = ?, lease_expires_at = ? WHERE id = ?`,
742
+ [consumer.subscriberId, attempts, leaseExpiresAt, row.id],
743
+ );
744
+ row.locked_by = consumer.subscriberId;
745
+ row.attempts = attempts;
746
+ row.status = "inflight";
747
+ row.lease_expires_at = leaseExpiresAt;
654
748
  await deliverOnce(row, consumer);
655
749
  }
656
750
  }
@@ -726,7 +820,7 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
726
820
  while (list.length > 50) list.shift();
727
821
  noteDelivered();
728
822
  await sql.exec(
729
- `UPDATE oke_signal_messages SET status = 'delivered', locked_by = NULL WHERE id = ?`,
823
+ `UPDATE oke_signal_messages SET status = 'delivered', locked_by = NULL, lease_expires_at = NULL WHERE id = ?`,
730
824
  [row.id],
731
825
  );
732
826
  }
@@ -945,12 +1039,12 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
945
1039
  const deliveredTo = new Set(JSON.parse(String(row?.delivered_to ?? "[]")) as string[]);
946
1040
  deliveredTo.delete(options.subscriberId);
947
1041
  await sql.exec(
948
- `UPDATE oke_signal_messages SET status = 'pending', locked_by = NULL, attempts = 0, available_at = ?, delivered_to = ? WHERE id = ?`,
1042
+ `UPDATE oke_signal_messages SET status = 'pending', locked_by = NULL, lease_expires_at = NULL, attempts = 0, available_at = ?, delivered_to = ? WHERE id = ?`,
949
1043
  [now(), JSON.stringify([...deliveredTo]), m.id],
950
1044
  );
951
1045
  } else {
952
1046
  await sql.exec(
953
- `UPDATE oke_signal_messages SET status = 'pending', locked_by = NULL, attempts = 0, available_at = ?, delivered_to = '[]' WHERE id = ?`,
1047
+ `UPDATE oke_signal_messages SET status = 'pending', locked_by = NULL, lease_expires_at = NULL, attempts = 0, available_at = ?, delivered_to = '[]' WHERE id = ?`,
954
1048
  [now(), m.id],
955
1049
  );
956
1050
  }
@@ -222,8 +222,8 @@ export async function openRedisSignal(options: SignalOpenOptions): Promise<Signa
222
222
 
223
223
  return {
224
224
  driverId: "redis",
225
- async emit(signal, payload) {
226
- await outbox.emit(signal, payload);
225
+ async emit(signal, payload, options) {
226
+ await outbox.emit(signal, payload, options);
227
227
  await relayToRedis(signal, payload);
228
228
  },
229
229
  async begin() {
@@ -231,9 +231,9 @@ export async function openRedisSignal(options: SignalOpenOptions): Promise<Signa
231
231
  const tx = await outbox.begin();
232
232
  return {
233
233
  write: (k, v) => tx.write(k, v),
234
- async emit(signal, payload) {
234
+ async emit(signal, payload, options) {
235
235
  staged.push({ signal, payload });
236
- await tx.emit(signal, payload);
236
+ await tx.emit(signal, payload, options);
237
237
  },
238
238
  async commit() {
239
239
  await tx.commit();
@@ -4,6 +4,12 @@
4
4
 
5
5
  import type { SignalDelivery } from "../manifest/types.ts";
6
6
  import type { SignalDecl } from "../elements/signal/declare.ts";
7
+ import { OkeError, OKE_ERRORS } from "../kernel/errors.ts";
8
+ import {
9
+ isStandardSchema,
10
+ normalizeIssuePath,
11
+ type SchemaInput,
12
+ } from "../validation/standard-schema.ts";
7
13
 
8
14
  /** Protocol ids for signal drivers. */
9
15
  export type SignalDriverId = "memory" | "postgres" | "redis" | "nats";
@@ -24,11 +30,25 @@ export interface SignalFailureReason {
24
30
  readonly attempt: number;
25
31
  }
26
32
 
33
+ /** Options for {@link SignalBus.emit} / {@link SignalTransaction.emit}. */
34
+ export interface SignalEmitOptions {
35
+ /**
36
+ * Per-key serialization for `once` delivery.
37
+ *
38
+ * When set, no two messages sharing the same `(signal, key)` are claimed
39
+ * concurrently — the in-flight message's visibility lease is the lock.
40
+ * Omit for pure competing-consumer behavior (no ordering guarantee).
41
+ */
42
+ readonly key?: string;
43
+ }
44
+
27
45
  /** A durable signal message. */
28
46
  export interface SignalMessage {
29
47
  readonly id: string;
30
48
  readonly signal: string;
31
49
  readonly payload: unknown;
50
+ /** Optional ordering key from {@link SignalEmitOptions.key}. */
51
+ readonly key?: string;
32
52
  readonly delivery: SignalDelivery;
33
53
  readonly attempts: number;
34
54
  readonly failures: readonly SignalFailureReason[];
@@ -180,20 +200,30 @@ export interface SignalTransaction {
180
200
  *
181
201
  * @param signal - Signal name
182
202
  * @param payload - Payload
203
+ * @param options - Optional emit options (`key` for per-key once ordering)
183
204
  */
184
- emit(signal: string, payload?: unknown): Promise<void>;
205
+ emit(signal: string, payload?: unknown, options?: SignalEmitOptions): Promise<void>;
185
206
  /** Commit writes + emits together. */
186
207
  commit(): Promise<void>;
187
208
  /** Discard the transaction — nothing is visible. */
188
209
  rollback(): Promise<void>;
189
210
  }
190
211
 
212
+ /** Default visibility lease for `once` claims (ms). */
213
+ export const SIGNAL_DEFAULT_LEASE_MS = 30_000;
214
+
191
215
  /** Options when opening a signal bus. */
192
216
  export interface SignalOpenOptions {
193
217
  /** Registered signal declarations (name → decl). */
194
218
  readonly signals: ReadonlyMap<string, SignalDecl>;
195
219
  /** Clock. */
196
220
  readonly now?: () => number;
221
+ /**
222
+ * Visibility lease for `once` claims (ms).
223
+ * Expired `inflight` rows are reclaimed lazily at the next claim.
224
+ * Defaults to {@link SIGNAL_DEFAULT_LEASE_MS}.
225
+ */
226
+ readonly leaseMs?: number;
197
227
  /**
198
228
  * Durable path for chaos / crash recovery tests.
199
229
  * When set, committed state survives process death.
@@ -252,8 +282,9 @@ export interface SignalBus {
252
282
  *
253
283
  * @param signal - Signal name
254
284
  * @param payload - Payload
285
+ * @param options - Optional emit options (`key` for per-key once ordering)
255
286
  */
256
- emit(signal: string, payload?: unknown): Promise<void>;
287
+ emit(signal: string, payload?: unknown, options?: SignalEmitOptions): Promise<void>;
257
288
  /** Begin a transaction that enrols writes + emits. */
258
289
  begin(): Promise<SignalTransaction>;
259
290
  /**
@@ -323,3 +354,31 @@ export interface SignalDriver {
323
354
  */
324
355
  open(options: SignalOpenOptions): Promise<SignalBus>;
325
356
  }
357
+
358
+ /**
359
+ * Validate an emit payload against the signal declaration's Standard Schema.
360
+ *
361
+ * @param name - Signal name (for OKE1043)
362
+ * @param decl - Declared signal
363
+ * @param payload - Raw emit payload
364
+ */
365
+ export async function validateSignalEmitPayload(
366
+ name: string,
367
+ decl: SignalDecl,
368
+ payload: unknown,
369
+ ): Promise<unknown> {
370
+ const schema = decl.schema as SchemaInput | undefined;
371
+ if (schema === undefined || schema === null || !isStandardSchema(schema)) {
372
+ return payload ?? null;
373
+ }
374
+ const result = await schema["~standard"].validate(payload ?? null);
375
+ if (!result.issues) return result.value;
376
+ const detail =
377
+ result.issues
378
+ .map((i) => {
379
+ const path = normalizeIssuePath(i.path);
380
+ return path.length > 0 ? `${path.join(".")}: ${i.message}` : i.message;
381
+ })
382
+ .join("; ") || "invalid payload";
383
+ throw new OkeError(OKE_ERRORS.SIGNAL_SCHEMA, { resource: name, detail });
384
+ }
@@ -18,7 +18,11 @@ const FORBIDDEN_SECRETS_SAAS = ["infi", "sical"].join("");
18
18
  const FORBIDDEN_AGE = ["age", "-", "encryption"].join("");
19
19
 
20
20
  /** Lockfiles regenerated by `bun install` — not source residue. */
21
- const LOCKFILE_PREFIXES = ["bun.lock:", "packages/create-oke/template/bun.lock:"];
21
+ const LOCKFILE_PREFIXES = [
22
+ "bun.lock:",
23
+ "packages/create-oke/templates/standard/bun.lock:",
24
+ "packages/create-oke/templates/advanced/bun.lock:",
25
+ ];
22
26
 
23
27
  /**
24
28
  * Run `git grep -F` and assert zero matches outside allow-listed prefixes.
@@ -18,7 +18,7 @@ describe("fallbackWeeklyCostDelta", () => {
18
18
  status: "fallback",
19
19
  attempts: [
20
20
  { driverId: "wa-cloud", ok: false, at: weekStart + 1 },
21
- { driverId: "unifonic", ok: true, at: weekStart + 2 },
21
+ { driverId: "taqnyat", ok: true, at: weekStart + 2 },
22
22
  ],
23
23
  at: weekStart + 3,
24
24
  },
@@ -39,7 +39,7 @@ describe("fallbackWeeklyCostDelta", () => {
39
39
  status: "fallback",
40
40
  attempts: [
41
41
  { driverId: "wa-cloud", ok: false, at: weekStart + 6 },
42
- { driverId: "unifonic", ok: true, at: weekStart + 7 },
42
+ { driverId: "msegat", ok: true, at: weekStart + 7 },
43
43
  ],
44
44
  at: weekStart + 8,
45
45
  },
@@ -106,13 +106,25 @@ export function fallbackWeeklyCostDelta(
106
106
  function mediumFromDriver(driverId: string, receiptMedium: string): string {
107
107
  const id = driverId.toLowerCase();
108
108
  if (id.includes("wa") || id.includes("whatsapp")) return "whatsapp";
109
- if (id.includes("sms") || id.includes("unifonic") || id.includes("twilio")) {
109
+ if (
110
+ id.includes("sms") ||
111
+ id.includes("taqnyat") ||
112
+ id.includes("msegat") ||
113
+ id.includes("unifonic") ||
114
+ id.includes("twilio")
115
+ ) {
110
116
  return "sms";
111
117
  }
112
118
  if (id.includes("push") || id.includes("fcm") || id.includes("webpush")) {
113
119
  return "push";
114
120
  }
115
- if (id.includes("smtp") || id.includes("resend") || id.includes("ses") || id.includes("email")) {
121
+ if (
122
+ id.includes("smtp") ||
123
+ id.includes("resend") ||
124
+ id.includes("sndr") ||
125
+ id.includes("ses") ||
126
+ id.includes("email")
127
+ ) {
116
128
  return "email";
117
129
  }
118
130
  return receiptMedium === "any" ? "email" : receiptMedium;
@@ -11,3 +11,14 @@ export type { Attachment, MailOptions, SendResult, Transport, RetryConfig } from
11
11
  export { SentlyError } from "sently/errors";
12
12
  export { RetryTransport } from "sently/transports/retry";
13
13
  export { FallbackTransport, FallbackError, type FallbackAttempt } from "sently/transports/fallback";
14
+ export {
15
+ toChannelSendResult,
16
+ type AnySendResult,
17
+ type ChannelSendResult as SentlyChannelSendResult,
18
+ } from "sently/channel-result";
19
+ export {
20
+ parse as parseSndrWebhook,
21
+ verifySignature as verifySndrSignature,
22
+ } from "sently/webhooks/sndr";
23
+ export { parse as parseUnifonicWebhook } from "sently/webhooks/unifonic";
24
+ export { toDeliveryEvent, type EmailEvent, type DeliveryEvent } from "sently/webhooks";