okengine 0.23.0 → 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 +4 -4
- package/site/content/docs/elements/channel/index.mdx +4 -4
- package/site/content/docs/elements/channel/receipts.mdx +7 -5
- package/site/content/docs/elements/channel/sms.mdx +2 -1
- package/site/content/docs/elements/flow/index.mdx +22 -18
- package/site/content/docs/elements/signal/broadcast.mdx +4 -2
- package/site/content/docs/elements/signal/index.mdx +15 -14
- package/site/content/docs/plugins/otp.mdx +3 -3
- package/site/content/docs/reference/configuration.mdx +16 -0
- package/site/content/docs/reference/errors.mdx +31 -19
- package/site/content/docs/reference/fx.mdx +12 -4
- package/src/auth/tenants.ts +17 -0
- package/src/cli/doctor-diff.test.ts +29 -1
- package/src/cli/doctor-diff.ts +35 -0
- package/src/cli/manifest-pr-diff.ts +16 -0
- 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/effects-fetch.test.ts +77 -0
- package/src/compiler/effects-infer.ts +87 -16
- package/src/compiler/effects-join.test.ts +48 -0
- package/src/compiler/extract.test.ts +28 -0
- package/src/compiler/extract.ts +11 -1
- package/src/compiler/fx-index.ts +58 -4
- 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/index.ts +1 -1
- package/src/console/ui-next/dist/assets/{access-page-DDKkhT9v.js → access-page-zBMhZnFm.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-62Xts8Xi.js → agent-disclosure-CBj9f7Ju.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-DyeoKHKA.js → cache-glyph-D3MY49h3.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-DvfpLXsU.js → call-pii-button-L9JxdBhp.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-BY03SeCg.js → collapsible-BvFaX-Zt.js} +1 -1
- package/src/console/ui-next/dist/assets/{decisions-page-CgkKlA56.js → decisions-page-DgB76m-X.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-Y6HLjyJ5.js → duration-tone-l1DSJ2Vz.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-DLPfNy-_.js → flows-page-Bb9t1lu6.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-D7nNzpgB.js → highlighted-json-DMvsi3Ti.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-DdL19zzh.js → http-method-BwhJBTcQ.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-C0lc_s9d.js → index-RhV2jT_7.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CRlcyLCC.js → observability-page-BRfUILBq.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-Bb3FWUu9.js → replica-lag-BsceAzIG.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-DYBMWhX8.js → request-meta-C6lTyCXp.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-CcE-SXC8.js → store-page-YmE806bJ.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-CKMSEQ4s.js → trace-detail-sheet-Di4irS49.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-QOfk0M0H.js → tree-expand-toggle-DYeL6Rye.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-DKC1CDDd.js → units-page-07agasRA.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-B54v4Yit.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 +106 -2
- package/src/drivers/journal-postgres.ts +475 -14
- package/src/drivers/postgres.test.ts +60 -0
- package/src/drivers/postgres.ts +79 -5
- package/src/drivers/signal-compete.test.ts +238 -0
- package/src/drivers/signal-postgres.test.ts +170 -0
- package/src/drivers/signal-postgres.ts +486 -213
- package/src/drivers/signal-redis.test.ts +224 -0
- package/src/drivers/signal-redis.ts +934 -93
- package/src/drivers/signal-types.ts +12 -0
- package/src/elements/ai/approval.test.ts +15 -3
- package/src/elements/ai/approval.ts +15 -9
- package/src/elements/ai/mcp-protocol.ts +5 -6
- package/src/elements/ai/runtime.ts +20 -19
- package/src/elements/channel/runtime.ts +42 -4
- package/src/elements/channel/sql-ledger.test.ts +404 -0
- package/src/elements/channel/sql-ledger.ts +395 -0
- package/src/elements/clock/chaos-child.ts +12 -2
- package/src/elements/clock/durable.ts +11 -0
- package/src/elements/signal/runtime.ts +9 -0
- package/src/elements/signal.test.ts +1 -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 +179 -15
- package/src/elements/store/cache.ts +357 -51
- package/src/elements/store/prepare-row.test.ts +6 -2
- 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.test.ts +2 -0
- package/src/elements/store/sql-session.ts +275 -9
- package/src/elements/store.ts +1 -0
- package/src/kernel/app.ts +207 -43
- package/src/kernel/auto-cache.test.ts +228 -0
- package/src/kernel/auto-registry.test.ts +5 -1
- package/src/kernel/boot-bind/channel.ts +63 -2
- package/src/kernel/boot-bind/honor-config.test.ts +40 -10
- package/src/kernel/boot-bind/signal.ts +72 -25
- package/src/kernel/boot-bind/store.ts +16 -1
- package/src/kernel/boot.test.ts +2 -2
- package/src/kernel/boot.ts +54 -10
- 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/errors-compiler.ts +11 -0
- package/src/kernel/errors-text.ts +16 -0
- package/src/kernel/errors.ts +8 -0
- package/src/kernel/flow.ts +33 -3
- package/src/kernel/fx-call-types.test.ts +32 -0
- package/src/kernel/fx-decide.ts +11 -7
- package/src/kernel/fx-sql-handle.ts +52 -7
- package/src/kernel/fx.test.ts +1 -3
- package/src/kernel/fx.ts +27 -16
- package/src/kernel/horizontal-child.ts +54 -1
- package/src/kernel/horizontal.integration.test.ts +131 -7
- package/src/kernel/idempotency.test.ts +4 -4
- package/src/kernel/journal-boot.test.ts +83 -6
- package/src/kernel/journal.test.ts +158 -0
- package/src/kernel/journal.ts +515 -54
- package/src/kernel/pipeline-tenant.ts +5 -1
- package/src/kernel/pipeline.ts +5 -0
- package/src/kernel/signal-tx.ts +28 -0
- package/src/kernel/store-transaction.test.ts +95 -0
- package/src/mcp/authorization.ts +5 -11
- package/src/mcp/confirmation.ts +203 -38
- package/src/mcp/docs-server.ts +26 -4
- package/src/mcp/index.ts +4 -0
- package/src/mcp/mcp.test.ts +404 -33
- package/src/mcp/protocol.ts +8 -2
- package/src/mcp/server.ts +31 -5
- package/src/mcp/session.ts +7 -0
- package/src/mcp/tools.ts +68 -19
- package/src/mcp/versions.ts +60 -0
- package/src/plugins/index.ts +1 -0
- package/src/plugins/mena.ts +56 -0
- package/src/runtime/bun.ts +5 -0
- package/src/runtime/types.ts +5 -0
|
@@ -265,6 +265,18 @@ export interface SignalOpenOptions {
|
|
|
265
265
|
readonly nats?: SignalNatsClientLike;
|
|
266
266
|
/** Outbox SQL for redis/nats relay (defaults to in-process). */
|
|
267
267
|
readonly outboxSql?: unknown;
|
|
268
|
+
/**
|
|
269
|
+
* When the SQL client has no `LISTEN`, poll `drain` on this interval (ms).
|
|
270
|
+
* `0` or omitted with a `listen` client uses notifications only.
|
|
271
|
+
*/
|
|
272
|
+
readonly pollMs?: number;
|
|
273
|
+
/**
|
|
274
|
+
* Redis `once` consume via a consumer group (`xreadgroup` / `xack`)
|
|
275
|
+
* instead of the process-local outbox. Boot sets this for `drivers.signal: redis`.
|
|
276
|
+
*/
|
|
277
|
+
readonly compete?: boolean;
|
|
278
|
+
/** Consumer name inside the redis group. Defaults to `"local"`. */
|
|
279
|
+
readonly consumerId?: string;
|
|
268
280
|
}
|
|
269
281
|
|
|
270
282
|
/**
|
|
@@ -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);
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MCP client protocol — 2026-07-28 happy path + dual-era fallback types.
|
|
3
3
|
*
|
|
4
|
-
* Reuses only the JSON-RPC envelope.
|
|
5
|
-
*
|
|
4
|
+
* Reuses only the JSON-RPC envelope. The client version is
|
|
5
|
+
* {@link MCP_CLIENT_PROTOCOL_VERSION} from the shared module, not the
|
|
6
|
+
* server's initialize advertisement.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
export const MCP_CLIENT_PROTOCOL_VERSION = "2026-07-28";
|
|
9
|
+
import { MCP_CLIENT_PROTOCOL_VERSION, MCP_LEGACY_PROTOCOL_VERSION } from "../../mcp/versions.ts";
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
export const MCP_LEGACY_PROTOCOL_VERSION = "2024-11-05";
|
|
11
|
+
export { MCP_CLIENT_PROTOCOL_VERSION, MCP_LEGACY_PROTOCOL_VERSION };
|
|
13
12
|
|
|
14
13
|
/** JSON-RPC request id. */
|
|
15
14
|
export type JsonRpcId = string | number;
|
|
@@ -879,8 +879,7 @@ export function createAiRuntime(options: CreateAiRuntimeOptions = {}): AiRuntime
|
|
|
879
879
|
approvalStepName(id),
|
|
880
880
|
() => record,
|
|
881
881
|
)) as AgentApprovalRecord;
|
|
882
|
-
|
|
883
|
-
if (decision.status === "pending") {
|
|
882
|
+
if (stored.status === "pending") {
|
|
884
883
|
opts.emit?.({
|
|
885
884
|
type: "RUN_FINISHED",
|
|
886
885
|
threadId: opts.threadId ?? opts.runId ?? opts.journal.runId,
|
|
@@ -890,24 +889,26 @@ export function createAiRuntime(options: CreateAiRuntimeOptions = {}): AiRuntime
|
|
|
890
889
|
interrupts: [{ id, reason: "approval", payload: { tool: capability, args } }],
|
|
891
890
|
},
|
|
892
891
|
});
|
|
893
|
-
|
|
894
|
-
|
|
892
|
+
}
|
|
893
|
+
// Always consume the sleep row. Resolve moves wakeAt to now, so a
|
|
894
|
+
// resolved replay returns here instead of parking again.
|
|
895
|
+
await opts.journal.sleep(approvalStepName(id), approval.timeout, () =>
|
|
896
|
+
approvalTimeoutMs(approval.timeout),
|
|
897
|
+
);
|
|
898
|
+
const store = options.journalStore;
|
|
899
|
+
if (!store) throw new Error("ai: approval resume requires a journal store");
|
|
900
|
+
let decision = (await readAgentApproval(store, id)) ?? stored;
|
|
901
|
+
if (decision.status === "pending") {
|
|
902
|
+
const wrote = await resolveAgentApproval(
|
|
903
|
+
store,
|
|
904
|
+
id,
|
|
905
|
+
{ decision: "deny", reason: "timeout", tenant: decision.tenant },
|
|
906
|
+
now,
|
|
907
|
+
opts.journal.run.lockedBy,
|
|
895
908
|
);
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
if (decision.status === "pending") {
|
|
900
|
-
const wrote = await resolveAgentApproval(
|
|
901
|
-
store,
|
|
902
|
-
id,
|
|
903
|
-
{ decision: "deny", reason: "timeout", tenant: decision.tenant },
|
|
904
|
-
now,
|
|
905
|
-
opts.journal.run.lockedBy,
|
|
906
|
-
);
|
|
907
|
-
decision = wrote.ok
|
|
908
|
-
? { ...decision, status: "denied", reason: "timeout" }
|
|
909
|
-
: ((await readAgentApproval(store, id)) ?? decision);
|
|
910
|
-
}
|
|
909
|
+
decision = wrote.ok
|
|
910
|
+
? { ...decision, status: "denied", reason: "timeout" }
|
|
911
|
+
: ((await readAgentApproval(store, id)) ?? decision);
|
|
911
912
|
}
|
|
912
913
|
if (decision.status === "denied") {
|
|
913
914
|
const denial: AgentDenial = {
|
|
@@ -105,6 +105,11 @@ export interface CreateChannelRuntimeOptions {
|
|
|
105
105
|
readonly retry?: boolean;
|
|
106
106
|
/** Injectable clock. */
|
|
107
107
|
readonly now?: () => number;
|
|
108
|
+
/**
|
|
109
|
+
* Reload durable consent / suppression / receipts.
|
|
110
|
+
* Boot calls this from the scheduler when a SQL ledger is bound.
|
|
111
|
+
*/
|
|
112
|
+
readonly refresh?: () => Promise<void>;
|
|
108
113
|
}
|
|
109
114
|
|
|
110
115
|
/** Send options for {@link ChannelRuntime.send}. */
|
|
@@ -127,6 +132,11 @@ export interface ChannelRuntime {
|
|
|
127
132
|
readonly costs: MediumCosts;
|
|
128
133
|
/** Bound driver chain (email + SMS + …). */
|
|
129
134
|
readonly drivers: readonly ChannelDriver[];
|
|
135
|
+
/**
|
|
136
|
+
* Reload a shared consent / suppression / receipt ledger.
|
|
137
|
+
* Memory stores leave this unset.
|
|
138
|
+
*/
|
|
139
|
+
refresh?(): Promise<void>;
|
|
130
140
|
/**
|
|
131
141
|
* Send a template through the driver chain.
|
|
132
142
|
*
|
|
@@ -160,11 +170,12 @@ export interface ChannelRuntime {
|
|
|
160
170
|
/**
|
|
161
171
|
* Ingest a post-send provider outcome (bounce / complaint / …).
|
|
162
172
|
* Hard bounce auto-adds suppression. Console projects the ledger — never
|
|
163
|
-
* raw webhooks.
|
|
173
|
+
* raw webhooks. When the ledger can read SQL, a cache miss loads that
|
|
174
|
+
* receipt before a new one is recorded.
|
|
164
175
|
*
|
|
165
176
|
* @param input - Normalized outcome
|
|
166
177
|
*/
|
|
167
|
-
ingestOutcome(input: IngestOutcomeInput): DeliveryReceipt
|
|
178
|
+
ingestOutcome(input: IngestOutcomeInput): Promise<DeliveryReceipt>;
|
|
168
179
|
}
|
|
169
180
|
|
|
170
181
|
/**
|
|
@@ -179,6 +190,28 @@ export function resetChannelProcessLocalWarnForTests(): void {
|
|
|
179
190
|
channelProcessLocalWarned = false;
|
|
180
191
|
}
|
|
181
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
|
+
|
|
182
215
|
export function createChannelRuntime(options: CreateChannelRuntimeOptions = {}): ChannelRuntime {
|
|
183
216
|
const templates = new Map<string, ChannelTemplateDecl>();
|
|
184
217
|
for (const t of options.templates ?? []) {
|
|
@@ -728,6 +761,7 @@ export function createChannelRuntime(options: CreateChannelRuntimeOptions = {}):
|
|
|
728
761
|
receipts,
|
|
729
762
|
costs,
|
|
730
763
|
drivers,
|
|
764
|
+
...(options.refresh ? { refresh: options.refresh } : {}),
|
|
731
765
|
async sendOtp(opts) {
|
|
732
766
|
const { otp } = otpSmsDriver();
|
|
733
767
|
return otp.sendOtp(opts);
|
|
@@ -739,9 +773,13 @@ export function createChannelRuntime(options: CreateChannelRuntimeOptions = {}):
|
|
|
739
773
|
async deliverOtp(opts) {
|
|
740
774
|
return deliverOtpAcrossChannels(drivers, sendTemplate, opts, now);
|
|
741
775
|
},
|
|
742
|
-
ingestOutcome(input) {
|
|
776
|
+
async ingestOutcome(input) {
|
|
743
777
|
const at = input.at ?? now();
|
|
744
|
-
|
|
778
|
+
let existing = receipts.byMessageId(input.messageId);
|
|
779
|
+
if (!existing) {
|
|
780
|
+
const lookup = lookupReceipt(receipts);
|
|
781
|
+
if (lookup) existing = await lookup(input.messageId);
|
|
782
|
+
}
|
|
745
783
|
if (input.state === "hard-bounce") {
|
|
746
784
|
const subject = input.to ?? existing?.to;
|
|
747
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
|
+
});
|