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.
Files changed (128) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/ai/mcp.mdx +4 -4
  3. package/site/content/docs/elements/channel/index.mdx +4 -4
  4. package/site/content/docs/elements/channel/receipts.mdx +7 -5
  5. package/site/content/docs/elements/channel/sms.mdx +2 -1
  6. package/site/content/docs/elements/flow/index.mdx +22 -18
  7. package/site/content/docs/elements/signal/broadcast.mdx +4 -2
  8. package/site/content/docs/elements/signal/index.mdx +15 -14
  9. package/site/content/docs/plugins/otp.mdx +3 -3
  10. package/site/content/docs/reference/configuration.mdx +16 -0
  11. package/site/content/docs/reference/errors.mdx +31 -19
  12. package/site/content/docs/reference/fx.mdx +12 -4
  13. package/src/auth/tenants.ts +17 -0
  14. package/src/cli/doctor-diff.test.ts +29 -1
  15. package/src/cli/doctor-diff.ts +35 -0
  16. package/src/cli/manifest-pr-diff.ts +16 -0
  17. package/src/compiler/aot.test.ts +3 -2
  18. package/src/compiler/aot.ts +45 -1
  19. package/src/compiler/dynamic.ts +1 -1
  20. package/src/compiler/effects-fetch.test.ts +77 -0
  21. package/src/compiler/effects-infer.ts +87 -16
  22. package/src/compiler/effects-join.test.ts +48 -0
  23. package/src/compiler/extract.test.ts +28 -0
  24. package/src/compiler/extract.ts +11 -1
  25. package/src/compiler/fx-index.ts +58 -4
  26. package/src/compiler/http-parse.test.ts +101 -0
  27. package/src/compiler/http-parse.ts +188 -9
  28. package/src/compiler/interpret.ts +25 -2
  29. package/src/console/index.ts +1 -1
  30. package/src/console/ui-next/dist/assets/{access-page-DDKkhT9v.js → access-page-zBMhZnFm.js} +1 -1
  31. package/src/console/ui-next/dist/assets/{agent-disclosure-62Xts8Xi.js → agent-disclosure-CBj9f7Ju.js} +1 -1
  32. package/src/console/ui-next/dist/assets/{cache-glyph-DyeoKHKA.js → cache-glyph-D3MY49h3.js} +1 -1
  33. package/src/console/ui-next/dist/assets/{call-pii-button-DvfpLXsU.js → call-pii-button-L9JxdBhp.js} +1 -1
  34. package/src/console/ui-next/dist/assets/{collapsible-BY03SeCg.js → collapsible-BvFaX-Zt.js} +1 -1
  35. package/src/console/ui-next/dist/assets/{decisions-page-CgkKlA56.js → decisions-page-DgB76m-X.js} +1 -1
  36. package/src/console/ui-next/dist/assets/{duration-tone-Y6HLjyJ5.js → duration-tone-l1DSJ2Vz.js} +1 -1
  37. package/src/console/ui-next/dist/assets/{flows-page-DLPfNy-_.js → flows-page-Bb9t1lu6.js} +1 -1
  38. package/src/console/ui-next/dist/assets/{highlighted-json-D7nNzpgB.js → highlighted-json-DMvsi3Ti.js} +1 -1
  39. package/src/console/ui-next/dist/assets/{http-method-DdL19zzh.js → http-method-BwhJBTcQ.js} +1 -1
  40. package/src/console/ui-next/dist/assets/{index-C0lc_s9d.js → index-RhV2jT_7.js} +3 -3
  41. package/src/console/ui-next/dist/assets/{observability-page-CRlcyLCC.js → observability-page-BRfUILBq.js} +1 -1
  42. package/src/console/ui-next/dist/assets/{replica-lag-Bb3FWUu9.js → replica-lag-BsceAzIG.js} +1 -1
  43. package/src/console/ui-next/dist/assets/{request-meta-DYBMWhX8.js → request-meta-C6lTyCXp.js} +1 -1
  44. package/src/console/ui-next/dist/assets/{store-page-CcE-SXC8.js → store-page-YmE806bJ.js} +1 -1
  45. package/src/console/ui-next/dist/assets/{trace-detail-sheet-CKMSEQ4s.js → trace-detail-sheet-Di4irS49.js} +1 -1
  46. package/src/console/ui-next/dist/assets/{tree-expand-toggle-QOfk0M0H.js → tree-expand-toggle-DYeL6Rye.js} +1 -1
  47. package/src/console/ui-next/dist/assets/{units-page-DKC1CDDd.js → units-page-07agasRA.js} +1 -1
  48. package/src/console/ui-next/dist/assets/{vault-page-B54v4Yit.js → vault-page-CHBck4n_.js} +1 -1
  49. package/src/console/ui-next/dist/index.html +1 -1
  50. package/src/console/ui-next/seed-parked-approval.ts +5 -1
  51. package/src/drivers/journal-postgres.test.ts +106 -2
  52. package/src/drivers/journal-postgres.ts +475 -14
  53. package/src/drivers/postgres.test.ts +60 -0
  54. package/src/drivers/postgres.ts +79 -5
  55. package/src/drivers/signal-compete.test.ts +238 -0
  56. package/src/drivers/signal-postgres.test.ts +170 -0
  57. package/src/drivers/signal-postgres.ts +486 -213
  58. package/src/drivers/signal-redis.test.ts +224 -0
  59. package/src/drivers/signal-redis.ts +934 -93
  60. package/src/drivers/signal-types.ts +12 -0
  61. package/src/elements/ai/approval.test.ts +15 -3
  62. package/src/elements/ai/approval.ts +15 -9
  63. package/src/elements/ai/mcp-protocol.ts +5 -6
  64. package/src/elements/ai/runtime.ts +20 -19
  65. package/src/elements/channel/runtime.ts +42 -4
  66. package/src/elements/channel/sql-ledger.test.ts +404 -0
  67. package/src/elements/channel/sql-ledger.ts +395 -0
  68. package/src/elements/clock/chaos-child.ts +12 -2
  69. package/src/elements/clock/durable.ts +11 -0
  70. package/src/elements/signal/runtime.ts +9 -0
  71. package/src/elements/signal.test.ts +1 -0
  72. package/src/elements/store/cache-bus.test.ts +121 -0
  73. package/src/elements/store/cache-bus.ts +278 -0
  74. package/src/elements/store/cache.test.ts +179 -15
  75. package/src/elements/store/cache.ts +357 -51
  76. package/src/elements/store/prepare-row.test.ts +6 -2
  77. package/src/elements/store/runtime.ts +44 -2
  78. package/src/elements/store/sql-errors.test.ts +131 -0
  79. package/src/elements/store/sql-errors.ts +39 -16
  80. package/src/elements/store/sql-nested-tx.test.ts +344 -0
  81. package/src/elements/store/sql-session.test.ts +2 -0
  82. package/src/elements/store/sql-session.ts +275 -9
  83. package/src/elements/store.ts +1 -0
  84. package/src/kernel/app.ts +207 -43
  85. package/src/kernel/auto-cache.test.ts +228 -0
  86. package/src/kernel/auto-registry.test.ts +5 -1
  87. package/src/kernel/boot-bind/channel.ts +63 -2
  88. package/src/kernel/boot-bind/honor-config.test.ts +40 -10
  89. package/src/kernel/boot-bind/signal.ts +72 -25
  90. package/src/kernel/boot-bind/store.ts +16 -1
  91. package/src/kernel/boot.test.ts +2 -2
  92. package/src/kernel/boot.ts +54 -10
  93. package/src/kernel/boundary-contract.ts +7 -0
  94. package/src/kernel/builtin-errors.ts +2 -0
  95. package/src/kernel/call.test.ts +1 -0
  96. package/src/kernel/errors-compiler.ts +11 -0
  97. package/src/kernel/errors-text.ts +16 -0
  98. package/src/kernel/errors.ts +8 -0
  99. package/src/kernel/flow.ts +33 -3
  100. package/src/kernel/fx-call-types.test.ts +32 -0
  101. package/src/kernel/fx-decide.ts +11 -7
  102. package/src/kernel/fx-sql-handle.ts +52 -7
  103. package/src/kernel/fx.test.ts +1 -3
  104. package/src/kernel/fx.ts +27 -16
  105. package/src/kernel/horizontal-child.ts +54 -1
  106. package/src/kernel/horizontal.integration.test.ts +131 -7
  107. package/src/kernel/idempotency.test.ts +4 -4
  108. package/src/kernel/journal-boot.test.ts +83 -6
  109. package/src/kernel/journal.test.ts +158 -0
  110. package/src/kernel/journal.ts +515 -54
  111. package/src/kernel/pipeline-tenant.ts +5 -1
  112. package/src/kernel/pipeline.ts +5 -0
  113. package/src/kernel/signal-tx.ts +28 -0
  114. package/src/kernel/store-transaction.test.ts +95 -0
  115. package/src/mcp/authorization.ts +5 -11
  116. package/src/mcp/confirmation.ts +203 -38
  117. package/src/mcp/docs-server.ts +26 -4
  118. package/src/mcp/index.ts +4 -0
  119. package/src/mcp/mcp.test.ts +404 -33
  120. package/src/mcp/protocol.ts +8 -2
  121. package/src/mcp/server.ts +31 -5
  122. package/src/mcp/session.ts +7 -0
  123. package/src/mcp/tools.ts +68 -19
  124. package/src/mcp/versions.ts +60 -0
  125. package/src/plugins/index.ts +1 -0
  126. package/src/plugins/mena.ts +56 -0
  127. package/src/runtime/bun.ts +5 -0
  128. 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", { method: "POST", body: "{}" }),
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", { method: "POST", body: "{}" }),
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", { method: "POST", body: "{}" }),
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 updated: JournalRun = {
216
- ...run,
217
- entries,
218
- wakeAt: at,
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
- await store.put(updated);
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. Does not import the app MCP *server*
5
- * dialect (`2024-11-05` initialize) as the client version.
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
- /** Current MCP protocol version (stateless core). */
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
- /** Legacy initialize dialect still in production. */
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
- let decision = stored;
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
- await opts.journal.sleep(approvalStepName(id), approval.timeout, () =>
894
- approvalTimeoutMs(approval.timeout),
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
- const store = options.journalStore;
897
- if (!store) throw new Error("ai: approval resume requires a journal store");
898
- decision = (await readAgentApproval(store, id)) ?? decision;
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
- const existing = receipts.byMessageId(input.messageId);
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
+ });