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
@@ -1,9 +1,12 @@
1
1
  /**
2
- * `webpush` channel driver — RFC 8030 (Web Push) + VAPID (RFC 8292).
2
+ * `webpush` channel driver — RFC 8030 + VAPID via sently.
3
3
  *
4
- * Implemented natively with Web Crypto. No third-party push library.
4
+ * Call path: ChannelTransport.send createPushSender WebPushTransport.
5
5
  */
6
6
 
7
+ import { createPushSender } from "sently/push";
8
+ import { WebPushTransport } from "sently/transports/webpush";
9
+ import { mapSentlySendError, mapSentlySendResult } from "./channel-sently-map.ts";
7
10
  import type {
8
11
  ChannelDriver,
9
12
  ChannelMessage,
@@ -12,263 +15,60 @@ import type {
12
15
  ChannelTransport,
13
16
  } from "./channel-types.ts";
14
17
 
15
- /**
16
- * Decode a URL-safe base64 string.
17
- *
18
- * @param input - Base64url text
19
- */
20
- function b64urlDecode(input: string): Uint8Array<ArrayBuffer> {
21
- const pad = "=".repeat((4 - (input.length % 4)) % 4);
22
- const b64 = (input + pad).replace(/-/g, "+").replace(/_/g, "/");
23
- const bin = atob(b64);
24
- const out = new Uint8Array(bin.length);
25
- for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
26
- return out;
27
- }
28
-
29
- /** Fresh buffer for Web Crypto `BufferSource` typing. */
30
- function asBufferSource(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
31
- return new Uint8Array(bytes);
32
- }
33
-
34
- /**
35
- * Encode bytes as base64url (no padding).
36
- *
37
- * @param bytes - Input
38
- */
39
- function b64urlEncode(bytes: Uint8Array): string {
40
- let s = "";
41
- for (const b of bytes) s += String.fromCharCode(b);
42
- return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
43
- }
44
-
45
- /**
46
- * Import a VAPID ECDSA P-256 private key from base64url raw or PKCS8.
47
- *
48
- * @param privateKeyB64 - Base64url private key
49
- */
50
- async function importVapidPrivateKey(privateKeyB64: string): Promise<CryptoKey> {
51
- const raw = b64urlDecode(privateKeyB64);
52
- // Prefer PKCS8; fall back to raw JWK construction for 32-byte seeds.
53
- if (raw.length > 32) {
54
- return crypto.subtle.importKey(
55
- "pkcs8",
56
- asBufferSource(raw),
57
- { name: "ECDSA", namedCurve: "P-256" },
58
- false,
59
- ["sign"],
60
- );
61
- }
62
- // Build a minimal JWK from d (and a synthetic public x/y via derive is hard);
63
- // tests inject fetch and only need Authorization header shape — use ECDSA
64
- // with a generated key when raw seed is provided without full PKCS8.
65
- const pair = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [
66
- "sign",
67
- "verify",
68
- ]);
69
- return pair.privateKey;
70
- }
71
-
72
- /**
73
- * Build a VAPID JWT (RFC 8292) for the push audience.
74
- *
75
- * @param audience - Origin of the push service
76
- * @param subject - `mailto:` or HTTPS contact
77
- * @param privateKey - ECDSA P-256 key
78
- */
79
- async function buildVapidJwt(
80
- audience: string,
81
- subject: string,
82
- privateKey: CryptoKey,
83
- ): Promise<string> {
84
- const header = b64urlEncode(
85
- new TextEncoder().encode(JSON.stringify({ typ: "JWT", alg: "ES256" })),
86
- );
87
- const exp = Math.floor(Date.now() / 1000) + 12 * 60 * 60;
88
- const payload = b64urlEncode(
89
- new TextEncoder().encode(JSON.stringify({ aud: audience, exp, sub: subject })),
90
- );
91
- const data = new TextEncoder().encode(`${header}.${payload}`);
92
- const sig = new Uint8Array(
93
- await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, privateKey, data),
94
- );
95
- // Convert DER → raw r||s if needed; Web Crypto in Bun returns raw P-1363.
96
- return `${header}.${payload}.${b64urlEncode(sig)}`;
97
- }
98
-
99
- /**
100
- * Minimal RFC 8030 encrypted body: salt + rs + server public + ciphertext.
101
- * For tests with injected fetch we send a well-formed envelope; production
102
- * should use full ece (aes128gcm). Here we implement aes128gcm content-coding
103
- * with Web Crypto ECDH + HKDF as specified.
104
- *
105
- * @param plaintext - Push payload bytes
106
- * @param p256dh - Subscriber public key (base64url)
107
- * @param auth - Subscriber auth secret (base64url)
108
- */
109
- async function encryptPushBody(
110
- plaintext: Uint8Array,
111
- p256dh: string,
112
- auth: string,
113
- ): Promise<{ body: Uint8Array; salt: Uint8Array; localPublic: Uint8Array }> {
114
- const userPublic = await crypto.subtle.importKey(
115
- "raw",
116
- asBufferSource(b64urlDecode(p256dh)),
117
- { name: "ECDH", namedCurve: "P-256" },
118
- true,
119
- [],
120
- );
121
- const local = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, [
122
- "deriveBits",
123
- ]);
124
- const shared = new Uint8Array(
125
- await crypto.subtle.deriveBits({ name: "ECDH", public: userPublic }, local.privateKey, 256),
126
- );
127
- const salt = crypto.getRandomValues(new Uint8Array(16));
128
- const authSecret = b64urlDecode(auth);
129
-
130
- // HKDF-SHA-256 as in RFC 8291
131
- const ikmKey = await crypto.subtle.importKey("raw", asBufferSource(shared), "HKDF", false, [
132
- "deriveBits",
133
- ]);
134
- const prk = new Uint8Array(
135
- await crypto.subtle.deriveBits(
136
- {
137
- name: "HKDF",
138
- hash: "SHA-256",
139
- salt: asBufferSource(authSecret),
140
- info: new TextEncoder().encode("WebPush: info\0"),
141
- },
142
- ikmKey,
143
- 256,
144
- ),
145
- );
146
- // Simplified content encryption key / nonce derivation for aes128gcm
147
- const prkKey = await crypto.subtle.importKey("raw", prk, "HKDF", false, ["deriveBits"]);
148
- const cek = new Uint8Array(
149
- await crypto.subtle.deriveBits(
150
- {
151
- name: "HKDF",
152
- hash: "SHA-256",
153
- salt,
154
- info: new TextEncoder().encode("Content-Encoding: aes128gcm\0"),
155
- },
156
- prkKey,
157
- 128,
158
- ),
159
- );
160
- const nonce = new Uint8Array(
161
- await crypto.subtle.deriveBits(
162
- {
163
- name: "HKDF",
164
- hash: "SHA-256",
165
- salt,
166
- info: new TextEncoder().encode("Content-Encoding: nonce\0"),
167
- },
168
- prkKey,
169
- 96,
170
- ),
171
- );
172
- const aes = await crypto.subtle.importKey("raw", cek, { name: "AES-GCM" }, false, ["encrypt"]);
173
- // Add RFC 8188 padding delimiter (0x02) then encrypt
174
- const padded = new Uint8Array(plaintext.length + 1);
175
- padded.set(plaintext, 0);
176
- padded[plaintext.length] = 2;
177
- const ciphertext = new Uint8Array(
178
- await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, aes, padded),
179
- );
180
- const localPublic = new Uint8Array(await crypto.subtle.exportKey("raw", local.publicKey));
181
-
182
- // Body = salt (16) || rs (4) || idlen (1) || keyid || ciphertext
183
- const rs = new Uint8Array(4);
184
- new DataView(rs.buffer).setUint32(0, 4096);
185
- const idlen = new Uint8Array([localPublic.length]);
186
- const body = new Uint8Array(16 + 4 + 1 + localPublic.length + ciphertext.length);
187
- body.set(salt, 0);
188
- body.set(rs, 16);
189
- body.set(idlen, 20);
190
- body.set(localPublic, 21);
191
- body.set(ciphertext, 21 + localPublic.length);
192
- return { body, salt, localPublic };
193
- }
194
-
195
18
  /**
196
19
  * Open a Web Push driver (RFC 8030 + VAPID).
197
20
  *
198
21
  * @param options - VAPID keys + subject
199
22
  */
200
23
  export function openWebPushChannel(options: ChannelOpenOptions = {}): ChannelDriver {
201
- const vapidPublic = options.vapidPublicKey;
202
- const vapidPrivate = options.vapidPrivateKey;
24
+ const vapidPublicKey = options.vapidPublicKey;
25
+ const vapidPrivateKey = options.vapidPrivateKey;
203
26
  const subject = options.vapidSubject ?? "mailto:ops@oke.local";
204
- const fetchFn = options.fetch ?? globalThis.fetch;
27
+ if (!vapidPublicKey || !vapidPrivateKey) {
28
+ throw new Error("webpush: vapidPublicKey and vapidPrivateKey are required");
29
+ }
30
+
31
+ const transport = new WebPushTransport({
32
+ vapidPublicKey,
33
+ vapidPrivateKey,
34
+ subject,
35
+ });
36
+ const sender = createPushSender({ transport });
205
37
 
206
38
  const channel: ChannelTransport = {
207
39
  provider: "webpush",
208
40
  mediums: ["push"],
209
41
  async send(message: ChannelMessage): Promise<ChannelSendResult> {
210
- if (!vapidPrivate || !vapidPublic) {
211
- throw new Error("webpush: vapidPublicKey and vapidPrivateKey are required");
212
- }
213
42
  const sub = message.pushSubscription;
214
43
  const endpoint = sub?.endpoint ?? message.to;
215
- if (!endpoint.startsWith("http")) {
216
- throw new Error("webpush: pushSubscription.endpoint (or https to) required");
217
- }
218
- const audience = new URL(endpoint).origin;
219
- const privateKey = await importVapidPrivateKey(vapidPrivate);
220
- const jwt = await buildVapidJwt(audience, subject, privateKey);
221
-
222
- const plaintext = new TextEncoder().encode(
223
- message.text ?? JSON.stringify(message.data ?? {}),
224
- );
225
- let body: Uint8Array = plaintext;
226
- const headers: Record<string, string> = {
227
- Authorization: `vapid t=${jwt}, k=${vapidPublic}`,
228
- TTL: "86400",
229
- Urgency: "normal",
230
- };
231
-
232
- if (sub?.keys?.p256dh && sub.keys.auth) {
233
- const enc = await encryptPushBody(plaintext, sub.keys.p256dh, sub.keys.auth);
234
- body = enc.body;
235
- headers["Content-Encoding"] = "aes128gcm";
236
- headers["Content-Type"] = "application/octet-stream";
237
- } else {
238
- headers["Content-Type"] = "text/plain;charset=utf-8";
44
+ if (!endpoint.startsWith("http") || !sub?.keys?.p256dh || !sub.keys.auth) {
45
+ throw new Error("webpush: pushSubscription with endpoint + keys is required");
239
46
  }
240
47
 
241
- const res = await fetchFn(endpoint, {
242
- method: "POST",
243
- headers,
244
- body: body as unknown as ArrayBuffer,
245
- });
246
- const id = res.headers.get("location") ?? crypto.randomUUID();
247
- if (!res.ok && res.status !== 201) {
248
- return {
249
- ok: false,
250
- messageId: id,
251
- driverId: "webpush",
252
- attempts: [
253
- {
254
- driverId: "webpush",
255
- ok: false,
256
- error: `HTTP ${res.status}`,
257
- at: Date.now(),
258
- },
259
- ],
260
- };
48
+ try {
49
+ const title =
50
+ message.subject ??
51
+ (typeof message.data?.title === "string" ? message.data.title : "Notification");
52
+ const body = message.text ?? JSON.stringify(message.data ?? {});
53
+ const result = await sender.send({
54
+ subscription: {
55
+ endpoint,
56
+ keys: { p256dh: sub.keys.p256dh, auth: sub.keys.auth },
57
+ },
58
+ title,
59
+ body,
60
+ ...(message.data ? { data: { ...message.data } } : {}),
61
+ });
62
+ return mapSentlySendResult("webpush", result);
63
+ } catch (err) {
64
+ return mapSentlySendError("webpush", err);
261
65
  }
262
- return {
263
- ok: true,
264
- messageId: id,
265
- driverId: "webpush",
266
- attempts: [{ driverId: "webpush", ok: true, at: Date.now(), messageId: id }],
267
- };
268
66
  },
67
+ verify: () => sender.verify(),
68
+ close: () => sender.close(),
269
69
  };
270
70
 
271
- return { id: "webpush", channel };
71
+ return { id: "webpush", channel, pushTransport: transport };
272
72
  }
273
73
 
274
74
  /** Web Push driver factory. */
@@ -41,6 +41,7 @@ export type {
41
41
  SignalDriver,
42
42
  SignalBus,
43
43
  SignalMessage,
44
+ SignalEmitOptions,
44
45
  SignalFailureReason,
45
46
  DeadLetter,
46
47
  SignalTransaction,
@@ -55,6 +56,8 @@ export type {
55
56
  SignalDiscardOptions,
56
57
  } from "./signal-types.ts";
57
58
 
59
+ export { SIGNAL_DEFAULT_LEASE_MS } from "./signal-types.ts";
60
+
58
61
  export {
59
62
  memorySqlDriver,
60
63
  memoryKvDriver,
@@ -139,10 +142,14 @@ export { createChannelInbox } from "./channel-types.ts";
139
142
  export { consoleChannelDriver, openConsoleChannel } from "./channel-console.ts";
140
143
  export { smtpChannelDriver, openSmtpChannel } from "./channel-smtp.ts";
141
144
  export { resendChannelDriver, openResendChannel } from "./channel-resend.ts";
145
+ export { sndrChannelDriver, openSndrChannel } from "./channel-sndr.ts";
146
+ export { taqnyatChannelDriver, openTaqnyatChannel } from "./channel-taqnyat.ts";
147
+ export { msegatChannelDriver, openMsegatChannel } from "./channel-msegat.ts";
142
148
  export { unifonicChannelDriver, openUnifonicChannel } from "./channel-unifonic.ts";
143
149
  export { waCloudChannelDriver, openWaCloudChannel } from "./channel-wa-cloud.ts";
144
150
  export { fcmChannelDriver, openFcmChannel } from "./channel-fcm.ts";
145
151
  export { webpushChannelDriver, openWebPushChannel } from "./channel-webpush.ts";
152
+ export { mapSentlySendResult, mapSentlySendError } from "./channel-sently-map.ts";
146
153
 
147
154
  export type {
148
155
  AiDriverId,
@@ -11,21 +11,25 @@ import { dirname } from "node:path";
11
11
  import type { SignalDecl } from "../elements/signal/declare.ts";
12
12
  import type { SignalDelivery } from "../manifest/types.ts";
13
13
  import { DryRunWriteIsolationError, setDryRunMessageId, withDryRun } from "../kernel/dry-run.ts";
14
- import type {
15
- DeadLetter,
16
- LiveHandler,
17
- SignalBus,
18
- SignalDiscardOptions,
19
- SignalDriverId,
20
- SignalFailureReason,
21
- SignalHandler,
22
- SignalMessage,
23
- SignalOpenOptions,
24
- SignalReplayOptions,
25
- SignalReplayResult,
26
- SignalStats,
27
- SignalTransaction,
28
- SignalUnsubscribe,
14
+ import { OkeError, OKE_ERRORS } from "../kernel/errors.ts";
15
+ import {
16
+ SIGNAL_DEFAULT_LEASE_MS,
17
+ validateSignalEmitPayload,
18
+ type DeadLetter,
19
+ type LiveHandler,
20
+ type SignalBus,
21
+ type SignalDiscardOptions,
22
+ type SignalDriverId,
23
+ type SignalEmitOptions,
24
+ type SignalFailureReason,
25
+ type SignalHandler,
26
+ type SignalMessage,
27
+ type SignalOpenOptions,
28
+ type SignalReplayOptions,
29
+ type SignalReplayResult,
30
+ type SignalStats,
31
+ type SignalTransaction,
32
+ type SignalUnsubscribe,
29
33
  } from "./signal-types.ts";
30
34
 
31
35
  /** Internal mutable message. */
@@ -33,6 +37,8 @@ interface MutMessage {
33
37
  id: string;
34
38
  signal: string;
35
39
  payload: unknown;
40
+ /** Optional per-key ordering token (`once` serialization). */
41
+ key: string | null;
36
42
  delivery: SignalDelivery;
37
43
  attempts: number;
38
44
  failures: SignalFailureReason[];
@@ -41,6 +47,8 @@ interface MutMessage {
41
47
  status: "pending" | "inflight" | "delivered" | "dead";
42
48
  /** For once: which consumer holds the claim. */
43
49
  lockedBy: string | null;
50
+ /** For once: when the claim lease expires (epoch ms); null when unlocked. */
51
+ leaseExpiresAt: number | null;
44
52
  /** For broadcast: subscriber ids that already received. */
45
53
  deliveredTo: Set<string>;
46
54
  }
@@ -48,9 +56,10 @@ interface MutMessage {
48
56
  /** Persisted snapshot for chaos recovery. */
49
57
  interface Snapshot {
50
58
  messages: Array<
51
- Omit<MutMessage, "deliveredTo" | "lockedBy"> & {
59
+ Omit<MutMessage, "deliveredTo" | "lockedBy" | "leaseExpiresAt"> & {
52
60
  deliveredTo: string[];
53
61
  lockedBy: string | null;
62
+ leaseExpiresAt: number | null;
54
63
  }
55
64
  >;
56
65
  writes: Array<[string, unknown]>;
@@ -76,6 +85,7 @@ export async function createSignalEngine(
76
85
  const now = options.now ?? (() => Date.now());
77
86
  const signals = options.signals;
78
87
  const durablePath = options.durablePath;
88
+ const leaseMs = options.leaseMs ?? SIGNAL_DEFAULT_LEASE_MS;
79
89
 
80
90
  let messages: MutMessage[] = [];
81
91
  let writes = new Map<string, unknown>();
@@ -152,7 +162,9 @@ export async function createSignalEngine(
152
162
  if (snap) {
153
163
  messages = snap.messages.map((m) => ({
154
164
  ...m,
165
+ key: m.key ?? null,
155
166
  lockedBy: m.lockedBy,
167
+ leaseExpiresAt: m.leaseExpiresAt ?? null,
156
168
  deliveredTo: new Set(m.deliveredTo),
157
169
  }));
158
170
  writes = new Map(snap.writes);
@@ -166,6 +178,7 @@ export async function createSignalEngine(
166
178
  id: m.id,
167
179
  signal: m.signal,
168
180
  payload: m.payload,
181
+ key: m.key,
169
182
  delivery: m.delivery,
170
183
  attempts: m.attempts,
171
184
  failures: m.failures,
@@ -173,6 +186,7 @@ export async function createSignalEngine(
173
186
  availableAt: m.availableAt,
174
187
  status: m.status,
175
188
  lockedBy: m.lockedBy,
189
+ leaseExpiresAt: m.leaseExpiresAt,
176
190
  deliveredTo: [...m.deliveredTo],
177
191
  })),
178
192
  writes: [...writes.entries()],
@@ -189,13 +203,38 @@ export async function createSignalEngine(
189
203
  return decl;
190
204
  }
191
205
 
192
- function stageEmit(stagedMessages: MutMessage[], name: string, payload: unknown): void {
206
+ function hasSubscriber(name: string): boolean {
207
+ if (consumers.some((c) => c.signal === name)) return true;
208
+ const live = liveHandlers.get(name);
209
+ return live !== undefined && live.size > 0;
210
+ }
211
+
212
+ function assertEmitAllowed(name: string): void {
213
+ const decl = requireDecl(name);
214
+ if (decl.optional) return;
215
+ if (hasSubscriber(name)) return;
216
+ throw new OkeError(OKE_ERRORS.ORPHAN_EMIT, {
217
+ flow: "unknown",
218
+ resource: name,
219
+ });
220
+ }
221
+
222
+ async function stageEmit(
223
+ stagedMessages: MutMessage[],
224
+ name: string,
225
+ payload: unknown,
226
+ options?: SignalEmitOptions,
227
+ ): Promise<void> {
193
228
  const decl = requireDecl(name);
229
+ assertEmitAllowed(name);
230
+ const value = await validateSignalEmitPayload(name, decl, payload);
194
231
  const t = now();
232
+ const key = typeof options?.key === "string" && options.key.length > 0 ? options.key : null;
195
233
  stagedMessages.push({
196
234
  id: crypto.randomUUID(),
197
235
  signal: name,
198
- payload: payload ?? null,
236
+ payload: value,
237
+ key,
199
238
  delivery: decl.delivery,
200
239
  attempts: 0,
201
240
  failures: [],
@@ -203,6 +242,7 @@ export async function createSignalEngine(
203
242
  availableAt: t,
204
243
  status: "pending",
205
244
  lockedBy: null,
245
+ leaseExpiresAt: null,
206
246
  deliveredTo: new Set(),
207
247
  });
208
248
  }
@@ -218,9 +258,9 @@ export async function createSignalEngine(
218
258
  if (done) throw new Error("transaction finished");
219
259
  stagedWrites.set(key, value);
220
260
  },
221
- async emit(signal, payload) {
261
+ async emit(signal, payload, options) {
222
262
  if (done) throw new Error("transaction finished");
223
- stageEmit(stagedMessages, signal, payload);
263
+ await stageEmit(stagedMessages, signal, payload, options);
224
264
  },
225
265
  async commit() {
226
266
  if (done) throw new Error("transaction finished");
@@ -240,9 +280,13 @@ export async function createSignalEngine(
240
280
  return Promise.resolve(tx);
241
281
  }
242
282
 
243
- async function emit(signal: string, payload?: unknown): Promise<void> {
283
+ async function emit(
284
+ signal: string,
285
+ payload?: unknown,
286
+ options?: SignalEmitOptions,
287
+ ): Promise<void> {
244
288
  const tx = await begin();
245
- await tx.emit(signal, payload);
289
+ await tx.emit(signal, payload, options);
246
290
  await tx.commit();
247
291
  }
248
292
 
@@ -287,6 +331,7 @@ export async function createSignalEngine(
287
331
  id: m.id,
288
332
  signal: m.signal,
289
333
  payload: m.payload,
334
+ ...(m.key !== null ? { key: m.key } : {}),
290
335
  delivery: m.delivery,
291
336
  attempts: m.attempts,
292
337
  failures: [...m.failures],
@@ -296,18 +341,40 @@ export async function createSignalEngine(
296
341
  };
297
342
  }
298
343
 
344
+ /** True when another same-(signal, key) message holds an unexpired lease. */
345
+ function keyHeldByOther(candidate: MutMessage, t: number): boolean {
346
+ if (candidate.key === null) return false;
347
+ return messages.some(
348
+ (other) =>
349
+ other !== candidate &&
350
+ other.signal === candidate.signal &&
351
+ other.key === candidate.key &&
352
+ other.status === "inflight" &&
353
+ other.leaseExpiresAt !== null &&
354
+ other.leaseExpiresAt > t,
355
+ );
356
+ }
357
+
299
358
  /**
300
- * Claim the next `once` message with SKIP LOCKED semantics.
359
+ * Claim the next `once` message with SKIP LOCKED + lazy lease reclaim.
360
+ *
361
+ * Eligible: pending/unlocked, or inflight whose `leaseExpiresAt` has passed,
362
+ * and not blocked by a same-key unexpired inflight (per-key serialization).
363
+ * No background sweeper — reclaim happens only at claim time.
301
364
  */
302
365
  function claimOnce(signal: string, consumerId: string): MutMessage | null {
303
366
  const t = now();
304
367
  for (const m of messages) {
305
368
  if (m.signal !== signal || m.delivery !== "once") continue;
306
- if (m.status !== "pending") continue;
307
369
  if (m.availableAt > t) continue;
308
- if (m.lockedBy !== null) continue;
370
+ const pendingOk = m.status === "pending" && m.lockedBy === null;
371
+ const leaseExpired =
372
+ m.status === "inflight" && m.leaseExpiresAt !== null && m.leaseExpiresAt <= t;
373
+ if (!pendingOk && !leaseExpired) continue;
374
+ if (keyHeldByOther(m, t)) continue;
309
375
  m.status = "inflight";
310
376
  m.lockedBy = consumerId;
377
+ m.leaseExpiresAt = t + leaseMs;
311
378
  m.attempts += 1;
312
379
  return m;
313
380
  }
@@ -320,10 +387,12 @@ export async function createSignalEngine(
320
387
  await consumer.handler(toPublic(m));
321
388
  m.status = "delivered";
322
389
  m.lockedBy = null;
390
+ m.leaseExpiresAt = null;
323
391
  noteDelivered();
324
392
  } catch (err) {
325
393
  m.failures.push(failureFromError(err, m.attempts));
326
394
  m.lockedBy = null;
395
+ m.leaseExpiresAt = null;
327
396
  if (m.attempts > decl.retries) {
328
397
  if (decl.deadLetter) {
329
398
  m.status = "dead";
@@ -348,6 +417,9 @@ export async function createSignalEngine(
348
417
  const m = claimOnce(consumer.signal, consumer.subscriberId);
349
418
  if (!m) continue;
350
419
  progress = true;
420
+ // Persist claim + lease before the handler runs so a crash mid-handler
421
+ // leaves a reclaimable inflight row (not a silent pending that never locked).
422
+ await persist();
351
423
  await deliverOnce(m, consumer);
352
424
  }
353
425
  return progress;
@@ -571,6 +643,7 @@ export async function createSignalEngine(
571
643
  if (!options.dryRun) {
572
644
  m.status = "pending";
573
645
  m.lockedBy = null;
646
+ m.leaseExpiresAt = null;
574
647
  m.availableAt = now();
575
648
  m.attempts = 0;
576
649
  if (m.delivery === "broadcast") {
@@ -87,8 +87,8 @@ export async function openNatsSignal(options: SignalOpenOptions): Promise<Signal
87
87
 
88
88
  return {
89
89
  driverId: "nats",
90
- async emit(signal, payload) {
91
- await outbox.emit(signal, payload);
90
+ async emit(signal, payload, options) {
91
+ await outbox.emit(signal, payload, options);
92
92
  await relayToNats(signal, payload);
93
93
  },
94
94
  async begin() {
@@ -96,9 +96,9 @@ export async function openNatsSignal(options: SignalOpenOptions): Promise<Signal
96
96
  const tx = await outbox.begin();
97
97
  return {
98
98
  write: (k, v) => tx.write(k, v),
99
- async emit(signal, payload) {
99
+ async emit(signal, payload, options) {
100
100
  staged.push({ signal, payload });
101
- await tx.emit(signal, payload);
101
+ await tx.emit(signal, payload, options);
102
102
  },
103
103
  async commit() {
104
104
  await tx.commit();