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
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * `postgres` signal driver — transactional emit (dual-write fix).
3
3
  *
4
- * `fx.emit` inserts into the outbox on the caller's connection. Consumers
5
- * claim with `FOR UPDATE SKIP LOCKED`; wakeups use `LISTEN` / `NOTIFY`.
4
+ * `fx.emit` inserts into the outbox on the caller's connection. `once`
5
+ * consumers claim with `FOR UPDATE SKIP LOCKED`. Broadcast and live stay
6
+ * unread in the table; each instance polls `db_at` from an in-memory cursor.
7
+ * Wakeups use `LISTEN` / `NOTIFY` when the client has them; otherwise the
8
+ * boot scheduler polls `drain`.
6
9
  */
7
10
 
8
11
  import type { SignalDecl } from "../elements/signal/declare.ts";
@@ -51,6 +54,8 @@ interface MsgRow {
51
54
  lease_expires_at: number | null;
52
55
  delivered_to: string;
53
56
  parent_run_id: string | null;
57
+ /** Database clock at insert (`clock_timestamp`), not the application clock. */
58
+ db_at: number | null;
54
59
  }
55
60
 
56
61
  /** Minimal SQL + listen surface for the postgres signal driver. */
@@ -65,18 +70,19 @@ export interface PostgresSignalSql {
65
70
  begin<T>(fn: (sql: PostgresSignalSql) => Promise<T>): Promise<T>;
66
71
  /**
67
72
  * LISTEN channel; returns unsubscribe.
73
+ * Optional — Bun.SQL has no listen API. Omit it and poll {@link SignalBus.drain}.
68
74
  *
69
75
  * @param channel - Channel name
70
76
  * @param onNotify - Payload callback
71
77
  */
72
- listen(channel: string, onNotify: (payload: string) => void): Promise<() => void>;
78
+ listen?(channel: string, onNotify: (payload: string) => void): Promise<() => void>;
73
79
  /**
74
- * NOTIFY channel.
80
+ * NOTIFY channel. Optional when the client cannot notify.
75
81
  *
76
82
  * @param channel - Channel name
77
83
  * @param payload - Payload
78
84
  */
79
- notify(channel: string, payload: string): Promise<void>;
85
+ notify?(channel: string, payload: string): Promise<void>;
80
86
  close(): Promise<void>;
81
87
  }
82
88
 
@@ -85,6 +91,215 @@ const CHANNEL = "oke_signal";
85
91
  /** Claim next once-row: pending/unlocked or lease-expired, blocked by same-key inflight. */
86
92
  const CLAIM_ONCE_SQL = `SELECT * FROM oke_signal_messages WHERE signal=? AND delivery='once' AND available_at<=? AND ((status='pending' AND locked_by IS NULL) OR (status='inflight' AND lease_expires_at IS NOT NULL AND lease_expires_at<=?)) AND (ordering_key IS NULL OR NOT EXISTS (SELECT 1 FROM oke_signal_messages h WHERE h.signal=oke_signal_messages.signal AND h.ordering_key=oke_signal_messages.ordering_key AND h.id<>oke_signal_messages.id AND h.status='inflight' AND h.lease_expires_at IS NOT NULL AND h.lease_expires_at>?)) ORDER BY created_at ASC LIMIT 1 FOR UPDATE SKIP LOCKED`;
87
93
 
94
+ /** `clock_timestamp()` in milliseconds. Stamped in SQL, not from the app clock. */
95
+ const DB_CLOCK_MS_SQL = `(extract(epoch from clock_timestamp())*1000)::bigint`;
96
+
97
+ /**
98
+ * Default overlap window for postgres broadcast and live polls (ms).
99
+ * A commit that lands more than this long after its `db_at` can be missed.
100
+ */
101
+ export const POSTGRES_SIGNAL_DEFAULT_LAG_MS = 30_000;
102
+
103
+ /**
104
+ * Broadcast/live statements the in-memory fake routes to one dedicated branch.
105
+ * Inserts stamp `db_at` from `clock_timestamp()`.
106
+ */
107
+ export const POSTGRES_SIGNAL_FANOUT_SQL = {
108
+ /** Insert including `db_at` from the database clock. */
109
+ insert: `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, db_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ${DB_CLOCK_MS_SQL})`,
110
+ /** Poll `db_at >=` and read the database clock from the same statement. */
111
+ poll: `SELECT m.id, m.signal, m.payload, m.ordering_key, m.delivery, m.attempts, m.failures, m.created_at, m.available_at, m.status, m.locked_by, m.lease_expires_at, m.delivered_to, m.parent_run_id, m.db_at, ${DB_CLOCK_MS_SQL} AS db_now FROM (SELECT ${DB_CLOCK_MS_SQL} AS db_now) AS clock LEFT JOIN oke_signal_messages m ON m.delivery IN ('broadcast', 'live') AND m.db_at >= ?`,
112
+ /** Backfill `db_at` from `created_at` (not 0). */
113
+ backfill: `UPDATE oke_signal_messages SET db_at = created_at WHERE db_at IS NULL`,
114
+ /** Supports the fan-out poll. */
115
+ index: `CREATE INDEX IF NOT EXISTS oke_signal_messages_delivery_db_at ON oke_signal_messages (signal, delivery, db_at)`,
116
+ } as const;
117
+
118
+ /** Drop broadcast rows only after the overlap window has closed. */
119
+ const BROADCAST_TTL_DELETE_SQL = `DELETE FROM oke_signal_messages WHERE delivery = 'broadcast' AND db_at < ?`;
120
+
121
+ function isOnceClaim(text: string): boolean {
122
+ return (
123
+ /FOR\s+UPDATE\s+SKIP\s+LOCKED/i.test(text) &&
124
+ /oke_signal_messages/i.test(text) &&
125
+ /delivery\s*=\s*'once'/i.test(text)
126
+ );
127
+ }
128
+
129
+ function isFanoutPoll(text: string): boolean {
130
+ return /^SELECT\b/i.test(text) && /db_at\s*>=/i.test(text) && /clock_timestamp\s*\(/i.test(text);
131
+ }
132
+
133
+ function isSelectStatus(text: string): boolean {
134
+ return /^SELECT\s+\*\s+FROM\s+oke_signal_messages\s+WHERE\s+signal\s*=\s*\?\s+AND\s+status\s*=\s*\?\s*$/i.test(
135
+ text,
136
+ );
137
+ }
138
+
139
+ function isSelectPending(text: string): boolean {
140
+ return /^SELECT\s+\*\s+FROM\s+oke_signal_messages\s+WHERE\s+status\s+IN\s*\('pending',\s*'inflight'\)\s*$/i.test(
141
+ text,
142
+ );
143
+ }
144
+
145
+ function isSelectSignal(text: string): boolean {
146
+ return /^SELECT\s+\*\s+FROM\s+oke_signal_messages\s+WHERE\s+signal\s*=\s*\?\s*$/i.test(text);
147
+ }
148
+
149
+ function isSelectWrite(text: string): boolean {
150
+ return /^SELECT\s+value\s+FROM\s+oke_signal_writes\s+WHERE\s+key\s*=\s*\?\s*$/i.test(text);
151
+ }
152
+
153
+ function isSelectId(text: string): boolean {
154
+ return /^SELECT\s+\*\s+FROM\s+oke_signal_messages\s+WHERE\s+id\s*=\s*\?\s*$/i.test(text);
155
+ }
156
+
157
+ function isSelectLive(text: string): boolean {
158
+ return /^SELECT\s+\*\s+FROM\s+oke_signal_messages\s+WHERE\s+signal\s*=\s*\?\s+AND\s+delivery\s*=\s*'live'\s+ORDER\s+BY\s+created_at\s+ASC\s*$/i.test(
159
+ text,
160
+ );
161
+ }
162
+
163
+ function isCreateTable(text: string): boolean {
164
+ return /^CREATE\s+TABLE/i.test(text);
165
+ }
166
+
167
+ function isDeliveryDbAtIndex(text: string): boolean {
168
+ return /^CREATE\s+INDEX\b/i.test(text) && /oke_signal_messages_delivery_db_at/i.test(text);
169
+ }
170
+
171
+ function isCreateIndex(text: string): boolean {
172
+ return /^CREATE\s+INDEX/i.test(text) && !isDeliveryDbAtIndex(text);
173
+ }
174
+
175
+ function isBroadcastTtlDelete(text: string): boolean {
176
+ return /^DELETE\s+FROM\s+oke_signal_messages\s+WHERE\s+delivery\s*=\s*'broadcast'\s+AND\s+db_at\s*<\s*\?\s*$/i.test(
177
+ text,
178
+ );
179
+ }
180
+
181
+ function isDeleteLive(text: string): boolean {
182
+ return /^DELETE\s+FROM\s+oke_signal_messages\s+WHERE\s+id\s*=\s*\?\s+AND\s+delivery\s*=\s*'live'\s*$/i.test(
183
+ text,
184
+ );
185
+ }
186
+
187
+ function isDeleteDead(text: string): boolean {
188
+ return /^DELETE\s+FROM\s+oke_signal_messages\s+WHERE\s+id\s*=\s*\?\s+AND\s+signal\s*=\s*\?\s+AND\s+status\s*=\s*'dead'\s*$/i.test(
189
+ text,
190
+ );
191
+ }
192
+
193
+ function isInsertDbAt(text: string): boolean {
194
+ return /^INSERT\s+INTO\s+oke_signal_messages\b/i.test(text) && /clock_timestamp\s*\(/i.test(text);
195
+ }
196
+
197
+ function isInsertMessage(text: string): boolean {
198
+ return (
199
+ /^INSERT\s+INTO\s+oke_signal_messages\s*\(([^)]+)\)\s*VALUES\s*\(([^)]+)\)\s*$/i.test(text) &&
200
+ !isInsertDbAt(text)
201
+ );
202
+ }
203
+
204
+ function isInsertWrite(text: string): boolean {
205
+ return /^INSERT\s+INTO\s+oke_signal_writes\s*\(key,\s*value\)\s*VALUES\s*\(\?,\s*\?\)\s*$/i.test(
206
+ text,
207
+ );
208
+ }
209
+
210
+ function isBackfillDbAt(text: string): boolean {
211
+ return /^UPDATE\s+oke_signal_messages\s+SET\s+db_at\s*=\s*created_at\s+WHERE\s+db_at\s+IS\s+NULL\s*$/i.test(
212
+ text,
213
+ );
214
+ }
215
+
216
+ function isUpdateById(text: string): boolean {
217
+ return (
218
+ /^UPDATE\s+oke_signal_messages\s+SET\s+(.+)\s+WHERE\s+id\s*=\s*\?\s*$/i.test(text) &&
219
+ !isBackfillDbAt(text)
220
+ );
221
+ }
222
+
223
+ function isAlterAddColumn(text: string): boolean {
224
+ return /^ALTER\s+TABLE\s+oke_signal_messages\s+ADD\s+COLUMN/i.test(text);
225
+ }
226
+
227
+ const POSTGRES_SIGNAL_FAKE_BRANCHES: ReadonlyArray<{
228
+ readonly name: string;
229
+ readonly test: (text: string) => boolean;
230
+ }> = [
231
+ { name: "once-claim", test: isOnceClaim },
232
+ { name: "fanout-poll", test: isFanoutPoll },
233
+ { name: "select-status", test: isSelectStatus },
234
+ { name: "select-pending", test: isSelectPending },
235
+ { name: "select-signal", test: isSelectSignal },
236
+ { name: "select-write", test: isSelectWrite },
237
+ { name: "select-id", test: isSelectId },
238
+ { name: "select-live", test: isSelectLive },
239
+ { name: "create-table", test: isCreateTable },
240
+ { name: "index-delivery-db-at", test: isDeliveryDbAtIndex },
241
+ { name: "create-index", test: isCreateIndex },
242
+ { name: "delete-broadcast-ttl", test: isBroadcastTtlDelete },
243
+ { name: "delete-live", test: isDeleteLive },
244
+ { name: "delete-dead", test: isDeleteDead },
245
+ { name: "insert-db-at", test: isInsertDbAt },
246
+ { name: "insert-message", test: isInsertMessage },
247
+ { name: "insert-write", test: isInsertWrite },
248
+ { name: "backfill-db-at", test: isBackfillDbAt },
249
+ { name: "update-by-id", test: isUpdateById },
250
+ { name: "alter-add-column", test: isAlterAddColumn },
251
+ ];
252
+
253
+ /**
254
+ * In-memory fake branches whose predicates match `sql`.
255
+ *
256
+ * Broadcast/live statements must match exactly one branch so a broader
257
+ * regex cannot swallow them.
258
+ *
259
+ * @param sql - Statement text
260
+ */
261
+ export function postgresSignalFakeBranches(sql: string): readonly string[] {
262
+ const text = sql.trim();
263
+ const names: string[] = [];
264
+ for (const branch of POSTGRES_SIGNAL_FAKE_BRANCHES) {
265
+ if (branch.test(text)) names.push(branch.name);
266
+ }
267
+ return names;
268
+ }
269
+
270
+ function fieldText(value: unknown, fallback: string): string {
271
+ if (typeof value === "string") return value;
272
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
273
+ return fallback;
274
+ }
275
+
276
+ function msgRowFromFields(row: Record<string, unknown>): MsgRow {
277
+ const orderingKey = row.ordering_key;
278
+ const parentRunId = row.parent_run_id;
279
+ return {
280
+ id: fieldText(row.id, ""),
281
+ signal: fieldText(row.signal, ""),
282
+ payload: fieldText(row.payload, "null"),
283
+ ordering_key:
284
+ orderingKey === undefined || orderingKey === null ? null : fieldText(orderingKey, ""),
285
+ delivery: row.delivery as SignalDelivery,
286
+ attempts: Number(row.attempts ?? 0),
287
+ failures: fieldText(row.failures, "[]"),
288
+ created_at: Number(row.created_at),
289
+ available_at: Number(row.available_at),
290
+ status: (row.status as MsgRow["status"]) ?? "pending",
291
+ locked_by: (row.locked_by as string | null) ?? null,
292
+ lease_expires_at:
293
+ row.lease_expires_at === undefined || row.lease_expires_at === null
294
+ ? null
295
+ : Number(row.lease_expires_at),
296
+ delivered_to: fieldText(row.delivered_to, "[]"),
297
+ parent_run_id:
298
+ parentRunId === undefined || parentRunId === null ? null : fieldText(parentRunId, ""),
299
+ db_at: row.db_at === undefined || row.db_at === null ? null : Number(row.db_at),
300
+ };
301
+ }
302
+
88
303
  /**
89
304
  * In-memory Postgres-protocol fake with transactions, SKIP LOCKED, LISTEN/NOTIFY.
90
305
  */
@@ -95,6 +310,7 @@ export function createPostgresSignalFake(options?: {
95
310
  /** Force-kill mid-transaction (drops uncommitted state). */
96
311
  killActiveTransaction(): void;
97
312
  } {
313
+ const clock = options?.now ?? (() => Date.now());
98
314
  const listeners = new Map<string, Set<(payload: string) => void>>();
99
315
 
100
316
  type State = {
@@ -121,6 +337,7 @@ export function createPostgresSignalFake(options?: {
121
337
  ordering_key: m.ordering_key ?? null,
122
338
  lease_expires_at: m.lease_expires_at ?? null,
123
339
  parent_run_id: m.parent_run_id ?? null,
340
+ db_at: m.db_at ?? null,
124
341
  })),
125
342
  writes: new Map(snap.writes),
126
343
  };
@@ -173,11 +390,7 @@ export function createPostgresSignalFake(options?: {
173
390
 
174
391
  // Claim: SELECT … FOR UPDATE SKIP LOCKED (pending or lease-expired inflight),
175
392
  // with per-key serialization when ordering_key is set.
176
- const isOnceClaim =
177
- /FOR\s+UPDATE\s+SKIP\s+LOCKED/i.test(text) &&
178
- /oke_signal_messages/i.test(text) &&
179
- /delivery\s*=\s*'once'/i.test(text);
180
- if (isOnceClaim) {
393
+ if (isOnceClaim(text)) {
181
394
  const signal = String(params[0]);
182
395
  const t = Number(params[1]);
183
396
  const leaseCutoff = Number(params[2]);
@@ -205,55 +418,53 @@ export function createPostgresSignalFake(options?: {
205
418
  return true;
206
419
  });
207
420
  if (!row) return [];
421
+ // Hold the row before yielding so a concurrent claim skips it.
422
+ row.status = "inflight";
423
+ if (row.locked_by === null) row.locked_by = "claimed";
208
424
  return [{ ...row }];
209
425
  }
210
426
 
211
- const selMsg =
212
- /^SELECT\s+\*\s+FROM\s+oke_signal_messages\s+WHERE\s+signal\s*=\s*\?\s+AND\s+status\s*=\s*\?\s*$/i.exec(
213
- text,
427
+ // Before broader SELECTs: poll reads db_at and the database clock together.
428
+ if (isFanoutPoll(text)) {
429
+ const lower = Number(params[0]);
430
+ const dbNow = clock();
431
+ const matched = state.messages.filter(
432
+ (m) =>
433
+ (m.delivery === "broadcast" || m.delivery === "live") &&
434
+ m.db_at != null &&
435
+ m.db_at >= lower,
214
436
  );
215
- if (selMsg) {
437
+ if (matched.length === 0) return [{ db_now: dbNow }];
438
+ return matched.map((m) => ({ ...m, db_now: dbNow }));
439
+ }
440
+
441
+ if (isSelectStatus(text)) {
216
442
  return state.messages
217
443
  .filter((m) => m.signal === params[0] && m.status === params[1])
218
444
  .map((m) => ({ ...m }));
219
445
  }
220
446
 
221
- const selPending =
222
- /^SELECT\s+\*\s+FROM\s+oke_signal_messages\s+WHERE\s+status\s+IN\s*\('pending',\s*'inflight'\)\s*$/i.exec(
223
- text,
224
- );
225
- if (selPending) {
447
+ if (isSelectPending(text)) {
226
448
  return state.messages
227
449
  .filter((m) => m.status === "pending" || m.status === "inflight")
228
450
  .map((m) => ({ ...m }));
229
451
  }
230
452
 
231
- const selAll =
232
- /^SELECT\s+\*\s+FROM\s+oke_signal_messages\s+WHERE\s+signal\s*=\s*\?\s*$/i.exec(text);
233
- if (selAll) {
453
+ if (isSelectSignal(text)) {
234
454
  return state.messages.filter((m) => m.signal === params[0]).map((m) => ({ ...m }));
235
455
  }
236
456
 
237
- const selWrite =
238
- /^SELECT\s+value\s+FROM\s+oke_signal_writes\s+WHERE\s+key\s*=\s*\?\s*$/i.exec(text);
239
- if (selWrite) {
457
+ if (isSelectWrite(text)) {
240
458
  const v = state.writes.get(String(params[0]));
241
459
  if (v === undefined) return [];
242
460
  return [{ value: JSON.stringify(v) }];
243
461
  }
244
462
 
245
- const byId = /^SELECT\s+\*\s+FROM\s+oke_signal_messages\s+WHERE\s+id\s*=\s*\?\s*$/i.exec(
246
- text,
247
- );
248
- if (byId) {
463
+ if (isSelectId(text)) {
249
464
  return state.messages.filter((m) => m.id === params[0]).map((m) => ({ ...m }));
250
465
  }
251
466
 
252
- const selLive =
253
- /^SELECT\s+\*\s+FROM\s+oke_signal_messages\s+WHERE\s+signal\s*=\s*\?\s+AND\s+delivery\s*=\s*'live'\s+ORDER\s+BY\s+created_at\s+ASC\s*$/i.exec(
254
- text,
255
- );
256
- if (selLive) {
467
+ if (isSelectLive(text)) {
257
468
  return state.messages
258
469
  .filter((m) => m.signal === params[0] && m.delivery === "live")
259
470
  .map((m) => ({ ...m }));
@@ -266,14 +477,25 @@ export function createPostgresSignalFake(options?: {
266
477
  const text = sql.trim();
267
478
  const state = view();
268
479
 
269
- if (/^CREATE\s+TABLE/i.test(text)) return { changes: 0 };
270
- if (/^CREATE\s+INDEX/i.test(text)) return { changes: 0 };
480
+ if (isCreateTable(text)) return { changes: 0 };
481
+ // Dedicated index branch before the broader CREATE INDEX match.
482
+ if (isDeliveryDbAtIndex(text)) return { changes: 0 };
483
+ if (isCreateIndex(text)) return { changes: 0 };
271
484
 
272
- const delLive =
273
- /^DELETE\s+FROM\s+oke_signal_messages\s+WHERE\s+id\s*=\s*\?\s+AND\s+delivery\s*=\s*'live'\s*$/i.exec(
274
- text,
275
- );
276
- if (delLive) {
485
+ if (isBroadcastTtlDelete(text)) {
486
+ const cutoff = Number(params[0]);
487
+ let changes = 0;
488
+ for (let i = state.messages.length - 1; i >= 0; i--) {
489
+ const m = state.messages[i]!;
490
+ if (m.delivery === "broadcast" && m.db_at != null && m.db_at < cutoff) {
491
+ state.messages.splice(i, 1);
492
+ changes += 1;
493
+ }
494
+ }
495
+ return { changes };
496
+ }
497
+
498
+ if (isDeleteLive(text)) {
277
499
  let changes = 0;
278
500
  for (let i = state.messages.length - 1; i >= 0; i--) {
279
501
  const m = state.messages[i]!;
@@ -285,11 +507,7 @@ export function createPostgresSignalFake(options?: {
285
507
  return { changes };
286
508
  }
287
509
 
288
- const delDead =
289
- /^DELETE\s+FROM\s+oke_signal_messages\s+WHERE\s+id\s*=\s*\?\s+AND\s+signal\s*=\s*\?\s+AND\s+status\s*=\s*'dead'\s*$/i.exec(
290
- text,
291
- );
292
- if (delDead) {
510
+ if (isDeleteDead(text)) {
293
511
  let changes = 0;
294
512
  for (let i = state.messages.length - 1; i >= 0; i--) {
295
513
  const m = state.messages[i]!;
@@ -301,54 +519,60 @@ export function createPostgresSignalFake(options?: {
301
519
  return { changes };
302
520
  }
303
521
 
304
- const insertMsg =
305
- /^INSERT\s+INTO\s+oke_signal_messages\s*\(([^)]+)\)\s*VALUES\s*\(([^)]+)\)\s*$/i.exec(text);
522
+ // Before the generic INSERT: `db_at` is `clock_timestamp()`, not a placeholder.
523
+ if (isInsertDbAt(text)) {
524
+ const head =
525
+ /^INSERT\s+INTO\s+oke_signal_messages\s*\(([^)]+)\)\s*VALUES\s*\((.*)\)\s*$/i.exec(text);
526
+ if (!head) throw new Error(`postgres signal fake: unsupported exec: ${sql}`);
527
+ const cols = head[1]!.split(",").map((c) => c.trim());
528
+ const fields: Record<string, unknown> = {};
529
+ let pi = 0;
530
+ for (const col of cols) {
531
+ if (col === "db_at") {
532
+ fields.db_at = clock();
533
+ continue;
534
+ }
535
+ fields[col] = params[pi++];
536
+ }
537
+ state.messages.push(msgRowFromFields(fields));
538
+ return { changes: 1 };
539
+ }
540
+
541
+ const insertMsg = isInsertMessage(text)
542
+ ? /^INSERT\s+INTO\s+oke_signal_messages\s*\(([^)]+)\)\s*VALUES\s*\(([^)]+)\)\s*$/i.exec(
543
+ text,
544
+ )
545
+ : null;
306
546
  if (insertMsg) {
307
547
  const cols = insertMsg[1]!.split(",").map((c) => c.trim());
308
548
  const row: Record<string, unknown> = {};
309
549
  cols.forEach((c, i) => {
310
550
  row[c] = params[i];
311
551
  });
312
- state.messages.push({
313
- id: String(row.id),
314
- signal: String(row.signal),
315
- payload: String(row.payload),
316
- ordering_key:
317
- row.ordering_key === undefined || row.ordering_key === null
318
- ? null
319
- : String(row.ordering_key),
320
- delivery: row.delivery as SignalDelivery,
321
- attempts: Number(row.attempts ?? 0),
322
- failures: String(row.failures ?? "[]"),
323
- created_at: Number(row.created_at),
324
- available_at: Number(row.available_at),
325
- status: (row.status as MsgRow["status"]) ?? "pending",
326
- locked_by: (row.locked_by as string | null) ?? null,
327
- lease_expires_at:
328
- row.lease_expires_at === undefined || row.lease_expires_at === null
329
- ? null
330
- : Number(row.lease_expires_at),
331
- delivered_to: String(row.delivered_to ?? "[]"),
332
- parent_run_id:
333
- row.parent_run_id === undefined || row.parent_run_id === null
334
- ? null
335
- : String(row.parent_run_id),
336
- });
552
+ state.messages.push(msgRowFromFields(row));
337
553
  return { changes: 1 };
338
554
  }
339
555
 
340
- const insertWrite =
341
- /^INSERT\s+INTO\s+oke_signal_writes\s*\(key,\s*value\)\s*VALUES\s*\(\?,\s*\?\)\s*$/i.exec(
342
- text,
343
- );
344
- if (insertWrite) {
556
+ if (isInsertWrite(text)) {
345
557
  state.writes.set(String(params[0]), JSON.parse(String(params[1])));
346
558
  return { changes: 1 };
347
559
  }
348
560
 
349
- const upd = /^UPDATE\s+oke_signal_messages\s+SET\s+(.+)\s+WHERE\s+id\s*=\s*\?\s*$/i.exec(
350
- text,
351
- );
561
+ // Before the generic UPDATE: backfill `db_at` from `created_at`.
562
+ if (isBackfillDbAt(text)) {
563
+ let changes = 0;
564
+ for (const m of state.messages) {
565
+ if (m.db_at == null) {
566
+ m.db_at = m.created_at;
567
+ changes += 1;
568
+ }
569
+ }
570
+ return { changes };
571
+ }
572
+
573
+ const upd = isUpdateById(text)
574
+ ? /^UPDATE\s+oke_signal_messages\s+SET\s+(.+)\s+WHERE\s+id\s*=\s*\?\s*$/i.exec(text)
575
+ : null;
352
576
  if (upd) {
353
577
  const id = String(params[params.length - 1]);
354
578
  const row = state.messages.find((m) => m.id === id);
@@ -372,7 +596,7 @@ export function createPostgresSignalFake(options?: {
372
596
  return { changes: 1 };
373
597
  }
374
598
 
375
- if (/^ALTER\s+TABLE\s+oke_signal_messages\s+ADD\s+COLUMN/i.test(text)) {
599
+ if (isAlterAddColumn(text)) {
376
600
  return { changes: 0 };
377
601
  }
378
602
 
@@ -439,7 +663,8 @@ async function ensureSchema(sql: PostgresSignalSql): Promise<void> {
439
663
  locked_by TEXT,
440
664
  lease_expires_at BIGINT,
441
665
  delivered_to TEXT,
442
- parent_run_id TEXT
666
+ parent_run_id TEXT,
667
+ db_at BIGINT
443
668
  )`);
444
669
  // Existing tables created before leases / keys: add columns in place.
445
670
  try {
@@ -470,6 +695,21 @@ async function ensureSchema(sql: PostgresSignalSql): Promise<void> {
470
695
  } catch {
471
696
  /* fake / older engines without IF NOT EXISTS — ignore */
472
697
  }
698
+ try {
699
+ await sql.exec(`ALTER TABLE oke_signal_messages ADD COLUMN IF NOT EXISTS db_at BIGINT`);
700
+ } catch {
701
+ /* fake / older engines without IF NOT EXISTS — ignore */
702
+ }
703
+ try {
704
+ await sql.exec(POSTGRES_SIGNAL_FANOUT_SQL.backfill);
705
+ } catch {
706
+ /* fake / older engines — ignore */
707
+ }
708
+ try {
709
+ await sql.exec(POSTGRES_SIGNAL_FANOUT_SQL.index);
710
+ } catch {
711
+ /* fake / older engines without IF NOT EXISTS — ignore */
712
+ }
473
713
  }
474
714
 
475
715
  function rowToMessage(row: Record<string, unknown>): SignalMessage {
@@ -496,15 +736,31 @@ function rowToMessage(row: Record<string, unknown>): SignalMessage {
496
736
  };
497
737
  }
498
738
 
739
+ /**
740
+ * Options for {@link openPostgresSignal}.
741
+ */
742
+ export interface PostgresSignalOpenOptions extends SignalOpenOptions {
743
+ /**
744
+ * Overlap window (ms) for broadcast and live polls.
745
+ * Rows with `db_at >= cursor - lagMs` are re-read and deduped by id.
746
+ * Defaults to {@link POSTGRES_SIGNAL_DEFAULT_LAG_MS}.
747
+ */
748
+ readonly lagMs?: number;
749
+ }
750
+
499
751
  /**
500
752
  * Open a postgres-backed signal bus.
501
753
  *
502
- * @param options - Declarations / sql fake / clock
754
+ * @param options - Declarations / sql fake / clock / lag window
503
755
  */
504
- export async function openPostgresSignal(options: SignalOpenOptions): Promise<SignalBus> {
756
+ export async function openPostgresSignal(options: PostgresSignalOpenOptions): Promise<SignalBus> {
505
757
  const now = options.now ?? (() => Date.now());
506
758
  const signals = options.signals;
507
759
  const leaseMs = options.leaseMs ?? SIGNAL_DEFAULT_LEASE_MS;
760
+ const lagMs = options.lagMs ?? POSTGRES_SIGNAL_DEFAULT_LAG_MS;
761
+ // Broadcast retention stays strictly longer than the overlap window.
762
+ // Live retention stays on pruneLive (default unbounded).
763
+ const broadcastTtlMs = lagMs + 1_000;
508
764
  const sql =
509
765
  (options.sql as PostgresSignalSql | undefined) ??
510
766
  createPostgresSignalFake({
@@ -517,6 +773,37 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
517
773
  }
518
774
  await ensureSchema(sql);
519
775
 
776
+ // Best-effort within lagMs. A transaction that commits more than lagMs
777
+ // after its db_at can be missed. Not gap-free ordering. No persisted cursor table.
778
+ let fanoutCursor = 0;
779
+ const fanoutSeen = new Map<string, number>();
780
+
781
+ async function readFanout(lower: number): Promise<{
782
+ rows: Record<string, unknown>[];
783
+ dbNow: number;
784
+ }> {
785
+ const raw = await sql.query(POSTGRES_SIGNAL_FANOUT_SQL.poll, [lower]);
786
+ let dbNow = Number.NaN;
787
+ const rows: Record<string, unknown>[] = [];
788
+ for (const row of raw) {
789
+ if (Number.isNaN(dbNow)) dbNow = Number(row.db_now);
790
+ if (row.id == null) continue;
791
+ rows.push(row);
792
+ }
793
+ if (!Number.isFinite(dbNow)) {
794
+ throw new Error("postgres signal: fanout poll did not return db_now");
795
+ }
796
+ return { rows, dbNow };
797
+ }
798
+
799
+ // Start at the current database time so messages committed while this
800
+ // instance was offline are already behind the cursor.
801
+ const bootSnap = await readFanout(Number.MIN_SAFE_INTEGER);
802
+ fanoutCursor = bootSnap.dbNow;
803
+ for (const row of bootSnap.rows) {
804
+ fanoutSeen.set(String(row.id), bootSnap.dbNow);
805
+ }
806
+
520
807
  const consumers: Array<{
521
808
  signal: string;
522
809
  subscriberId: string;
@@ -565,9 +852,18 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
565
852
  return n;
566
853
  }
567
854
 
568
- unlisten = await sql.listen(CHANNEL, () => {
569
- void drainQuiet();
570
- });
855
+ if (typeof sql.listen === "function") {
856
+ unlisten = await sql.listen(CHANNEL, () => {
857
+ void drainQuiet();
858
+ });
859
+ } else if ((options.pollMs ?? 0) > 0) {
860
+ const timer = setInterval(() => {
861
+ void drainQuiet();
862
+ }, options.pollMs);
863
+ unlisten = () => {
864
+ clearInterval(timer);
865
+ };
866
+ }
571
867
 
572
868
  function requireDecl(name: string): SignalDecl {
573
869
  const decl = signals.get(name);
@@ -605,25 +901,22 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
605
901
  typeof options?.parentRunId === "string" && options.parentRunId.length > 0
606
902
  ? options.parentRunId
607
903
  : null;
608
- await tx.exec(
609
- `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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
610
- [
611
- okid(),
612
- signal,
613
- JSON.stringify(payload ?? null),
614
- orderingKey,
615
- decl.delivery,
616
- 0,
617
- "[]",
618
- t,
619
- t,
620
- "pending",
621
- null,
622
- null,
623
- "[]",
624
- parentRunId,
625
- ],
626
- );
904
+ await tx.exec(POSTGRES_SIGNAL_FANOUT_SQL.insert, [
905
+ okid(),
906
+ signal,
907
+ JSON.stringify(payload ?? null),
908
+ orderingKey,
909
+ decl.delivery,
910
+ 0,
911
+ "[]",
912
+ t,
913
+ t,
914
+ "pending",
915
+ null,
916
+ null,
917
+ "[]",
918
+ parentRunId,
919
+ ]);
627
920
  }
628
921
 
629
922
  async function begin(): Promise<SignalTransaction> {
@@ -663,7 +956,9 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
663
956
  await insertEmit(tx, e.signal, e.payload, e.options);
664
957
  }
665
958
  });
666
- await sql.notify(CHANNEL, "commit");
959
+ if (typeof sql.notify === "function") {
960
+ await sql.notify(CHANNEL, "commit");
961
+ }
667
962
  },
668
963
  async rollback() {
669
964
  if (finished) throw new Error("transaction finished");
@@ -827,107 +1122,88 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
827
1122
  for (const consumer of consumers) {
828
1123
  const decl = signals.get(consumer.signal);
829
1124
  if (decl?.delivery !== "once") continue;
830
- const claimed = await sql.query(CLAIM_ONCE_SQL, [consumer.signal, t, t, t]);
831
- if (claimed[0]) {
832
- progress = true;
833
- const row = claimed[0];
834
- const attempts = Number(row.attempts) + 1;
1125
+ const row = await sql.begin(async (tx) => {
1126
+ const claimed = await tx.query(CLAIM_ONCE_SQL, [consumer.signal, t, t, t]);
1127
+ const hit = claimed[0];
1128
+ if (!hit) return undefined;
1129
+ const attempts = Number(hit.attempts) + 1;
835
1130
  const leaseExpiresAt = t + leaseMs;
836
- await sql.exec(
1131
+ await tx.exec(
837
1132
  `UPDATE oke_signal_messages SET status = 'inflight', locked_by = ?, attempts = ?, lease_expires_at = ? WHERE id = ?`,
838
- [consumer.subscriberId, attempts, leaseExpiresAt, row.id],
1133
+ [consumer.subscriberId, attempts, leaseExpiresAt, hit.id],
839
1134
  );
840
- row.locked_by = consumer.subscriberId;
841
- row.attempts = attempts;
842
- row.status = "inflight";
843
- row.lease_expires_at = leaseExpiresAt;
1135
+ hit.locked_by = consumer.subscriberId;
1136
+ hit.attempts = attempts;
1137
+ hit.status = "inflight";
1138
+ hit.lease_expires_at = leaseExpiresAt;
1139
+ return hit;
1140
+ });
1141
+ if (row) {
1142
+ progress = true;
844
1143
  await deliverOnce(row, consumer);
845
1144
  }
846
1145
  }
847
1146
  return progress;
848
1147
  }
849
1148
 
850
- async function drainBroadcastPass(): Promise<boolean> {
1149
+ async function drainFanoutPass(): Promise<boolean> {
851
1150
  let progress = false;
852
- for (const consumer of consumers) {
853
- const decl = signals.get(consumer.signal);
854
- if (decl?.delivery !== "broadcast") continue;
855
- const rows = await sql.query(`SELECT * FROM oke_signal_messages WHERE signal = ?`, [
856
- consumer.signal,
857
- ]);
858
- for (const row of rows) {
859
- if (row.status === "dead" || row.status === "delivered") continue;
860
- if (row.delivery !== "broadcast") continue;
861
- // Re-read delivered_to so concurrent drain (NOTIFY) cannot double-deliver.
862
- const fresh = await sql.query(`SELECT * FROM oke_signal_messages WHERE signal = ?`, [
863
- consumer.signal,
864
- ]);
865
- const current = fresh.find((r) => r.id === row.id);
866
- if (!current || current.status === "delivered") continue;
867
- const deliveredTo = new Set(JSON.parse(String(current.delivered_to ?? "[]")) as string[]);
868
- if (deliveredTo.has(consumer.subscriberId)) continue;
869
- // Claim this subscriber slot before invoking the handler.
870
- deliveredTo.add(consumer.subscriberId);
871
- const subs = consumers.filter((c) => c.signal === consumer.signal);
872
- const status = subs.every((s) => deliveredTo.has(s.subscriberId)) ? "delivered" : "pending";
873
- await sql.exec(
874
- `UPDATE oke_signal_messages SET delivered_to = ?, status = ?, attempts = ? WHERE id = ?`,
875
- [JSON.stringify([...deliveredTo]), status, Number(current.attempts) + 1, row.id],
876
- );
1151
+ const snap = await readFanout(fanoutCursor - lagMs);
1152
+ const liveSignals = new Set<string>();
1153
+ for (const row of snap.rows) {
1154
+ const id = String(row.id);
1155
+ if (fanoutSeen.has(id)) continue;
1156
+ const delivery = String(row.delivery);
1157
+ if (delivery === "broadcast") {
1158
+ const name = String(row.signal);
1159
+ const subs = consumers.filter((c) => c.signal === name);
1160
+ if (subs.length === 0) continue;
1161
+ fanoutSeen.set(id, snap.dbNow);
877
1162
  progress = true;
878
- const msg = rowToMessage(current);
879
- try {
880
- await consumer.handler(msg);
881
- noteDelivered();
882
- } catch (err) {
883
- const failures = [...msg.failures, failureFromError(err, Number(current.attempts) + 1)];
884
- const key = `${consumer.signal}::${consumer.subscriberId}`;
885
- subscriberErrors.set(key, (subscriberErrors.get(key) ?? 0) + 1);
886
- await sql.exec(`UPDATE oke_signal_messages SET failures = ? WHERE id = ?`, [
887
- JSON.stringify(failures),
888
- row.id,
889
- ]);
1163
+ const msg = rowToMessage(row);
1164
+ for (const consumer of subs) {
1165
+ try {
1166
+ await consumer.handler(msg);
1167
+ noteDelivered();
1168
+ } catch (err) {
1169
+ const failures = [...msg.failures, failureFromError(err, msg.attempts + 1)];
1170
+ const key = `${name}::${consumer.subscriberId}`;
1171
+ subscriberErrors.set(key, (subscriberErrors.get(key) ?? 0) + 1);
1172
+ await sql.exec(`UPDATE oke_signal_messages SET failures = ? WHERE id = ?`, [
1173
+ JSON.stringify(failures),
1174
+ id,
1175
+ ]);
1176
+ }
890
1177
  }
1178
+ } else if (delivery === "live") {
1179
+ fanoutSeen.set(id, snap.dbNow);
1180
+ progress = true;
1181
+ const name = String(row.signal);
1182
+ liveSignals.add(name);
1183
+ const handlers = liveHandlers.get(name);
1184
+ const payload = JSON.parse(String(row.payload));
1185
+ if (handlers) {
1186
+ const event: LiveEvent = { id, payload };
1187
+ for (const h of handlers) await h(event);
1188
+ }
1189
+ let list = recentLive.get(name);
1190
+ if (!list) {
1191
+ list = [];
1192
+ recentLive.set(name, list);
1193
+ }
1194
+ list.push(payload);
1195
+ while (list.length > 50) list.shift();
1196
+ noteDelivered();
891
1197
  }
892
1198
  }
893
- return progress;
894
- }
895
-
896
- async function drainLivePass(): Promise<boolean> {
897
- let progress = false;
898
- const pending = await sql.query(
899
- `SELECT * FROM oke_signal_messages WHERE status IN ('pending', 'inflight')`,
900
- );
901
- for (const row of pending) {
902
- if (row.delivery !== "live") continue;
903
- progress = true;
904
- const handlers = liveHandlers.get(String(row.signal));
905
- const payload = JSON.parse(String(row.payload));
906
- if (handlers) {
907
- const event: LiveEvent = { id: String(row.id), payload };
908
- for (const h of handlers) await h(event);
909
- }
910
- const sig = String(row.signal);
911
- let list = recentLive.get(sig);
912
- if (!list) {
913
- list = [];
914
- recentLive.set(sig, list);
915
- }
916
- list.push(payload);
917
- while (list.length > 50) list.shift();
918
- noteDelivered();
919
- await sql.exec(
920
- `UPDATE oke_signal_messages SET status = 'delivered', locked_by = NULL, lease_expires_at = NULL WHERE id = ?`,
921
- [row.id],
922
- );
1199
+ const pruneBefore = snap.dbNow - lagMs;
1200
+ for (const [id, seenAt] of fanoutSeen) {
1201
+ if (seenAt < pruneBefore) fanoutSeen.delete(id);
923
1202
  }
924
- const pruned = new Set<string>();
925
- for (const row of pending) {
926
- if (row.delivery !== "live") continue;
927
- const sig = String(row.signal);
928
- if (pruned.has(sig)) continue;
929
- pruned.add(sig);
930
- await pruneLive(sig);
1203
+ fanoutCursor = snap.dbNow;
1204
+ await sql.exec(BROADCAST_TTL_DELETE_SQL, [snap.dbNow - broadcastTtlMs]);
1205
+ for (const name of liveSignals) {
1206
+ await pruneLive(name);
931
1207
  }
932
1208
  return progress;
933
1209
  }
@@ -940,9 +1216,8 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
940
1216
  draining = (async () => {
941
1217
  for (let i = 0; i < 1000; i++) {
942
1218
  const a = await drainOncePass();
943
- const b = await drainBroadcastPass();
944
- const c = await drainLivePass();
945
- if (!a && !b && !c) break;
1219
+ const b = await drainFanoutPass();
1220
+ if (!a && !b) break;
946
1221
  }
947
1222
  })();
948
1223
  try {
@@ -992,22 +1267,20 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
992
1267
  }
993
1268
  }
994
1269
  const subs = consumers.filter((c) => c.signal === name);
995
- const subscribers = await Promise.all(
996
- subs.map(async (c) => {
997
- let lag = 0;
998
- for (const row of rows) {
999
- if (row.delivery !== "broadcast") continue;
1000
- if (row.status === "dead" || row.status === "delivered") continue;
1001
- const deliveredTo = new Set(JSON.parse(String(row.delivered_to ?? "[]")) as string[]);
1002
- if (!deliveredTo.has(c.subscriberId)) lag += 1;
1003
- }
1004
- return {
1005
- id: c.subscriberId,
1006
- lag,
1007
- errorCount: subscriberErrors.get(`${name}::${c.subscriberId}`) ?? 0,
1008
- };
1009
- }),
1010
- );
1270
+ const subscribers = subs.map((c) => {
1271
+ let lag = 0;
1272
+ for (const row of rows) {
1273
+ if (row.delivery !== "broadcast") continue;
1274
+ if (row.status === "dead") continue;
1275
+ if (fanoutSeen.has(String(row.id))) continue;
1276
+ lag += 1;
1277
+ }
1278
+ return {
1279
+ id: c.subscriberId,
1280
+ lag,
1281
+ errorCount: subscriberErrors.get(`${name}::${c.subscriberId}`) ?? 0,
1282
+ };
1283
+ });
1011
1284
  return {
1012
1285
  signal: name,
1013
1286
  delivery: decl.delivery,