okengine 0.23.1 → 0.23.2
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/package.json +1 -1
- package/site/content/docs/ai/mcp.mdx +3 -3
- package/site/content/docs/elements/signal/broadcast.mdx +4 -2
- package/site/content/docs/reference/configuration.mdx +16 -0
- package/site/content/docs/reference/errors.mdx +31 -19
- package/src/compiler/aot.test.ts +3 -2
- package/src/compiler/aot.ts +45 -1
- package/src/compiler/dynamic.ts +1 -1
- package/src/compiler/http-parse.test.ts +101 -0
- package/src/compiler/http-parse.ts +188 -9
- package/src/compiler/interpret.ts +25 -2
- package/src/console/ui-next/dist/assets/{access-page-C_N4qvai.js → access-page-zBMhZnFm.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-Cq1TxniW.js → agent-disclosure-CBj9f7Ju.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-BaI4uxZz.js → cache-glyph-D3MY49h3.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-DNyK7IIY.js → call-pii-button-L9JxdBhp.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-CRhBJMZR.js → collapsible-BvFaX-Zt.js} +1 -1
- package/src/console/ui-next/dist/assets/{decisions-page-D9twj8Xy.js → decisions-page-DgB76m-X.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-CsHWlVPU.js → duration-tone-l1DSJ2Vz.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-ZaTJAhR9.js → flows-page-Bb9t1lu6.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-DQ-EMAfz.js → highlighted-json-DMvsi3Ti.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-Cn_Fl79s.js → http-method-BwhJBTcQ.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-CmRFeWdT.js → index-RhV2jT_7.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-Cn6R_dXz.js → observability-page-BRfUILBq.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-Bx6EZhSe.js → replica-lag-BsceAzIG.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-DmdiUrfh.js → request-meta-C6lTyCXp.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-8P6vGXnn.js → store-page-YmE806bJ.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-BfGvpFSv.js → trace-detail-sheet-Di4irS49.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-CWdUHbc0.js → tree-expand-toggle-DYeL6Rye.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-CZOFZzay.js → units-page-07agasRA.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-ki_ishmX.js → vault-page-CHBck4n_.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/seed-parked-approval.ts +5 -1
- package/src/drivers/journal-postgres.test.ts +84 -1
- package/src/drivers/journal-postgres.ts +282 -32
- package/src/drivers/postgres.test.ts +60 -0
- package/src/drivers/postgres.ts +79 -5
- package/src/drivers/signal-compete.test.ts +6 -2
- package/src/drivers/signal-postgres.test.ts +170 -0
- package/src/drivers/signal-postgres.ts +449 -196
- package/src/drivers/signal-redis.test.ts +224 -0
- package/src/drivers/signal-redis.ts +835 -150
- package/src/elements/ai/approval.test.ts +15 -3
- package/src/elements/ai/approval.ts +15 -9
- package/src/elements/channel/runtime.ts +31 -4
- package/src/elements/channel/sql-ledger.test.ts +404 -0
- package/src/elements/channel/sql-ledger.ts +177 -33
- package/src/elements/clock/chaos-child.ts +12 -2
- package/src/elements/clock/durable.ts +11 -0
- package/src/elements/store/cache-bus.test.ts +121 -0
- package/src/elements/store/cache-bus.ts +278 -0
- package/src/elements/store/cache.test.ts +59 -0
- package/src/elements/store/cache.ts +157 -12
- package/src/elements/store/runtime.ts +44 -2
- package/src/elements/store/sql-errors.test.ts +131 -0
- package/src/elements/store/sql-errors.ts +39 -16
- package/src/elements/store/sql-nested-tx.test.ts +344 -0
- package/src/elements/store/sql-session.ts +183 -3
- package/src/kernel/app.ts +176 -28
- package/src/kernel/auto-registry.test.ts +5 -1
- package/src/kernel/boot-bind/signal.ts +13 -2
- package/src/kernel/boot-bind/store.ts +16 -1
- package/src/kernel/boot.test.ts +2 -2
- package/src/kernel/boot.ts +41 -11
- package/src/kernel/boundary-contract.ts +7 -0
- package/src/kernel/builtin-errors.ts +2 -0
- package/src/kernel/call.test.ts +1 -0
- package/src/kernel/fx-sql-handle.ts +7 -1
- package/src/kernel/horizontal-child.ts +54 -1
- package/src/kernel/horizontal.integration.test.ts +131 -7
- package/src/kernel/idempotency.test.ts +5 -6
- package/src/kernel/journal-boot.test.ts +83 -6
- package/src/kernel/journal.test.ts +44 -0
- package/src/kernel/journal.ts +171 -26
- package/src/mcp/authorization.ts +5 -11
- package/src/mcp/confirmation.ts +203 -38
- package/src/mcp/docs-server.ts +7 -2
- package/src/mcp/index.ts +4 -0
- package/src/mcp/mcp.test.ts +321 -33
- package/src/mcp/server.ts +8 -3
- package/src/mcp/session.ts +7 -0
- package/src/mcp/tools.ts +68 -19
- package/src/runtime/bun.ts +5 -0
- package/src/runtime/types.ts +5 -0
|
@@ -325,7 +325,11 @@ describe("approval http", () => {
|
|
|
325
325
|
apps.push(app);
|
|
326
326
|
|
|
327
327
|
const parked = await app.fetch(
|
|
328
|
-
new Request("http://localhost/assist", {
|
|
328
|
+
new Request("http://localhost/assist", {
|
|
329
|
+
method: "POST",
|
|
330
|
+
headers: { "content-type": "application/json" },
|
|
331
|
+
body: "{}",
|
|
332
|
+
}),
|
|
329
333
|
);
|
|
330
334
|
expect(parked.status).toBeLessThan(500);
|
|
331
335
|
const runs = await store.list();
|
|
@@ -450,7 +454,11 @@ describe("approval http", () => {
|
|
|
450
454
|
|
|
451
455
|
const parkStream = async (app: OkeApp) => {
|
|
452
456
|
const parked = await app.fetch(
|
|
453
|
-
new Request("http://localhost/assist", {
|
|
457
|
+
new Request("http://localhost/assist", {
|
|
458
|
+
method: "POST",
|
|
459
|
+
headers: { "content-type": "application/json" },
|
|
460
|
+
body: "{}",
|
|
461
|
+
}),
|
|
454
462
|
);
|
|
455
463
|
expect(parked.status).toBe(200);
|
|
456
464
|
const body = await parked.text();
|
|
@@ -594,7 +602,11 @@ describe("approval http", () => {
|
|
|
594
602
|
apps.push(app);
|
|
595
603
|
|
|
596
604
|
const parked = await app.fetch(
|
|
597
|
-
new Request("http://localhost/assist", {
|
|
605
|
+
new Request("http://localhost/assist", {
|
|
606
|
+
method: "POST",
|
|
607
|
+
headers: { "content-type": "application/json" },
|
|
608
|
+
body: "{}",
|
|
609
|
+
}),
|
|
598
610
|
);
|
|
599
611
|
expect(parked.status).toBe(200);
|
|
600
612
|
const first = await parked.text();
|
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
hasJournalLease,
|
|
10
10
|
JOURNAL_DEFAULT_LEASE_MS,
|
|
11
11
|
type JournalEntry,
|
|
12
|
-
type JournalRun,
|
|
13
12
|
type JournalStore,
|
|
14
13
|
} from "../../kernel/journal.ts";
|
|
15
14
|
import { parseDurationMs } from "../clock/duration.ts";
|
|
@@ -212,15 +211,22 @@ export async function resolveAgentApproval(
|
|
|
212
211
|
if (item.kind === "sleep" && item.label === name) return { ...item, wakeAt: at };
|
|
213
212
|
return item;
|
|
214
213
|
});
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
status: run.status === "sleeping" ? "sleeping" : run.status,
|
|
220
|
-
lockedBy: run.lockedBy,
|
|
221
|
-
leaseExpiresAt: run.leaseExpiresAt,
|
|
214
|
+
const fence = {
|
|
215
|
+
lockedBy: run.lockedBy ?? token,
|
|
216
|
+
leaseToken: run.leaseToken ?? 0,
|
|
217
|
+
now: at,
|
|
222
218
|
};
|
|
223
|
-
|
|
219
|
+
if (store.updateEntry) {
|
|
220
|
+
for (let seq = 0; seq < entries.length; seq++) {
|
|
221
|
+
const item = entries[seq];
|
|
222
|
+
const prev = run.entries[seq];
|
|
223
|
+
if (!item || !prev || JSON.stringify(item) === JSON.stringify(prev)) continue;
|
|
224
|
+
await store.updateEntry(parsed.runId, seq, item, fence);
|
|
225
|
+
}
|
|
226
|
+
await store.put({ ...run, entries: [], wakeAt: at, updatedAt: at }, fence);
|
|
227
|
+
} else {
|
|
228
|
+
await store.put({ ...run, entries, wakeAt: at, updatedAt: at });
|
|
229
|
+
}
|
|
224
230
|
return { ok: true };
|
|
225
231
|
} finally {
|
|
226
232
|
if (hold === undefined) await store.releaseLease(parsed.runId, token);
|
|
@@ -170,11 +170,12 @@ export interface ChannelRuntime {
|
|
|
170
170
|
/**
|
|
171
171
|
* Ingest a post-send provider outcome (bounce / complaint / …).
|
|
172
172
|
* Hard bounce auto-adds suppression. Console projects the ledger — never
|
|
173
|
-
* raw webhooks.
|
|
173
|
+
* raw webhooks. When the ledger can read SQL, a cache miss loads that
|
|
174
|
+
* receipt before a new one is recorded.
|
|
174
175
|
*
|
|
175
176
|
* @param input - Normalized outcome
|
|
176
177
|
*/
|
|
177
|
-
ingestOutcome(input: IngestOutcomeInput): DeliveryReceipt
|
|
178
|
+
ingestOutcome(input: IngestOutcomeInput): Promise<DeliveryReceipt>;
|
|
178
179
|
}
|
|
179
180
|
|
|
180
181
|
/**
|
|
@@ -189,6 +190,28 @@ export function resetChannelProcessLocalWarnForTests(): void {
|
|
|
189
190
|
channelProcessLocalWarned = false;
|
|
190
191
|
}
|
|
191
192
|
|
|
193
|
+
/**
|
|
194
|
+
* SQL read for a receipt the cache does not have yet.
|
|
195
|
+
*
|
|
196
|
+
* @param ledger - Receipt ledger, possibly backed by Postgres
|
|
197
|
+
*/
|
|
198
|
+
function lookupReceipt(
|
|
199
|
+
ledger: ReceiptLedger,
|
|
200
|
+
): ((messageId: string) => Promise<DeliveryReceipt | undefined>) | undefined {
|
|
201
|
+
if (!("lookup" in ledger)) return undefined;
|
|
202
|
+
const lookup = (ledger as { lookup?: unknown }).lookup;
|
|
203
|
+
if (typeof lookup !== "function") return undefined;
|
|
204
|
+
return (messageId: string) =>
|
|
205
|
+
Promise.resolve(
|
|
206
|
+
(
|
|
207
|
+
lookup as (
|
|
208
|
+
this: ReceiptLedger,
|
|
209
|
+
messageId: string,
|
|
210
|
+
) => DeliveryReceipt | undefined | Promise<DeliveryReceipt | undefined>
|
|
211
|
+
).call(ledger, messageId),
|
|
212
|
+
).then((value) => value ?? undefined);
|
|
213
|
+
}
|
|
214
|
+
|
|
192
215
|
export function createChannelRuntime(options: CreateChannelRuntimeOptions = {}): ChannelRuntime {
|
|
193
216
|
const templates = new Map<string, ChannelTemplateDecl>();
|
|
194
217
|
for (const t of options.templates ?? []) {
|
|
@@ -750,9 +773,13 @@ export function createChannelRuntime(options: CreateChannelRuntimeOptions = {}):
|
|
|
750
773
|
async deliverOtp(opts) {
|
|
751
774
|
return deliverOtpAcrossChannels(drivers, sendTemplate, opts, now);
|
|
752
775
|
},
|
|
753
|
-
ingestOutcome(input) {
|
|
776
|
+
async ingestOutcome(input) {
|
|
754
777
|
const at = input.at ?? now();
|
|
755
|
-
|
|
778
|
+
let existing = receipts.byMessageId(input.messageId);
|
|
779
|
+
if (!existing) {
|
|
780
|
+
const lookup = lookupReceipt(receipts);
|
|
781
|
+
if (lookup) existing = await lookup(input.messageId);
|
|
782
|
+
}
|
|
756
783
|
if (input.state === "hard-bounce") {
|
|
757
784
|
const subject = input.to ?? existing?.to;
|
|
758
785
|
const medium = (input.medium ?? existing?.medium ?? "email") as ChannelMedium;
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Postgres channel ledger — incremental receipt reload and cache-miss lookup.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import type { DeliveryReceipt } from "./receipts.ts";
|
|
7
|
+
import { createChannelRuntime } from "./runtime.ts";
|
|
8
|
+
import { openPostgresChannelLedger, type ChannelLedgerSql } from "./sql-ledger.ts";
|
|
9
|
+
|
|
10
|
+
interface StoredReceipt {
|
|
11
|
+
readonly id: string;
|
|
12
|
+
readonly body: string;
|
|
13
|
+
readonly message_id: string | null;
|
|
14
|
+
readonly updated_at: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface LedgerSqlFake extends ChannelLedgerSql {
|
|
18
|
+
/**
|
|
19
|
+
* Set the fake database clock in milliseconds.
|
|
20
|
+
*
|
|
21
|
+
* @param ms - `clock_timestamp()` milliseconds
|
|
22
|
+
*/
|
|
23
|
+
setNow(ms: number): void;
|
|
24
|
+
/**
|
|
25
|
+
* Insert a row without going through the ledger.
|
|
26
|
+
*
|
|
27
|
+
* @param row - Stored receipt
|
|
28
|
+
*/
|
|
29
|
+
seedReceipt(row: StoredReceipt): void;
|
|
30
|
+
/**
|
|
31
|
+
* Pause the next incremental receipt read.
|
|
32
|
+
*
|
|
33
|
+
* @param gate - Resolves when the read may finish
|
|
34
|
+
*/
|
|
35
|
+
holdNextDelta(gate: Promise<void>): void;
|
|
36
|
+
/** Incremental receipt reads so far. */
|
|
37
|
+
deltaReads(): number;
|
|
38
|
+
/** Point lookups so far. */
|
|
39
|
+
lookupReads(): number;
|
|
40
|
+
/**
|
|
41
|
+
* Read one stored row.
|
|
42
|
+
*
|
|
43
|
+
* @param id - Receipt id
|
|
44
|
+
*/
|
|
45
|
+
receipt(id: string): StoredReceipt | undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function receipt(
|
|
49
|
+
overrides: Partial<DeliveryReceipt> & Pick<DeliveryReceipt, "id">,
|
|
50
|
+
): DeliveryReceipt {
|
|
51
|
+
return {
|
|
52
|
+
template: "note",
|
|
53
|
+
to: "a@b.c",
|
|
54
|
+
medium: "email",
|
|
55
|
+
status: "sent",
|
|
56
|
+
attempts: [],
|
|
57
|
+
at: 1,
|
|
58
|
+
...overrides,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* In-memory Postgres stand-in. Patterns are ordered so a specific statement
|
|
64
|
+
* wins over a broader one. Anything else throws.
|
|
65
|
+
*/
|
|
66
|
+
function ledgerSqlFake(): LedgerSqlFake {
|
|
67
|
+
let nowMs = 0;
|
|
68
|
+
let deltaReadCount = 0;
|
|
69
|
+
let lookupReadCount = 0;
|
|
70
|
+
let nextDeltaHold: Promise<void> | undefined;
|
|
71
|
+
const consent = new Map<string, { subject: string; medium: string; at: number }>();
|
|
72
|
+
const bounce = new Map<string, { subject: string; medium: string; at: number }>();
|
|
73
|
+
const receipts = new Map<string, StoredReceipt>();
|
|
74
|
+
|
|
75
|
+
function sqlText(value: unknown): string {
|
|
76
|
+
if (typeof value === "string") return value;
|
|
77
|
+
if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") {
|
|
78
|
+
return String(value);
|
|
79
|
+
}
|
|
80
|
+
throw new Error(`expected a SQL text binding, got ${typeof value}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function pair(params: readonly unknown[]): string {
|
|
84
|
+
return `${sqlText(params[0])}\0${sqlText(params[1])}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const execHandlers: ReadonlyArray<{
|
|
88
|
+
readonly pattern: RegExp;
|
|
89
|
+
readonly run: (params: readonly unknown[]) => { changes: number };
|
|
90
|
+
}> = [
|
|
91
|
+
{ pattern: /ADD COLUMN IF NOT EXISTS updated_at/, run: () => ({ changes: 0 }) },
|
|
92
|
+
{ pattern: /ADD COLUMN IF NOT EXISTS message_id/, run: () => ({ changes: 0 }) },
|
|
93
|
+
{
|
|
94
|
+
pattern: /CREATE INDEX IF NOT EXISTS oke_channel_receipt_message_id_idx/,
|
|
95
|
+
run: () => ({ changes: 0 }),
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
pattern: /CREATE INDEX IF NOT EXISTS oke_channel_receipt_updated_at_idx/,
|
|
99
|
+
run: () => ({ changes: 0 }),
|
|
100
|
+
},
|
|
101
|
+
{ pattern: /^CREATE TABLE/, run: () => ({ changes: 0 }) },
|
|
102
|
+
{
|
|
103
|
+
pattern: /INSERT INTO oke_channel_consent/,
|
|
104
|
+
run: (params) => {
|
|
105
|
+
consent.set(pair(params), {
|
|
106
|
+
subject: sqlText(params[0]),
|
|
107
|
+
medium: sqlText(params[1]),
|
|
108
|
+
at: Number(params[2]),
|
|
109
|
+
});
|
|
110
|
+
return { changes: 1 };
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
pattern: /DELETE FROM oke_channel_consent/,
|
|
115
|
+
run: (params) => {
|
|
116
|
+
consent.delete(pair(params));
|
|
117
|
+
return { changes: 1 };
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
pattern: /INSERT INTO oke_channel_bounce/,
|
|
122
|
+
run: (params) => {
|
|
123
|
+
bounce.set(pair(params), {
|
|
124
|
+
subject: sqlText(params[0]),
|
|
125
|
+
medium: sqlText(params[1]),
|
|
126
|
+
at: Number(params[2]),
|
|
127
|
+
});
|
|
128
|
+
return { changes: 1 };
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
pattern: /DELETE FROM oke_channel_bounce/,
|
|
133
|
+
run: (params) => {
|
|
134
|
+
bounce.delete(pair(params));
|
|
135
|
+
return { changes: 1 };
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
pattern: /UPDATE oke_channel_receipt SET message_id/,
|
|
140
|
+
run: (params) => {
|
|
141
|
+
const id = sqlText(params[1]);
|
|
142
|
+
const prev = receipts.get(id);
|
|
143
|
+
if (!prev || prev.message_id != null) return { changes: 0 };
|
|
144
|
+
receipts.set(id, { ...prev, message_id: sqlText(params[0]) });
|
|
145
|
+
return { changes: 1 };
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
pattern: /INSERT INTO oke_channel_receipt/,
|
|
150
|
+
run: (params) => {
|
|
151
|
+
const id = sqlText(params[0]);
|
|
152
|
+
receipts.set(id, {
|
|
153
|
+
id,
|
|
154
|
+
body: sqlText(params[1]),
|
|
155
|
+
message_id: params[2] == null ? null : sqlText(params[2]),
|
|
156
|
+
updated_at: nowMs,
|
|
157
|
+
});
|
|
158
|
+
return { changes: 1 };
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
pattern: /UPDATE oke_channel_receipt/,
|
|
163
|
+
run: (params) => {
|
|
164
|
+
const id = sqlText(params[2]);
|
|
165
|
+
const prev = receipts.get(id);
|
|
166
|
+
if (!prev) return { changes: 0 };
|
|
167
|
+
receipts.set(id, {
|
|
168
|
+
id,
|
|
169
|
+
body: sqlText(params[0]),
|
|
170
|
+
message_id: params[1] == null ? null : sqlText(params[1]),
|
|
171
|
+
updated_at: nowMs,
|
|
172
|
+
});
|
|
173
|
+
return { changes: 1 };
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
const queryHandlers: ReadonlyArray<{
|
|
179
|
+
readonly pattern: RegExp;
|
|
180
|
+
readonly run: (params: readonly unknown[]) => Promise<Record<string, unknown>[]>;
|
|
181
|
+
}> = [
|
|
182
|
+
{
|
|
183
|
+
pattern: /WHERE message_id IS NULL/,
|
|
184
|
+
run: () =>
|
|
185
|
+
Promise.resolve(
|
|
186
|
+
[...receipts.values()]
|
|
187
|
+
.filter((row) => row.message_id == null)
|
|
188
|
+
.map((row) => ({ id: row.id, body: row.body })),
|
|
189
|
+
),
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
pattern: /WHERE id = \? OR message_id = \?/,
|
|
193
|
+
run: (params) => {
|
|
194
|
+
lookupReadCount += 1;
|
|
195
|
+
const key = sqlText(params[0]);
|
|
196
|
+
const found = [...receipts.values()].find(
|
|
197
|
+
(row) => row.id === key || row.message_id === key,
|
|
198
|
+
);
|
|
199
|
+
if (!found) return Promise.resolve([]);
|
|
200
|
+
return Promise.resolve([
|
|
201
|
+
{
|
|
202
|
+
id: found.id,
|
|
203
|
+
body: found.body,
|
|
204
|
+
message_id: found.message_id,
|
|
205
|
+
updated_at: found.updated_at,
|
|
206
|
+
},
|
|
207
|
+
]);
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
pattern: /oke_channel_receipt/,
|
|
212
|
+
run: async (params) => {
|
|
213
|
+
deltaReadCount += 1;
|
|
214
|
+
const hold = nextDeltaHold;
|
|
215
|
+
nextDeltaHold = undefined;
|
|
216
|
+
if (hold) await hold;
|
|
217
|
+
const since = Number(params[0]);
|
|
218
|
+
const matched = [...receipts.values()].filter((row) => row.updated_at >= since);
|
|
219
|
+
if (matched.length === 0) return [{ db_now: nowMs }];
|
|
220
|
+
return matched.map((row) => ({
|
|
221
|
+
id: row.id,
|
|
222
|
+
body: row.body,
|
|
223
|
+
message_id: row.message_id,
|
|
224
|
+
updated_at: row.updated_at,
|
|
225
|
+
db_now: nowMs,
|
|
226
|
+
}));
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
pattern: /oke_channel_consent/,
|
|
231
|
+
run: () => Promise.resolve([...consent.values()]),
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
pattern: /oke_channel_bounce/,
|
|
235
|
+
run: () => Promise.resolve([...bounce.values()]),
|
|
236
|
+
},
|
|
237
|
+
];
|
|
238
|
+
|
|
239
|
+
function match<T>(
|
|
240
|
+
sql: string,
|
|
241
|
+
handlers: ReadonlyArray<{
|
|
242
|
+
readonly pattern: RegExp;
|
|
243
|
+
readonly run: (params: readonly unknown[]) => T;
|
|
244
|
+
}>,
|
|
245
|
+
params: readonly unknown[],
|
|
246
|
+
): T {
|
|
247
|
+
for (const handler of handlers) {
|
|
248
|
+
if (handler.pattern.test(sql)) return handler.run(params);
|
|
249
|
+
}
|
|
250
|
+
throw new Error(`unknown SQL: ${sql}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
async query(sql, params = []) {
|
|
255
|
+
return match(sql, queryHandlers, params);
|
|
256
|
+
},
|
|
257
|
+
async exec(sql, params = []) {
|
|
258
|
+
return match(sql, execHandlers, params);
|
|
259
|
+
},
|
|
260
|
+
setNow(ms) {
|
|
261
|
+
nowMs = ms;
|
|
262
|
+
},
|
|
263
|
+
seedReceipt(row) {
|
|
264
|
+
receipts.set(row.id, row);
|
|
265
|
+
},
|
|
266
|
+
holdNextDelta(gate) {
|
|
267
|
+
nextDeltaHold = gate;
|
|
268
|
+
},
|
|
269
|
+
deltaReads() {
|
|
270
|
+
return deltaReadCount;
|
|
271
|
+
},
|
|
272
|
+
lookupReads() {
|
|
273
|
+
return lookupReadCount;
|
|
274
|
+
},
|
|
275
|
+
receipt(id) {
|
|
276
|
+
return receipts.get(id);
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
describe("postgres channel receipt ledger", () => {
|
|
282
|
+
test("unknown SQL throws", async () => {
|
|
283
|
+
const sql = ledgerSqlFake();
|
|
284
|
+
await expect(sql.query("SELECT 1")).rejects.toThrow(/unknown SQL/);
|
|
285
|
+
await expect(sql.exec("DROP TABLE oke_channel_receipt")).rejects.toThrow(/unknown SQL/);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("incremental reload keeps a receipt that was not in the delta", async () => {
|
|
289
|
+
const sql = ledgerSqlFake();
|
|
290
|
+
sql.seedReceipt({
|
|
291
|
+
id: "keep",
|
|
292
|
+
body: JSON.stringify(receipt({ id: "keep", messageId: "keep-m" })),
|
|
293
|
+
message_id: "keep-m",
|
|
294
|
+
updated_at: 0,
|
|
295
|
+
});
|
|
296
|
+
sql.setNow(100_000);
|
|
297
|
+
const ledger = await openPostgresChannelLedger(sql);
|
|
298
|
+
expect(ledger.receipts.byMessageId("keep-m")?.id).toBe("keep");
|
|
299
|
+
|
|
300
|
+
sql.setNow(200_000);
|
|
301
|
+
sql.seedReceipt({
|
|
302
|
+
id: "keep",
|
|
303
|
+
body: JSON.stringify(receipt({ id: "keep", messageId: "keep-m", template: "rewritten" })),
|
|
304
|
+
message_id: "keep-m",
|
|
305
|
+
updated_at: 0,
|
|
306
|
+
});
|
|
307
|
+
await ledger.reload();
|
|
308
|
+
|
|
309
|
+
expect(ledger.receipts.byMessageId("keep-m")?.template).toBe("note");
|
|
310
|
+
expect(ledger.receipts.all().map((row) => row.id)).toEqual(["keep"]);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("a status update reloads onto a second ledger without clearing", async () => {
|
|
314
|
+
const sql = ledgerSqlFake();
|
|
315
|
+
sql.seedReceipt({
|
|
316
|
+
id: "keep",
|
|
317
|
+
body: JSON.stringify(receipt({ id: "keep", messageId: "keep-m" })),
|
|
318
|
+
message_id: "keep-m",
|
|
319
|
+
updated_at: 0,
|
|
320
|
+
});
|
|
321
|
+
sql.setNow(100_000);
|
|
322
|
+
const writer = await openPostgresChannelLedger(sql);
|
|
323
|
+
const reader = await openPostgresChannelLedger(sql);
|
|
324
|
+
writer.receipts.record(receipt({ id: "r1", messageId: "m1", status: "sent" }));
|
|
325
|
+
await writer.flush();
|
|
326
|
+
expect(writer.receipts.updateStatus("m1", { status: "hard-bounce", at: 5 })?.status).toBe(
|
|
327
|
+
"hard-bounce",
|
|
328
|
+
);
|
|
329
|
+
await writer.flush();
|
|
330
|
+
|
|
331
|
+
sql.setNow(200_000);
|
|
332
|
+
sql.seedReceipt({
|
|
333
|
+
id: "keep",
|
|
334
|
+
body: JSON.stringify(receipt({ id: "keep", messageId: "keep-m", template: "rewritten" })),
|
|
335
|
+
message_id: "keep-m",
|
|
336
|
+
updated_at: 0,
|
|
337
|
+
});
|
|
338
|
+
await reader.reload();
|
|
339
|
+
|
|
340
|
+
expect(reader.receipts.byMessageId("keep-m")?.template).toBe("note");
|
|
341
|
+
expect(reader.receipts.byMessageId("m1")?.status).toBe("hard-bounce");
|
|
342
|
+
expect(reader.receipts.all()).toHaveLength(2);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("a message id miss loads the row instead of synthesizing", async () => {
|
|
346
|
+
const sql = ledgerSqlFake();
|
|
347
|
+
sql.setNow(1_000);
|
|
348
|
+
const writer = await openPostgresChannelLedger(sql);
|
|
349
|
+
const reader = await openPostgresChannelLedger(sql);
|
|
350
|
+
writer.receipts.record(
|
|
351
|
+
receipt({ id: "r1", messageId: "provider-1", template: "note", to: "a@b.c" }),
|
|
352
|
+
);
|
|
353
|
+
await writer.flush();
|
|
354
|
+
expect(reader.receipts.byMessageId("provider-1")).toBeUndefined();
|
|
355
|
+
|
|
356
|
+
const runtime = createChannelRuntime({
|
|
357
|
+
receipts: reader.receipts,
|
|
358
|
+
suppression: reader.suppression,
|
|
359
|
+
});
|
|
360
|
+
const result = await runtime.ingestOutcome({
|
|
361
|
+
messageId: "provider-1",
|
|
362
|
+
state: "hard-bounce",
|
|
363
|
+
medium: "email",
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
expect(sql.lookupReads()).toBe(1);
|
|
367
|
+
expect(result.id).toBe("r1");
|
|
368
|
+
expect(result.template).toBe("note");
|
|
369
|
+
expect(result.status).toBe("hard-bounce");
|
|
370
|
+
expect(reader.receipts.all()).toHaveLength(1);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("legacy updated_at 0 rows are included on the first reload", async () => {
|
|
374
|
+
const sql = ledgerSqlFake();
|
|
375
|
+
const legacy = receipt({ id: "legacy", messageId: "leg-1", template: "note" });
|
|
376
|
+
sql.seedReceipt({
|
|
377
|
+
id: "legacy",
|
|
378
|
+
body: JSON.stringify(legacy),
|
|
379
|
+
message_id: null,
|
|
380
|
+
updated_at: 0,
|
|
381
|
+
});
|
|
382
|
+
sql.setNow(5_000);
|
|
383
|
+
const ledger = await openPostgresChannelLedger(sql);
|
|
384
|
+
|
|
385
|
+
expect(ledger.receipts.byMessageId("leg-1")?.id).toBe("legacy");
|
|
386
|
+
expect(sql.receipt("legacy")?.message_id).toBe("leg-1");
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("a second reload waits for the in-flight read", async () => {
|
|
390
|
+
const sql = ledgerSqlFake();
|
|
391
|
+
const ledger = await openPostgresChannelLedger(sql);
|
|
392
|
+
const before = sql.deltaReads();
|
|
393
|
+
let release: () => void = () => {};
|
|
394
|
+
const gate = new Promise<void>((resolve) => {
|
|
395
|
+
release = resolve;
|
|
396
|
+
});
|
|
397
|
+
sql.holdNextDelta(gate);
|
|
398
|
+
const first = ledger.reload();
|
|
399
|
+
const second = ledger.reload();
|
|
400
|
+
release();
|
|
401
|
+
await Promise.all([first, second]);
|
|
402
|
+
expect(sql.deltaReads()).toBe(before + 1);
|
|
403
|
+
});
|
|
404
|
+
});
|