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.
- package/AGENTS.md +2 -2
- package/README.md +98 -71
- package/package.json +4 -4
- package/site/content/docs/console/clock.mdx +25 -5
- package/site/content/docs/console/signals.mdx +13 -4
- package/site/content/docs/elements/ai.mdx +29 -2
- package/site/content/docs/elements/channel.mdx +90 -20
- package/site/content/docs/elements/clock.mdx +56 -24
- package/site/content/docs/elements/flow.mdx +2 -0
- package/site/content/docs/elements/gate.mdx +8 -1
- package/site/content/docs/elements/signal.mdx +173 -29
- package/site/content/docs/elements/store.mdx +464 -73
- package/site/content/docs/elements/vault.mdx +15 -5
- package/site/content/docs/get-started/basic-usage.mdx +13 -6
- package/site/content/docs/get-started/installation.mdx +49 -13
- package/site/content/docs/get-started/introduction.mdx +1 -1
- package/site/content/docs/reference/cli.md +30 -2
- package/site/content/docs/reference/configuration.mdx +18 -17
- package/site/content/docs/reference/environment-variables.mdx +44 -18
- package/site/content/docs/reference/errors.mdx +12 -11
- package/site/content/docs/reference/fx.mdx +15 -13
- package/site/content/docs/reference/security.md +3 -1
- package/src/cli/ai-setup/ai-setup.test.ts +144 -0
- package/src/cli/ai-setup/apply.ts +214 -0
- package/src/cli/ai-setup/catalog.ts +263 -0
- package/src/cli/ai-setup/detect-ollama.ts +166 -0
- package/src/cli/ai-setup/index.ts +228 -0
- package/src/cli/ai-setup/prompts.ts +649 -0
- package/src/cli/ai-setup/recommend.test.ts +100 -0
- package/src/cli/ai-setup/recommend.ts +203 -0
- package/src/cli/ai.ts +38 -0
- package/src/cli/db-auto-push.test.ts +6 -1
- package/src/cli/db-auto-push.ts +8 -3
- package/src/cli/dev-db-push.test.ts +59 -0
- package/src/cli/dev.test.ts +40 -0
- package/src/cli/dev.ts +55 -1
- package/src/cli/docker-clean.test.ts +179 -0
- package/src/cli/docker-clean.ts +406 -0
- package/src/cli/docker.ts +16 -4
- package/src/cli/ensure-drizzle-config.ts +1 -1
- package/src/cli/hero-meta.test.ts +6 -5
- package/src/cli/hero-meta.ts +7 -1
- package/src/cli/index.ts +5 -0
- package/src/cli/load-config.ts +4 -4
- package/src/cli/openbao-bootstrap.test.ts +1 -0
- package/src/cli/openbao-bootstrap.ts +9 -2
- package/src/cli/openbao-restart.integration.test.ts +106 -97
- package/src/cli/registry.ts +79 -1
- package/src/client/live-gap.test.ts +35 -0
- package/src/client/transport.test.ts +21 -0
- package/src/client/transport.ts +27 -5
- package/src/compiler/fixtures/skyport/oke.config.ts +1 -1
- package/src/config/define-config.test.ts +6 -6
- package/src/config/index.ts +1 -1
- package/src/console/server/console.test.ts +45 -0
- package/src/console/server/flows.ts +39 -9
- package/src/console/server/i18n.ts +41 -0
- package/src/console/server/vault.ts +12 -38
- package/src/console/ui/dist/assets/index-ClX0ZCe5.js +10 -0
- package/src/console/ui/dist/assets/{panel-access-BGv45snf.js → panel-access-ri1X9Otj.js} +1 -1
- package/src/console/ui/dist/assets/{panel-ai-B2S7LEii.js → panel-ai-DLHjRoJk.js} +1 -1
- package/src/console/ui/dist/assets/{panel-architecture-D7UJh91v.js → panel-architecture-DI3bQF3J.js} +1 -1
- package/src/console/ui/dist/assets/{panel-channels-9T3ybqRu.js → panel-channels-BDYWX29p.js} +1 -1
- package/src/console/ui/dist/assets/{panel-clock-Cb1UXGRQ.js → panel-clock-i-JpTjVB.js} +1 -1
- package/src/console/ui/dist/assets/{panel-diff-DmYbKWmN.js → panel-diff-CFuWxiXy.js} +1 -1
- package/src/console/ui/dist/assets/{panel-flows-PiHwT55z.js → panel-flows-B8TRhcoe.js} +1 -1
- package/src/console/ui/dist/assets/{panel-gates-BQGYXvjT.js → panel-gates-CfkBgco7.js} +1 -1
- package/src/console/ui/dist/assets/{panel-overview-BBnRO18l.js → panel-overview-DONDzBd2.js} +1 -1
- package/src/console/ui/dist/assets/{panel-plugins-D0PsmVw2.js → panel-plugins-DDJwTehL.js} +1 -1
- package/src/console/ui/dist/assets/{panel-runs-CWuRDe0r.js → panel-runs-D5zf-D9c.js} +1 -1
- package/src/console/ui/dist/assets/{panel-signals-Bbg4ewpP.js → panel-signals-Dsxdu_AR.js} +1 -1
- package/src/console/ui/dist/assets/{panel-store-CPCbsDRa.js → panel-store-hy7O8HOs.js} +1 -1
- package/src/console/ui/dist/assets/{panel-traces-DVAzuA_S.js → panel-traces-CBiAe5go.js} +1 -1
- package/src/console/ui/dist/assets/{panel-vault-D1_MvOmo.js → panel-vault-Za1GyJfM.js} +1 -1
- package/src/console/ui/dist/index.html +1 -1
- package/src/console/ui/shell/components/ui.tsx +5 -1
- package/src/console/ui/shell/setup/Wizard.tsx +26 -5
- package/src/docker/cleanup.test.ts +193 -0
- package/src/docker/cleanup.ts +355 -0
- package/src/docker/docker.test.ts +4 -0
- package/src/docker/dockerfile.integration.test.ts +126 -119
- package/src/docker/index.ts +19 -0
- package/src/docker/recipes/ollama.ts +10 -5
- package/src/docker/stack.integration.test.ts +118 -102
- package/src/drivers/ai-ollama-tools.integration.test.ts +8 -6
- package/src/drivers/ai-ollama.integration.test.ts +3 -19
- package/src/drivers/ai-ollama.ts +45 -2
- package/src/drivers/ai-providers.test.ts +31 -0
- package/src/drivers/channel-fcm.ts +49 -53
- package/src/drivers/channel-msegat.ts +61 -0
- package/src/drivers/channel-sently-map.ts +57 -0
- package/src/drivers/channel-sently.test.ts +99 -0
- package/src/drivers/channel-sndr.ts +28 -0
- package/src/drivers/channel-taqnyat.ts +57 -0
- package/src/drivers/channel-types.ts +79 -2
- package/src/drivers/channel-unifonic.ts +26 -43
- package/src/drivers/channel-wa-cloud.ts +33 -47
- package/src/drivers/channel-webpush.ts +39 -239
- package/src/drivers/index.ts +7 -0
- package/src/drivers/signal-engine.ts +98 -25
- package/src/drivers/signal-nats.ts +4 -4
- package/src/drivers/signal-postgres.ts +158 -64
- package/src/drivers/signal-redis.ts +4 -4
- package/src/drivers/signal-types.ts +61 -2
- package/src/drivers/vault-driver-removal.test.ts +5 -1
- package/src/elements/channel/costs.test.ts +2 -2
- package/src/elements/channel/costs.ts +14 -2
- package/src/elements/channel/mime.ts +11 -0
- package/src/elements/channel/runtime.ts +94 -0
- package/src/elements/channel/sndr-webhooks.test.ts +26 -0
- package/src/elements/channel.ts +10 -1
- package/src/elements/clock/chaos-child.ts +150 -0
- package/src/elements/clock/chaos.test.ts +204 -0
- package/src/elements/clock/reconcile.ts +110 -0
- package/src/elements/clock/runtime.ts +11 -0
- package/src/elements/clock.test.ts +164 -2
- package/src/elements/clock.ts +1 -0
- package/src/elements/gate/runtime.ts +6 -0
- package/src/elements/index.ts +9 -0
- package/src/elements/signal/chaos-child.ts +41 -4
- package/src/elements/signal/declare.ts +4 -1
- package/src/elements/signal/delivery-modes.test.ts +179 -0
- package/src/elements/signal/key-ordering.test.ts +306 -0
- package/src/elements/signal/lease-reclaim.test.ts +123 -0
- package/src/elements/signal/optional-emit.test.ts +76 -0
- package/src/elements/signal/order-lifecycle.test.ts +130 -0
- package/src/elements/signal/orphan-messages.test.ts +152 -0
- package/src/elements/signal/runtime.ts +11 -4
- package/src/elements/signal/schema-emit.test.ts +107 -0
- package/src/elements/signal.test.ts +89 -5
- package/src/elements/store/files-fx.ts +104 -0
- package/src/elements/store/files-image.test.ts +251 -0
- package/src/elements/store/files-image.ts +494 -0
- package/src/elements/store/runtime.ts +60 -0
- package/src/elements/store.ts +24 -0
- package/src/elements/vault/boot-chain.ts +150 -0
- package/src/elements/vault/runtime.ts +8 -0
- package/src/kernel/app.ts +3 -1
- package/src/kernel/boot-bind/channel.test.ts +68 -3
- package/src/kernel/boot-bind/channel.ts +93 -2
- package/src/kernel/boot-bind/clock.ts +52 -7
- package/src/kernel/boot-bind/gate.ts +71 -3
- package/src/kernel/boot-bind/honor-config.test.ts +247 -0
- package/src/kernel/boot-bind/signal.ts +64 -7
- package/src/kernel/boot-bind/vault.ts +27 -8
- package/src/kernel/boot.ts +10 -2
- package/src/kernel/errors.ts +8 -2
- package/src/kernel/fx.test.ts +13 -0
- package/src/kernel/fx.ts +27 -4
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +10 -4
- package/src/release/exports.test.ts +26 -0
- package/src/release/exports.ts +64 -5
- package/src/release/index.ts +5 -0
- package/src/release/measure.exports.test.ts +13 -1
- package/src/release/measure.ts +76 -13
- package/src/release/official-plugins.ts +46 -0
- package/src/release/readme.test.ts +30 -2
- package/src/runtime/dev-request-log.test.ts +20 -1
- package/src/runtime/dev-request-log.ts +38 -0
- package/src/term.test.ts +15 -0
- package/src/term.ts +8 -3
- 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
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
)
|
|
158
|
-
|
|
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
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
m.
|
|
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<{
|
|
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
|
-
|
|
511
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
605
|
-
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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 = [
|
|
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: "
|
|
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: "
|
|
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 (
|
|
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 (
|
|
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";
|