okengine 0.23.1 → 0.23.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/ai/mcp.mdx +3 -3
  3. package/site/content/docs/elements/signal/broadcast.mdx +4 -2
  4. package/site/content/docs/reference/configuration.mdx +16 -0
  5. package/site/content/docs/reference/errors.mdx +31 -19
  6. package/src/compiler/aot.test.ts +3 -2
  7. package/src/compiler/aot.ts +45 -1
  8. package/src/compiler/dynamic.ts +1 -1
  9. package/src/compiler/http-parse.test.ts +101 -0
  10. package/src/compiler/http-parse.ts +188 -9
  11. package/src/compiler/interpret.ts +25 -2
  12. package/src/console/ui-next/dist/assets/{access-page-C_N4qvai.js → access-page-zBMhZnFm.js} +1 -1
  13. package/src/console/ui-next/dist/assets/{agent-disclosure-Cq1TxniW.js → agent-disclosure-CBj9f7Ju.js} +1 -1
  14. package/src/console/ui-next/dist/assets/{cache-glyph-BaI4uxZz.js → cache-glyph-D3MY49h3.js} +1 -1
  15. package/src/console/ui-next/dist/assets/{call-pii-button-DNyK7IIY.js → call-pii-button-L9JxdBhp.js} +1 -1
  16. package/src/console/ui-next/dist/assets/{collapsible-CRhBJMZR.js → collapsible-BvFaX-Zt.js} +1 -1
  17. package/src/console/ui-next/dist/assets/{decisions-page-D9twj8Xy.js → decisions-page-DgB76m-X.js} +1 -1
  18. package/src/console/ui-next/dist/assets/{duration-tone-CsHWlVPU.js → duration-tone-l1DSJ2Vz.js} +1 -1
  19. package/src/console/ui-next/dist/assets/{flows-page-ZaTJAhR9.js → flows-page-Bb9t1lu6.js} +1 -1
  20. package/src/console/ui-next/dist/assets/{highlighted-json-DQ-EMAfz.js → highlighted-json-DMvsi3Ti.js} +1 -1
  21. package/src/console/ui-next/dist/assets/{http-method-Cn_Fl79s.js → http-method-BwhJBTcQ.js} +1 -1
  22. package/src/console/ui-next/dist/assets/{index-CmRFeWdT.js → index-RhV2jT_7.js} +3 -3
  23. package/src/console/ui-next/dist/assets/{observability-page-Cn6R_dXz.js → observability-page-BRfUILBq.js} +1 -1
  24. package/src/console/ui-next/dist/assets/{replica-lag-Bx6EZhSe.js → replica-lag-BsceAzIG.js} +1 -1
  25. package/src/console/ui-next/dist/assets/{request-meta-DmdiUrfh.js → request-meta-C6lTyCXp.js} +1 -1
  26. package/src/console/ui-next/dist/assets/{store-page-8P6vGXnn.js → store-page-YmE806bJ.js} +1 -1
  27. package/src/console/ui-next/dist/assets/{trace-detail-sheet-BfGvpFSv.js → trace-detail-sheet-Di4irS49.js} +1 -1
  28. package/src/console/ui-next/dist/assets/{tree-expand-toggle-CWdUHbc0.js → tree-expand-toggle-DYeL6Rye.js} +1 -1
  29. package/src/console/ui-next/dist/assets/{units-page-CZOFZzay.js → units-page-07agasRA.js} +1 -1
  30. package/src/console/ui-next/dist/assets/{vault-page-ki_ishmX.js → vault-page-CHBck4n_.js} +1 -1
  31. package/src/console/ui-next/dist/index.html +1 -1
  32. package/src/console/ui-next/seed-parked-approval.ts +5 -1
  33. package/src/drivers/journal-postgres.test.ts +84 -1
  34. package/src/drivers/journal-postgres.ts +282 -32
  35. package/src/drivers/postgres.test.ts +60 -0
  36. package/src/drivers/postgres.ts +79 -5
  37. package/src/drivers/signal-compete.test.ts +6 -2
  38. package/src/drivers/signal-postgres.test.ts +170 -0
  39. package/src/drivers/signal-postgres.ts +449 -196
  40. package/src/drivers/signal-redis.test.ts +224 -0
  41. package/src/drivers/signal-redis.ts +835 -150
  42. package/src/elements/ai/approval.test.ts +15 -3
  43. package/src/elements/ai/approval.ts +15 -9
  44. package/src/elements/channel/runtime.ts +31 -4
  45. package/src/elements/channel/sql-ledger.test.ts +404 -0
  46. package/src/elements/channel/sql-ledger.ts +177 -33
  47. package/src/elements/clock/chaos-child.ts +12 -2
  48. package/src/elements/clock/durable.ts +11 -0
  49. package/src/elements/store/cache-bus.test.ts +121 -0
  50. package/src/elements/store/cache-bus.ts +278 -0
  51. package/src/elements/store/cache.test.ts +59 -0
  52. package/src/elements/store/cache.ts +157 -12
  53. package/src/elements/store/runtime.ts +44 -2
  54. package/src/elements/store/sql-errors.test.ts +131 -0
  55. package/src/elements/store/sql-errors.ts +39 -16
  56. package/src/elements/store/sql-nested-tx.test.ts +344 -0
  57. package/src/elements/store/sql-session.ts +183 -3
  58. package/src/kernel/app.ts +176 -28
  59. package/src/kernel/auto-registry.test.ts +5 -1
  60. package/src/kernel/boot-bind/signal.ts +13 -2
  61. package/src/kernel/boot-bind/store.ts +16 -1
  62. package/src/kernel/boot.test.ts +2 -2
  63. package/src/kernel/boot.ts +41 -11
  64. package/src/kernel/boundary-contract.ts +7 -0
  65. package/src/kernel/builtin-errors.ts +2 -0
  66. package/src/kernel/call.test.ts +1 -0
  67. package/src/kernel/fx-sql-handle.ts +7 -1
  68. package/src/kernel/horizontal-child.ts +54 -1
  69. package/src/kernel/horizontal.integration.test.ts +131 -7
  70. package/src/kernel/idempotency.test.ts +5 -6
  71. package/src/kernel/journal-boot.test.ts +83 -6
  72. package/src/kernel/journal.test.ts +44 -0
  73. package/src/kernel/journal.ts +171 -26
  74. package/src/mcp/authorization.ts +5 -11
  75. package/src/mcp/confirmation.ts +203 -38
  76. package/src/mcp/docs-server.ts +7 -2
  77. package/src/mcp/index.ts +4 -0
  78. package/src/mcp/mcp.test.ts +321 -33
  79. package/src/mcp/server.ts +8 -3
  80. package/src/mcp/session.ts +7 -0
  81. package/src/mcp/tools.ts +68 -19
  82. package/src/runtime/bun.ts +5 -0
  83. package/src/runtime/types.ts +5 -0
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Reads are synchronous against a cache. Writes hit SQL and the cache.
5
5
  * Call {@link PostgresChannelLedger.reload} on another instance to see them.
6
+ * Receipt reloads merge rows touched since the last read and keep the rest.
6
7
  */
7
8
 
8
9
  import type { ChannelMedium } from "../../manifest/types.ts";
@@ -28,20 +29,42 @@ export interface ChannelLedgerSql {
28
29
  exec(sql: string, params?: readonly unknown[]): Promise<{ changes: number }>;
29
30
  }
30
31
 
32
+ /**
33
+ * Durable receipt read used when the in-memory cache misses.
34
+ */
35
+ export interface ChannelReceiptLookup {
36
+ /**
37
+ * Load one receipt by receipt id or provider message id.
38
+ *
39
+ * @param messageId - Receipt id or provider message id
40
+ */
41
+ lookup(messageId: string): Promise<DeliveryReceipt | undefined>;
42
+ }
43
+
31
44
  /** Shared channel ledger (consent + suppression + receipts). */
32
45
  export interface PostgresChannelLedger {
33
46
  readonly consent: ConsentStore;
34
47
  readonly suppression: SuppressionStore;
35
- readonly receipts: ReceiptLedger;
48
+ readonly receipts: ReceiptLedger & ChannelReceiptLookup;
36
49
  /** Wait for queued writes. */
37
50
  flush(): Promise<void>;
38
- /** Wait for queued writes, then replace the cache from SQL. */
51
+ /**
52
+ * Wait for queued writes, then refresh the cache from SQL.
53
+ * Receipts merge by id. Rows absent from the delta stay cached.
54
+ */
39
55
  reload(): Promise<void>;
40
56
  }
41
57
 
42
58
  const CONSENT = "oke_channel_consent";
43
59
  const BOUNCE = "oke_channel_bounce";
44
60
  const RECEIPT = "oke_channel_receipt";
61
+ /** Postgres `clock_timestamp()` in milliseconds. Writes use the database clock. */
62
+ const DB_NOW_MS = "(extract(epoch from clock_timestamp())*1000)::bigint";
63
+ /**
64
+ * Reload overlap. A commit that lands while a read is in flight still
65
+ * appears on the next pass because the cursor moves backward by this much.
66
+ */
67
+ const RECEIPT_RELOAD_LAG_MS = 30_000;
45
68
 
46
69
  /**
47
70
  * Open the ledger tables and load the current rows.
@@ -58,10 +81,19 @@ export async function openPostgresChannelLedger(
58
81
  `CREATE TABLE IF NOT EXISTS ${BOUNCE} (subject TEXT NOT NULL, medium TEXT NOT NULL, at BIGINT NOT NULL, PRIMARY KEY (subject, medium))`,
59
82
  );
60
83
  await sql.exec(`CREATE TABLE IF NOT EXISTS ${RECEIPT} (id TEXT PRIMARY KEY, body TEXT NOT NULL)`);
84
+ await sql.exec(
85
+ `ALTER TABLE ${RECEIPT} ADD COLUMN IF NOT EXISTS updated_at BIGINT NOT NULL DEFAULT 0`,
86
+ );
87
+ await sql.exec(`ALTER TABLE ${RECEIPT} ADD COLUMN IF NOT EXISTS message_id TEXT`);
88
+ await sql.exec(
89
+ `CREATE INDEX IF NOT EXISTS oke_channel_receipt_message_id_idx ON ${RECEIPT} (message_id)`,
90
+ );
91
+ await sql.exec(
92
+ `CREATE INDEX IF NOT EXISTS oke_channel_receipt_updated_at_idx ON ${RECEIPT} (updated_at)`,
93
+ );
61
94
 
62
95
  const consents: OptOut[] = [];
63
96
  const bounces: OptOut[] = [];
64
- const receipts: DeliveryReceipt[] = [];
65
97
  let chain: Promise<void> = Promise.resolve();
66
98
 
67
99
  function enqueue(work: () => Promise<void>): void {
@@ -176,50 +208,119 @@ export async function openPostgresChannelLedger(
176
208
  },
177
209
  };
178
210
 
179
- const receiptLedger: ReceiptLedger = {
180
- record(receipt) {
181
- receipts.push(receipt);
211
+ const byId = new Map<string, DeliveryReceipt>();
212
+ const byMessage = new Map<string, string>();
213
+ /** `updated_at` cursor. Starts at 0 so legacy rows (`updated_at = 0`) load. */
214
+ let cursor = 0;
215
+ let reloading: Promise<void> | undefined;
216
+ let messageIdBackfill: Promise<void> | undefined;
217
+
218
+ function rememberReceipt(receipt: DeliveryReceipt): void {
219
+ const prev = byId.get(receipt.id);
220
+ if (prev?.messageId && prev.messageId !== receipt.messageId) {
221
+ if (byMessage.get(prev.messageId) === receipt.id) byMessage.delete(prev.messageId);
222
+ }
223
+ byId.set(receipt.id, receipt);
224
+ byMessage.set(receipt.id, receipt.id);
225
+ if (receipt.messageId) byMessage.set(receipt.messageId, receipt.id);
226
+ }
227
+
228
+ function findByMessageId(messageId: string): DeliveryReceipt | undefined {
229
+ const id = byMessage.get(messageId);
230
+ if (id === undefined) return undefined;
231
+ return byId.get(id);
232
+ }
233
+
234
+ function parseReceipt(body: unknown): DeliveryReceipt | undefined {
235
+ if (typeof body !== "string") return undefined;
236
+ let parsed: unknown;
237
+ try {
238
+ parsed = JSON.parse(body);
239
+ } catch {
240
+ return undefined;
241
+ }
242
+ if (
243
+ !parsed ||
244
+ typeof parsed !== "object" ||
245
+ !("id" in parsed) ||
246
+ typeof parsed.id !== "string"
247
+ ) {
248
+ return undefined;
249
+ }
250
+ return parsed as DeliveryReceipt;
251
+ }
252
+
253
+ function readMillis(value: unknown): number | undefined {
254
+ if (typeof value === "number" && Number.isFinite(value)) return value;
255
+ if (typeof value === "bigint") return Number(value);
256
+ if (typeof value === "string" && value !== "") {
257
+ const parsed = Number(value);
258
+ if (Number.isFinite(parsed)) return parsed;
259
+ }
260
+ return undefined;
261
+ }
262
+
263
+ const receiptLedger = {
264
+ record(receipt: DeliveryReceipt) {
265
+ rememberReceipt(receipt);
182
266
  enqueue(() =>
183
267
  sql
184
- .exec(`INSERT INTO ${RECEIPT} (id, body) VALUES (?, ?)`, [
185
- receipt.id,
186
- JSON.stringify(receipt),
187
- ])
268
+ .exec(
269
+ `INSERT INTO ${RECEIPT} (id, body, message_id, updated_at) VALUES (?, ?, ?, ${DB_NOW_MS})`,
270
+ [receipt.id, JSON.stringify(receipt), receipt.messageId ?? null],
271
+ )
188
272
  .then(() => undefined),
189
273
  );
190
274
  },
191
275
  all() {
192
- return [...receipts];
276
+ return [...byId.values()];
193
277
  },
194
- forTemplate(template) {
195
- return receipts.filter((row) => row.template === template);
278
+ forTemplate(template: string) {
279
+ return [...byId.values()].filter((row) => row.template === template);
196
280
  },
197
- byMessageId(messageId) {
198
- for (let i = receipts.length - 1; i >= 0; i--) {
199
- const row = receipts[i]!;
200
- if (row.messageId === messageId || row.id === messageId) return row;
201
- }
202
- return undefined;
281
+ byMessageId(messageId: string) {
282
+ return findByMessageId(messageId);
203
283
  },
204
- updateStatus(messageId, patch) {
205
- const idx = receipts.findIndex((row) => row.messageId === messageId || row.id === messageId);
206
- if (idx < 0) return undefined;
207
- const prev = receipts[idx]!;
284
+ updateStatus(
285
+ messageId: string,
286
+ patch: {
287
+ readonly status: DeliveryReceipt["status"];
288
+ readonly at?: number;
289
+ readonly error?: string;
290
+ },
291
+ ) {
292
+ const prev = findByMessageId(messageId);
293
+ if (!prev) return undefined;
208
294
  const next: DeliveryReceipt = {
209
295
  ...prev,
210
296
  status: patch.status,
211
297
  at: patch.at ?? prev.at,
212
298
  ...(patch.error !== undefined ? { error: patch.error } : {}),
213
299
  };
214
- receipts[idx] = next;
300
+ rememberReceipt(next);
215
301
  enqueue(() =>
216
302
  sql
217
- .exec(`UPDATE ${RECEIPT} SET body = ? WHERE id = ?`, [JSON.stringify(next), next.id])
303
+ .exec(
304
+ `UPDATE ${RECEIPT} SET body = ?, message_id = ?, updated_at = ${DB_NOW_MS} WHERE id = ?`,
305
+ [JSON.stringify(next), next.messageId ?? null, next.id],
306
+ )
218
307
  .then(() => undefined),
219
308
  );
220
309
  return next;
221
310
  },
222
- };
311
+ async lookup(messageId: string): Promise<DeliveryReceipt | undefined> {
312
+ const rows = await sql.query(
313
+ `SELECT id, body, message_id, updated_at FROM ${RECEIPT} WHERE id = ? OR message_id = ? LIMIT 1`,
314
+ [messageId, messageId],
315
+ );
316
+ const row = rows[0];
317
+ if (!row) return undefined;
318
+ const receipt = parseReceipt(row.body);
319
+ if (!receipt) return undefined;
320
+ rememberReceipt(receipt);
321
+ return receipt;
322
+ },
323
+ } satisfies ReceiptLedger & ChannelReceiptLookup;
223
324
 
224
325
  async function loadOptOuts(table: string, into: OptOut[]): Promise<void> {
225
326
  const rows = await sql.query(`SELECT subject, medium, at FROM ${table}`);
@@ -233,16 +334,59 @@ export async function openPostgresChannelLedger(
233
334
  }
234
335
  }
235
336
 
236
- async function reload(): Promise<void> {
337
+ function backfillMessageIds(): Promise<void> {
338
+ if (messageIdBackfill) return messageIdBackfill;
339
+ const run = (async () => {
340
+ const rows = await sql.query(`SELECT id, body FROM ${RECEIPT} WHERE message_id IS NULL`);
341
+ for (const row of rows) {
342
+ const receipt = parseReceipt(row.body);
343
+ const messageId = receipt?.messageId;
344
+ if (!messageId) continue;
345
+ await sql.exec(`UPDATE ${RECEIPT} SET message_id = ? WHERE id = ? AND message_id IS NULL`, [
346
+ messageId,
347
+ String(row.id),
348
+ ]);
349
+ }
350
+ })().catch((err: unknown) => {
351
+ messageIdBackfill = undefined;
352
+ throw err;
353
+ });
354
+ messageIdBackfill = run;
355
+ return run;
356
+ }
357
+
358
+ async function loadReceiptDelta(): Promise<void> {
359
+ const since = cursor - RECEIPT_RELOAD_LAG_MS;
360
+ const rows = await sql.query(
361
+ `SELECT r.id, r.body, r.message_id, r.updated_at, now_ms.db_now FROM (SELECT ${DB_NOW_MS} AS db_now) AS now_ms LEFT JOIN ${RECEIPT} r ON r.updated_at >= ?`,
362
+ [since],
363
+ );
364
+ let dbNow: number | undefined;
365
+ for (const row of rows) {
366
+ const read = readMillis(row.db_now);
367
+ if (read !== undefined) dbNow = read;
368
+ if (row.id == null || row.body == null) continue;
369
+ const receipt = parseReceipt(row.body);
370
+ if (receipt) rememberReceipt(receipt);
371
+ }
372
+ if (dbNow !== undefined) cursor = dbNow;
373
+ }
374
+
375
+ async function reloadFromSql(): Promise<void> {
237
376
  await flush();
238
377
  await loadOptOuts(CONSENT, consents);
239
378
  await loadOptOuts(BOUNCE, bounces);
240
- const rows = await sql.query(`SELECT id, body FROM ${RECEIPT}`);
241
- receipts.length = 0;
242
- for (const row of rows) {
243
- const parsed: unknown = JSON.parse(String(row.body));
244
- if (parsed && typeof parsed === "object") receipts.push(parsed as DeliveryReceipt);
245
- }
379
+ await backfillMessageIds();
380
+ await loadReceiptDelta();
381
+ }
382
+
383
+ function reload(): Promise<void> {
384
+ if (reloading) return reloading;
385
+ const run = reloadFromSql().finally(() => {
386
+ if (reloading === run) reloading = undefined;
387
+ });
388
+ reloading = run;
389
+ return run;
246
390
  }
247
391
 
248
392
  await reload();
@@ -206,7 +206,13 @@ if (mode === "tick-loop") {
206
206
  leaseMs,
207
207
  flow: chargeFlow(instanceId!, stepLogPath!, blockMs),
208
208
  });
209
- void app.fetch(new Request("http://localhost/charge", { method: "POST", body: "{}" }));
209
+ void app.fetch(
210
+ new Request("http://localhost/charge", {
211
+ method: "POST",
212
+ headers: { "content-type": "application/json" },
213
+ body: "{}",
214
+ }),
215
+ );
210
216
  // Marker once step 1 is journaled — then hang; the parent SIGKILLs mid-run.
211
217
  const deadline = Date.now() + 15_000;
212
218
  for (;;) {
@@ -269,7 +275,11 @@ if (mode === "tick-loop") {
269
275
  flow: sleeperFlow(instanceId!, stepLogPath!, wakeMs),
270
276
  });
271
277
  const res = await app.fetch(
272
- new Request("http://localhost/sleep", { method: "POST", body: "{}" }),
278
+ new Request("http://localhost/sleep", {
279
+ method: "POST",
280
+ headers: { "content-type": "application/json" },
281
+ body: "{}",
282
+ }),
273
283
  );
274
284
  if (res.status !== 204) {
275
285
  console.error(`journal-pg-park: expected 204 park, got ${res.status}`);
@@ -14,7 +14,10 @@ import { isFlowFailure } from "../../kernel/hooks.ts";
14
14
  import { isJsonStreamResult } from "../../kernel/json-result.ts";
15
15
  import {
16
16
  createJournal,
17
+ isJournalLeaseBusy,
17
18
  isJournalSuspend,
19
+ isLostJournalLease,
20
+ JournalLeaseBusy,
18
21
  type Journal,
19
22
  type JournalLeaseOptions,
20
23
  type JournalSession,
@@ -55,6 +58,8 @@ export interface RunDurableOptions {
55
58
  * Resume throws `JournalLeaseBusy` when another live instance holds the run.
56
59
  */
57
60
  readonly lease?: JournalLeaseOptions;
61
+ /** Opt-in app code version. Unset skips the stamp and the check. */
62
+ readonly codeVersion?: string;
58
63
  /** Injectable clock. */
59
64
  readonly now?: () => number;
60
65
  /** Extra fx options (secrets, store runtime, …). */
@@ -74,6 +79,7 @@ export async function runDurable<O = unknown>(
74
79
  store: options.journalStore,
75
80
  now,
76
81
  ...(options.lease ? { lease: options.lease } : {}),
82
+ ...(options.codeVersion ? { codeVersion: options.codeVersion } : {}),
77
83
  });
78
84
 
79
85
  if (options.runId) {
@@ -121,6 +127,7 @@ export async function runDurable<O = unknown>(
121
127
  });
122
128
 
123
129
  if (session.run.status === "compensating") {
130
+ if (isLostJournalLease(session.run.error)) throw new JournalLeaseBusy(session.runId);
124
131
  await rebindUndosFromDo(options.flow, options.input, fx, session);
125
132
  const priorError = session.run.error ?? "Error";
126
133
  await runCompensationPhase({
@@ -170,6 +177,7 @@ export async function runDurable<O = unknown>(
170
177
  }
171
178
  const output = produced;
172
179
  if (isFlowFailure(output)) {
180
+ if (isLostJournalLease(output)) throw new JournalLeaseBusy(session.runId);
173
181
  await runCompensationPhase({
174
182
  flow: options.flow,
175
183
  input: options.input,
@@ -198,6 +206,9 @@ export async function runDurable<O = unknown>(
198
206
  label: err.label,
199
207
  };
200
208
  }
209
+ if (isLostJournalLease(err) || isJournalLeaseBusy(err)) {
210
+ throw isJournalLeaseBusy(err) ? err : new JournalLeaseBusy(session.runId);
211
+ }
201
212
  await runCompensationPhase({
202
213
  flow: options.flow,
203
214
  input: options.input,
@@ -0,0 +1,121 @@
1
+ import { expect, test } from "bun:test";
2
+
3
+ import { computedCacheKey, createStoreCache, type StoreCache } from "./cache.ts";
4
+ import {
5
+ CACHE_INVALIDATE_CHANNEL,
6
+ openCacheInvalidation,
7
+ type CacheBusRedis,
8
+ type CacheBusSql,
9
+ } from "./cache-bus.ts";
10
+
11
+ function hub(): { client: () => CacheBusRedis } {
12
+ const subs = new Map<string, Set<(message: string) => void>>();
13
+ return {
14
+ client: () => ({
15
+ async publish(channel, message) {
16
+ const set = subs.get(channel);
17
+ if (!set) return 0;
18
+ for (const listener of [...set]) listener(message);
19
+ return set.size;
20
+ },
21
+ async subscribe(channel, listener) {
22
+ const set = subs.get(channel) ?? new Set<(message: string) => void>();
23
+ subs.set(channel, set);
24
+ set.add(listener);
25
+ return () => {
26
+ set.delete(listener);
27
+ };
28
+ },
29
+ }),
30
+ };
31
+ }
32
+
33
+ function seed(cache: StoreCache, resource: "sql:notes"): string {
34
+ const key = computedCacheKey(resource);
35
+ cache.set({ tier: 1, key, value: { n: 1 }, resources: [resource], expiresAt: null });
36
+ return key;
37
+ }
38
+
39
+ test("redis invalidation drops the other instance and does not republish", async () => {
40
+ const bus = hub();
41
+ let publishA = 0;
42
+ const redisA = bus.client();
43
+ const publish = redisA.publish.bind(redisA);
44
+ redisA.publish = async (channel, message) => {
45
+ publishA += 1;
46
+ return publish(channel, message);
47
+ };
48
+ const cacheA = createStoreCache();
49
+ const cacheB = createStoreCache();
50
+ const a = openCacheInvalidation(cacheA, { kind: "redis", redis: redisA, origin: "a" });
51
+ const b = openCacheInvalidation(cacheB, { kind: "redis", redis: bus.client(), origin: "b" });
52
+ await Promise.resolve();
53
+ const key = seed(cacheA, "sql:notes");
54
+ seed(cacheB, "sql:notes");
55
+ b.publish(["sql:notes"]);
56
+ await Promise.resolve();
57
+ expect(cacheA.get(key)).toBeUndefined();
58
+ expect(cacheB.get<{ n: number }>(key)).toEqual({ n: 1 });
59
+ expect(publishA).toBe(0);
60
+ expect(CACHE_INVALIDATE_CHANNEL).toBe("oke:cache:invalidate");
61
+ await a.stop();
62
+ await b.stop();
63
+ });
64
+
65
+ test("postgres invalidation is polled once and skips the sender", async () => {
66
+ const rows: Array<{ id: string; resource: string; origin: string; created_at: number }> = [];
67
+ let now = 1_000_000;
68
+ const sql = (): CacheBusSql => ({
69
+ async exec(statement, params) {
70
+ if (statement.startsWith("CREATE")) return { changes: 0 };
71
+ if (statement.startsWith("INSERT")) {
72
+ rows.push({
73
+ id: String(params?.[0]),
74
+ resource: String(params?.[1]),
75
+ origin: String(params?.[2]),
76
+ created_at: now,
77
+ });
78
+ return { changes: 1 };
79
+ }
80
+ if (statement.startsWith("DELETE")) {
81
+ const cutoff = Number(params?.[0]);
82
+ for (let i = rows.length - 1; i >= 0; i--) {
83
+ if (rows[i]!.created_at < cutoff) rows.splice(i, 1);
84
+ }
85
+ return { changes: 0 };
86
+ }
87
+ throw new Error(`unmatched ${statement}`);
88
+ },
89
+ async query(statement) {
90
+ if (statement.includes("db_now")) return [{ db_now: now }];
91
+ if (statement.includes("FROM oke_cache_invalidations"))
92
+ return rows.map((row) => ({ ...row }));
93
+ throw new Error(`unmatched ${statement}`);
94
+ },
95
+ });
96
+ const cacheA = createStoreCache();
97
+ const cacheB = createStoreCache();
98
+ const a = openCacheInvalidation(cacheA, {
99
+ kind: "postgres",
100
+ origin: "a",
101
+ sql: async () => sql(),
102
+ });
103
+ const b = openCacheInvalidation(cacheB, {
104
+ kind: "postgres",
105
+ origin: "b",
106
+ sql: async () => sql(),
107
+ });
108
+ await a.poll();
109
+ await b.poll();
110
+ const key = seed(cacheA, "sql:notes");
111
+ seed(cacheB, "sql:notes");
112
+ now += 5;
113
+ b.publish(["sql:notes"]);
114
+ for (let i = 0; i < 20; i++) await Promise.resolve();
115
+ await a.poll();
116
+ await b.poll();
117
+ expect(cacheA.get(key)).toBeUndefined();
118
+ expect(cacheB.get<{ n: number }>(key)).toEqual({ n: 1 });
119
+ await a.stop();
120
+ await b.stop();
121
+ });