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
@@ -9,6 +9,7 @@ import {
9
9
  isPostgresConnectionNoise,
10
10
  isSharedPostgresPaused,
11
11
  pauseSharedPostgresClients,
12
+ PostgresDriverError,
12
13
  resumeSharedPostgresClients,
13
14
  SharedPostgresPausedError,
14
15
  sharedPostgresClient,
@@ -101,3 +102,62 @@ describe("pauseSharedPostgresClients", () => {
101
102
  expect(isPostgresConnectionNoise(new Error("relation missing"))).toBe(false);
102
103
  });
103
104
  });
105
+
106
+ describe("PostgresDriverError", () => {
107
+ test("a non-Error rejection copies SQLSTATE fields and sets cause", async () => {
108
+ const cause = {
109
+ code: "ERR_POSTGRES_SERVER_ERROR",
110
+ errno: "23505",
111
+ constraint: "users_pkey",
112
+ table: "users",
113
+ column: "id",
114
+ message: "duplicate key",
115
+ };
116
+ const conn = await connectPostgres({
117
+ client: {
118
+ async unsafe() {
119
+ throw cause;
120
+ },
121
+ },
122
+ });
123
+ let caught: unknown;
124
+ try {
125
+ await conn.query("SELECT 1");
126
+ } catch (err) {
127
+ caught = err;
128
+ } finally {
129
+ await conn.close();
130
+ }
131
+ expect(caught).toBeInstanceOf(PostgresDriverError);
132
+ const wrapped = caught as PostgresDriverError;
133
+ expect(wrapped.code).toBe("ERR_POSTGRES_SERVER_ERROR");
134
+ expect(wrapped.errno).toBe("23505");
135
+ expect(wrapped.constraint).toBe("users_pkey");
136
+ expect(wrapped.table).toBe("users");
137
+ expect(wrapped.column).toBe("id");
138
+ expect(wrapped.cause).toBe(cause);
139
+ });
140
+
141
+ test("an Error rejection is rethrown unchanged", async () => {
142
+ const original = Object.assign(new Error("duplicate key"), {
143
+ code: "23505",
144
+ errno: "23505",
145
+ });
146
+ const conn = await connectPostgres({
147
+ client: {
148
+ async unsafe() {
149
+ throw original;
150
+ },
151
+ },
152
+ });
153
+ let caught: unknown;
154
+ try {
155
+ await conn.query("SELECT 1");
156
+ } catch (err) {
157
+ caught = err;
158
+ } finally {
159
+ await conn.close();
160
+ }
161
+ expect(caught).toBe(original);
162
+ });
163
+ });
@@ -87,6 +87,80 @@ export class SharedPostgresPausedError extends Error {
87
87
  }
88
88
  }
89
89
 
90
+ /**
91
+ * Bun.SQL rejected with a value that is not an {@link Error}.
92
+ *
93
+ * {@link Error.cause} is the original rejection. `code`, `errno`,
94
+ * `constraint`, `table`, and `column` are copied when the driver sent them
95
+ * so SQLSTATE mapping can read a plain object the same way it reads an Error.
96
+ */
97
+ export class PostgresDriverError extends Error {
98
+ /** Driver `code` (`ERR_POSTGRES_*` or a five-character SQLSTATE). */
99
+ readonly code?: string;
100
+ /** SQLSTATE string, or a numeric errno when the driver sent one. */
101
+ readonly errno?: string | number;
102
+ /** Constraint name, when the driver sent one. */
103
+ readonly constraint?: string;
104
+ /** Table name, when the driver sent one. */
105
+ readonly table?: string;
106
+ /** Column name, when the driver sent one. */
107
+ readonly column?: string;
108
+
109
+ /**
110
+ * @param cause - Non-Error rejection from Bun.SQL
111
+ */
112
+ constructor(cause: unknown) {
113
+ super(postgresRejectionMessage(cause), { cause });
114
+ this.name = "PostgresDriverError";
115
+ if (cause === null || typeof cause !== "object") return;
116
+ const row = cause as Record<string, unknown>;
117
+ const code = readDriverString(row, "code");
118
+ const constraint = readDriverString(row, "constraint");
119
+ const table = readDriverString(row, "table");
120
+ const column = readDriverString(row, "column");
121
+ const errno = readDriverErrno(row);
122
+ if (code !== undefined) this.code = code;
123
+ if (errno !== undefined) this.errno = errno;
124
+ if (constraint !== undefined) this.constraint = constraint;
125
+ if (table !== undefined) this.table = table;
126
+ if (column !== undefined) this.column = column;
127
+ }
128
+ }
129
+
130
+ function postgresRejectionMessage(cause: unknown): string {
131
+ if (cause !== null && typeof cause === "object" && "message" in cause) {
132
+ const message = (cause as { message: unknown }).message;
133
+ if (typeof message === "string" && message.length > 0) return message;
134
+ }
135
+ return "Postgres query failed";
136
+ }
137
+
138
+ function readDriverString(row: Record<string, unknown>, key: string): string | undefined {
139
+ const value = row[key];
140
+ return typeof value === "string" && value.length > 0 ? value : undefined;
141
+ }
142
+
143
+ function readDriverErrno(row: Record<string, unknown>): string | number | undefined {
144
+ const value = row.errno;
145
+ if (typeof value === "number" && Number.isFinite(value)) return value;
146
+ if (typeof value === "string" && value.length > 0) return value;
147
+ return undefined;
148
+ }
149
+
150
+ /**
151
+ * Await a Bun.SQL call. Non-Error rejections become {@link PostgresDriverError}.
152
+ *
153
+ * @param query - Driver promise
154
+ */
155
+ async function settlePostgres<T>(query: PromiseLike<T>): Promise<T> {
156
+ try {
157
+ return await query;
158
+ } catch (err) {
159
+ if (err instanceof Error) throw err;
160
+ throw new PostgresDriverError(err);
161
+ }
162
+ }
163
+
90
164
  /**
91
165
  * Resolve the Postgres URL (injected, `DATABASE_URL`, then localhost).
92
166
  *
@@ -270,14 +344,14 @@ export async function withPinnedPostgres<T>(
270
344
  if (typeof client.reserve === "function") {
271
345
  const reserved = await client.reserve();
272
346
  try {
273
- await reserved.unsafe("BEGIN");
347
+ await settlePostgres(reserved.unsafe("BEGIN"));
274
348
  try {
275
349
  const result = await fn(reserved);
276
- await reserved.unsafe("COMMIT");
350
+ await settlePostgres(reserved.unsafe("COMMIT"));
277
351
  return result;
278
352
  } catch (err) {
279
353
  try {
280
- await reserved.unsafe("ROLLBACK");
354
+ await settlePostgres(reserved.unsafe("ROLLBACK"));
281
355
  } catch {
282
356
  // Already aborted.
283
357
  }
@@ -365,14 +439,14 @@ function wrapPostgresClient(
365
439
  // Shared wrappers outlive pause — fail soft before Bun.SQL reconnects.
366
440
  if (opts.shared) assertSharedPostgresReady();
367
441
  const pg = toPostgresParams(sql, params);
368
- const result = await client.unsafe(pg, [...params]);
442
+ const result = await settlePostgres(client.unsafe(pg, [...params]));
369
443
  if (Array.isArray(result)) return result as SqlRow[];
370
444
  return Array.from(result as ArrayLike<SqlRow>);
371
445
  },
372
446
  async exec(sql, params = []) {
373
447
  if (opts.shared) assertSharedPostgresReady();
374
448
  const pg = toPostgresParams(sql, params);
375
- const result = await client.unsafe(pg, [...params]);
449
+ const result = await settlePostgres(client.unsafe(pg, [...params]));
376
450
  if (
377
451
  result &&
378
452
  typeof result === "object" &&
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Two consumers, one bus: postgres SKIP LOCKED and redis consumer groups.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { signal } from "../elements/signal.ts";
7
+ import {
8
+ openPostgresChannelLedger,
9
+ type ChannelLedgerSql,
10
+ } from "../elements/channel/sql-ledger.ts";
11
+ import { createPostgresSignalFake, openPostgresSignal } from "./signal-postgres.ts";
12
+ import {
13
+ createBunSignalRedisClient,
14
+ createSignalRedisFake,
15
+ openRedisSignal,
16
+ SIGNAL_REDIS_MIN_IDLE_MS,
17
+ } from "./signal-redis.ts";
18
+
19
+ function decls(name: string, retries = 3) {
20
+ const decl = signal.once(name, { retries, deadLetter: true });
21
+ return new Map([[decl.name, decl]]);
22
+ }
23
+
24
+ describe("postgres signal competing consumers", () => {
25
+ test("one message is delivered once across two buses", async () => {
26
+ const sql = createPostgresSignalFake();
27
+ const name = "order-placed";
28
+ const a = await openPostgresSignal({ signals: decls(name), sql });
29
+ const b = await openPostgresSignal({ signals: decls(name), sql });
30
+ const got: string[] = [];
31
+ await a.subscribe(name, "a", async () => {
32
+ got.push("a");
33
+ });
34
+ await b.subscribe(name, "b", async () => {
35
+ got.push("b");
36
+ });
37
+ await a.emit(name, { id: "1" });
38
+ await Bun.sleep(5);
39
+ await a.drain();
40
+ await b.drain();
41
+ expect(got).toHaveLength(1);
42
+ await a.close();
43
+ await b.close();
44
+ });
45
+ });
46
+
47
+ describe("redis streams competing consumers", () => {
48
+ test("one message is delivered once across two groups", async () => {
49
+ const redis = createSignalRedisFake();
50
+ const name = "order-placed";
51
+ const shared = { signals: decls(name), redis, compete: true as const };
52
+ const a = await openRedisSignal({ ...shared, consumerId: "a" });
53
+ const b = await openRedisSignal({ ...shared, consumerId: "b" });
54
+ const got: string[] = [];
55
+ await a.subscribe(name, "a", async () => {
56
+ got.push("a");
57
+ });
58
+ await b.subscribe(name, "b", async () => {
59
+ got.push("b");
60
+ });
61
+ await a.emit(name, { id: "1" });
62
+ await a.drain();
63
+ await b.drain();
64
+ expect(got).toHaveLength(1);
65
+ await a.close();
66
+ await b.close();
67
+ });
68
+
69
+ test("a failed handler leaves the entry pending for redelivery", async () => {
70
+ const redis = createSignalRedisFake();
71
+ const name = "flaky";
72
+ const bus = await openRedisSignal({
73
+ signals: decls(name, 2),
74
+ redis,
75
+ compete: true,
76
+ consumerId: "a",
77
+ });
78
+ let calls = 0;
79
+ await bus.subscribe(name, "a", async () => {
80
+ calls += 1;
81
+ if (calls === 1) throw new Error("boom");
82
+ });
83
+ await bus.emit(name, { id: "1" });
84
+ await bus.drain();
85
+ expect(calls).toBe(1);
86
+ await bus.drain();
87
+ expect(calls).toBe(2);
88
+ await bus.close();
89
+ });
90
+
91
+ test("another consumer reclaims an unacked entry", async () => {
92
+ let nowMs = 0;
93
+ const now = () => nowMs;
94
+ const redis = createSignalRedisFake({ now });
95
+ const name = "handoff";
96
+ const shared = { signals: decls(name, 3), redis, compete: true as const, now };
97
+ const a = await openRedisSignal({ ...shared, consumerId: "a" });
98
+ const b = await openRedisSignal({ ...shared, consumerId: "b" });
99
+ let aCalls = 0;
100
+ const got: string[] = [];
101
+ await a.subscribe(name, "a", async () => {
102
+ aCalls += 1;
103
+ throw new Error("down");
104
+ });
105
+ await b.subscribe(name, "b", async () => {
106
+ got.push("b");
107
+ });
108
+ await a.emit(name, { id: "1" });
109
+ await a.drain();
110
+ nowMs += SIGNAL_REDIS_MIN_IDLE_MS;
111
+ await b.drain();
112
+ expect(aCalls).toBe(1);
113
+ expect(got).toEqual(["b"]);
114
+ await a.close();
115
+ await b.close();
116
+ });
117
+
118
+ test.skipIf(!process.env.REDIS_URL)("REDIS_URL: two clients ack once", async () => {
119
+ const url = process.env.REDIS_URL;
120
+ const name = `oke-compete-${Date.now()}`;
121
+ const redisA = createBunSignalRedisClient(url);
122
+ const redisB = createBunSignalRedisClient(url);
123
+ const a = await openRedisSignal({
124
+ signals: decls(name),
125
+ redis: redisA,
126
+ compete: true,
127
+ consumerId: "a",
128
+ });
129
+ const b = await openRedisSignal({
130
+ signals: decls(name),
131
+ redis: redisB,
132
+ compete: true,
133
+ consumerId: "b",
134
+ });
135
+ const got: string[] = [];
136
+ await a.subscribe(name, "a", async () => {
137
+ got.push("a");
138
+ });
139
+ await b.subscribe(name, "b", async () => {
140
+ got.push("b");
141
+ });
142
+ await a.emit(name, { id: "1" });
143
+ await a.drain();
144
+ await b.drain();
145
+ expect(got).toHaveLength(1);
146
+ await a.close();
147
+ await b.close();
148
+ });
149
+ });
150
+
151
+ function memoryLedgerSql(): ChannelLedgerSql {
152
+ const consent = new Map<string, { subject: string; medium: string; at: number }>();
153
+ const bounce = new Map<string, { subject: string; medium: string; at: number }>();
154
+ const receipts = new Map<string, string>();
155
+
156
+ function pair(params: readonly unknown[]): string {
157
+ return `${String(params[0])}\0${String(params[1])}`;
158
+ }
159
+
160
+ return {
161
+ async query(sql) {
162
+ if (sql.includes("oke_channel_consent")) {
163
+ return [...consent.values()];
164
+ }
165
+ if (sql.includes("oke_channel_bounce")) {
166
+ return [...bounce.values()];
167
+ }
168
+ if (sql.includes("oke_channel_receipt")) {
169
+ return [...receipts.entries()].map(([id, body]) => ({ id, body }));
170
+ }
171
+ return [];
172
+ },
173
+ async exec(sql, params = []) {
174
+ if (sql.startsWith("CREATE")) return { changes: 0 };
175
+ if (sql.includes("INSERT INTO oke_channel_consent")) {
176
+ consent.set(pair(params), {
177
+ subject: String(params[0]),
178
+ medium: String(params[1]),
179
+ at: Number(params[2]),
180
+ });
181
+ return { changes: 1 };
182
+ }
183
+ if (sql.includes("DELETE FROM oke_channel_consent")) {
184
+ consent.delete(pair(params));
185
+ return { changes: 1 };
186
+ }
187
+ if (sql.includes("INSERT INTO oke_channel_bounce")) {
188
+ bounce.set(pair(params), {
189
+ subject: String(params[0]),
190
+ medium: String(params[1]),
191
+ at: Number(params[2]),
192
+ });
193
+ return { changes: 1 };
194
+ }
195
+ if (sql.includes("DELETE FROM oke_channel_bounce")) {
196
+ bounce.delete(pair(params));
197
+ return { changes: 1 };
198
+ }
199
+ if (sql.includes("INSERT INTO oke_channel_receipt")) {
200
+ receipts.set(String(params[0]), String(params[1]));
201
+ return { changes: 1 };
202
+ }
203
+ if (sql.includes("UPDATE oke_channel_receipt")) {
204
+ receipts.set(String(params[1]), String(params[0]));
205
+ return { changes: 1 };
206
+ }
207
+ return { changes: 0 };
208
+ },
209
+ };
210
+ }
211
+
212
+ describe("channel ledger across instances", () => {
213
+ test("consent and suppression written on A are visible to B", async () => {
214
+ const sql = memoryLedgerSql();
215
+ const a = await openPostgresChannelLedger(sql);
216
+ const b = await openPostgresChannelLedger(sql);
217
+ a.suppression.optOut("a@b.c", "email");
218
+ a.suppression.addPriorBounce("c@d.e", "sms");
219
+ a.receipts.record({
220
+ id: "r1",
221
+ template: "note",
222
+ to: "a@b.c",
223
+ medium: "email",
224
+ status: "sent",
225
+ attempts: [],
226
+ at: 1,
227
+ });
228
+ await a.flush();
229
+ await b.reload();
230
+ expect(b.consent.isOptedOut("a@b.c", "email")).toBe(true);
231
+ expect(b.suppression.isSuppressed("c@d.e", "sms")).toEqual({
232
+ suppressed: true,
233
+ reason: "prior-bounce",
234
+ });
235
+ expect(b.receipts.all()).toHaveLength(1);
236
+ expect(b.receipts.all()[0]?.id).toBe("r1");
237
+ });
238
+ });
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Postgres broadcast/live fan-out: shared table, per-instance cursor.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { signal } from "../elements/signal.ts";
7
+ import type { SignalDecl } from "../elements/signal/declare.ts";
8
+ import {
9
+ POSTGRES_SIGNAL_FANOUT_SQL,
10
+ createPostgresSignalFake,
11
+ openPostgresSignal,
12
+ postgresSignalFakeBranches,
13
+ } from "./signal-postgres.ts";
14
+
15
+ const legacyInsert = `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, parent_run_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
16
+
17
+ async function expectRejects(pending: Promise<unknown>, message: string): Promise<void> {
18
+ let err: unknown;
19
+ try {
20
+ await pending;
21
+ } catch (caught) {
22
+ err = caught;
23
+ }
24
+ if (!(err instanceof Error)) {
25
+ throw new Error("expected the statement to throw");
26
+ }
27
+ expect(err.message).toContain(message);
28
+ }
29
+
30
+ describe("postgres signal fake SQL branches", () => {
31
+ test("an unmatched statement throws", async () => {
32
+ const sql = createPostgresSignalFake();
33
+ await expectRejects(sql.query("SELECT 1"), "unsupported query");
34
+ await expectRejects(sql.exec("VACUUM oke_signal_messages"), "unsupported exec");
35
+ });
36
+
37
+ test("broadcast/live statements each hit exactly one branch", async () => {
38
+ const sql = createPostgresSignalFake({ now: () => 4_200 });
39
+ expect(postgresSignalFakeBranches(POSTGRES_SIGNAL_FANOUT_SQL.insert)).toEqual(["insert-db-at"]);
40
+ expect(postgresSignalFakeBranches(POSTGRES_SIGNAL_FANOUT_SQL.poll)).toEqual(["fanout-poll"]);
41
+ expect(postgresSignalFakeBranches(POSTGRES_SIGNAL_FANOUT_SQL.backfill)).toEqual([
42
+ "backfill-db-at",
43
+ ]);
44
+ expect(postgresSignalFakeBranches(POSTGRES_SIGNAL_FANOUT_SQL.index)).toEqual([
45
+ "index-delivery-db-at",
46
+ ]);
47
+ expect(postgresSignalFakeBranches(legacyInsert)).toEqual(["insert-message"]);
48
+
49
+ await sql.exec(legacyInsert, [
50
+ "old",
51
+ "s",
52
+ "null",
53
+ null,
54
+ "broadcast",
55
+ 0,
56
+ "[]",
57
+ 50,
58
+ 50,
59
+ "pending",
60
+ null,
61
+ null,
62
+ "[]",
63
+ null,
64
+ ]);
65
+ await sql.exec(POSTGRES_SIGNAL_FANOUT_SQL.backfill);
66
+ await sql.exec(POSTGRES_SIGNAL_FANOUT_SQL.index);
67
+ await sql.exec(POSTGRES_SIGNAL_FANOUT_SQL.insert, [
68
+ "new",
69
+ "s",
70
+ "null",
71
+ null,
72
+ "broadcast",
73
+ 0,
74
+ "[]",
75
+ 1,
76
+ 1,
77
+ "pending",
78
+ null,
79
+ null,
80
+ "[]",
81
+ null,
82
+ ]);
83
+
84
+ const rows = await sql.query(POSTGRES_SIGNAL_FANOUT_SQL.poll, [0]);
85
+ expect(rows.find((row) => row.id === "old")?.db_at).toBe(50);
86
+ const fresh = rows.find((row) => row.id === "new");
87
+ expect(fresh?.db_at).toBe(4_200);
88
+ expect(fresh?.db_now).toBe(4_200);
89
+ });
90
+ });
91
+
92
+ describe("postgres broadcast and live fan-out", () => {
93
+ test("a broadcast and a live message on A reach B", async () => {
94
+ const sql = createPostgresSignalFake();
95
+ const news = signal.broadcast("news");
96
+ const feed = signal.live("feed", { optional: true });
97
+ const signals = new Map<string, SignalDecl>([
98
+ [news.name, news],
99
+ [feed.name, feed],
100
+ ]);
101
+ const a = await openPostgresSignal({ signals, sql });
102
+ const b = await openPostgresSignal({ signals, sql });
103
+ const got: string[] = [];
104
+ await a.subscribe("news", "a", async () => {
105
+ got.push("a");
106
+ });
107
+ await b.subscribe("news", "b", async () => {
108
+ got.push("b");
109
+ });
110
+
111
+ const iterA = a.live("feed")[Symbol.asyncIterator]();
112
+ const iterB = b.live("feed")[Symbol.asyncIterator]();
113
+ const nextA = iterA.next();
114
+ const nextB = iterB.next();
115
+ await Bun.sleep(10);
116
+
117
+ await a.emit("news", { id: "1" });
118
+ await a.emit("feed", { n: 1 });
119
+ await a.drain();
120
+ await b.drain();
121
+
122
+ expect(got.slice().sort()).toEqual(["a", "b"]);
123
+ const [liveA, liveB] = await Promise.all([nextA, nextB]);
124
+ expect(liveA.value?.payload).toEqual({ n: 1 });
125
+ expect(liveB.value?.payload).toEqual({ n: 1 });
126
+ await iterA.return?.();
127
+ await iterB.return?.();
128
+ await a.close();
129
+ await b.close();
130
+ });
131
+
132
+ test("cursor starts at head so a row written before boot is not delivered", async () => {
133
+ let t = 5_000;
134
+ const sql = createPostgresSignalFake({ now: () => t });
135
+ const news = signal.broadcast("news", { optional: true });
136
+ const signals = new Map<string, SignalDecl>([[news.name, news]]);
137
+ const writer = await openPostgresSignal({ signals, sql, lagMs: 30_000 });
138
+ await writer.emit("news", { n: 1 });
139
+ await writer.close();
140
+
141
+ t = 6_000;
142
+ const reader = await openPostgresSignal({ signals, sql, lagMs: 30_000 });
143
+ const got: unknown[] = [];
144
+ await reader.subscribe("news", "b", async (message) => {
145
+ got.push(message.payload);
146
+ });
147
+ await reader.drain();
148
+ expect(got).toEqual([]);
149
+ await reader.close();
150
+ });
151
+
152
+ test("lagMs dedupes the same id across overlapping polls", async () => {
153
+ let t = 10_000;
154
+ const sql = createPostgresSignalFake({ now: () => t });
155
+ const news = signal.broadcast("news");
156
+ const signals = new Map<string, SignalDecl>([[news.name, news]]);
157
+ const bus = await openPostgresSignal({ signals, sql, lagMs: 30_000 });
158
+ const got: unknown[] = [];
159
+ await bus.subscribe("news", "a", async (message) => {
160
+ got.push(message.payload);
161
+ });
162
+ await bus.emit("news", { n: 1 });
163
+ t = 15_000;
164
+ await bus.drain();
165
+ t = 20_000;
166
+ await bus.drain();
167
+ expect(got).toEqual([{ n: 1 }]);
168
+ await bus.close();
169
+ });
170
+ });