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 { gate } from "../elements/gate.ts";
9
9
  import { field, id, now, store } from "../elements/store.ts";
10
10
  import { createTestApp, type TestApiCall, type TestApp } from "../test/create-test-app.ts";
11
11
  import { isStoreResourceRef } from "../elements/store/cache.ts";
12
+ import type { Effects } from "../manifest/types.ts";
12
13
  import { oke } from "./app.ts";
13
14
  import { resetNoEffectsWarnForTests } from "./boot.ts";
14
15
  import { flow, resetFlowSeq } from "./flow.ts";
@@ -56,6 +57,7 @@ describe("automatic tier-1 cache from effects", () => {
56
57
  const app = oke({
57
58
  name: "auto-cache",
58
59
  stores: [db],
60
+ cache: { auto: true },
59
61
  gate: { policies: [gate.public] },
60
62
  }).adopt({ list });
61
63
  const t = await createTestApp(app, { capability: "open" });
@@ -88,6 +90,7 @@ describe("automatic tier-1 cache from effects", () => {
88
90
  const app = oke({
89
91
  name: "auto-cache-execute",
90
92
  stores: [db],
93
+ cache: { auto: true },
91
94
  gate: { policies: [gate.public] },
92
95
  }).adopt({ list });
93
96
  const t = await createTestApp(app, { capability: "open" });
@@ -156,6 +159,7 @@ describe("automatic tier-1 cache from effects", () => {
156
159
  const app = oke({
157
160
  name: "auto-cache-ledger",
158
161
  stores: [db],
162
+ cache: { auto: true },
159
163
  gate: { policies: [gate.public] },
160
164
  }).adopt({ list });
161
165
  const t = await createTestApp(app, { capability: "open" });
@@ -207,6 +211,7 @@ describe("automatic tier-1 cache from effects", () => {
207
211
  const app = oke({
208
212
  name: "auto-cache-inv",
209
213
  stores: [db],
214
+ cache: { auto: true },
210
215
  gate: { policies: [gate.public] },
211
216
  }).adopt({ list, create });
212
217
  const t = await createTestApp(app, { capability: "open" });
@@ -249,6 +254,7 @@ describe("automatic tier-1 cache from effects", () => {
249
254
  const app = oke({
250
255
  name: "auto-cache-ledger-inv",
251
256
  stores: [db],
257
+ cache: { auto: true },
252
258
  gate: { policies: [gate.public] },
253
259
  }).adopt({ list, create });
254
260
  const t = await createTestApp(app, { capability: "open" });
@@ -261,4 +267,226 @@ describe("automatic tier-1 cache from effects", () => {
261
267
  expect(lists).toBe(2);
262
268
  await t.close();
263
269
  });
270
+
271
+ test("omitted cache caches a pure store read", async () => {
272
+ resetBindings();
273
+ resetFlowSeq();
274
+ const db = store.sql("app", { schema: { notes } });
275
+ let lists = 0;
276
+ const list = on(
277
+ http.get("/notes").public(),
278
+ flow("notes.list", {
279
+ effects: { reads: ["sql:notes"] },
280
+ do: () => {
281
+ lists += 1;
282
+ return [{ id: "n1", title: "Harbor" }];
283
+ },
284
+ }),
285
+ );
286
+ const app = oke({
287
+ name: "auto-cache-default-on",
288
+ stores: [db],
289
+ gate: { policies: [gate.public] },
290
+ }).adopt({ list });
291
+ const t = await createTestApp(app, { capability: "open" });
292
+ const notesApi = requireNotesApi(t);
293
+ await notesApi.list({});
294
+ await notesApi.list({});
295
+ expect(lists).toBe(1);
296
+ await t.close();
297
+ });
298
+
299
+ test("cache.auto false turns automatic caching off", async () => {
300
+ resetBindings();
301
+ resetFlowSeq();
302
+ const db = store.sql("app", { schema: { notes } });
303
+ let lists = 0;
304
+ const list = on(
305
+ http.get("/notes").public(),
306
+ flow("notes.list", {
307
+ effects: { reads: ["sql:notes"] },
308
+ do: () => {
309
+ lists += 1;
310
+ return [{ id: "n1", title: "Harbor" }];
311
+ },
312
+ }),
313
+ );
314
+ const app = oke({
315
+ name: "auto-cache-app-off",
316
+ stores: [db],
317
+ cache: { auto: false },
318
+ gate: { policies: [gate.public] },
319
+ }).adopt({ list });
320
+ const t = await createTestApp(app, { capability: "open" });
321
+ const notesApi = requireNotesApi(t);
322
+ await notesApi.list({});
323
+ await notesApi.list({});
324
+ expect(lists).toBe(2);
325
+ await t.close();
326
+ });
327
+
328
+ test("cache: true opts one flow in without the app switch", async () => {
329
+ resetBindings();
330
+ resetFlowSeq();
331
+ const db = store.sql("app", { schema: { notes } });
332
+ let lists = 0;
333
+ const list = on(
334
+ http.get("/notes", { out: z.array(NoteOut) }).public(),
335
+ flow("notes.list", {
336
+ cache: true,
337
+ effects: { reads: ["sql:notes"] },
338
+ do: () => {
339
+ lists += 1;
340
+ return [{ id: "n1", title: "Harbor" }];
341
+ },
342
+ }),
343
+ );
344
+ const app = oke({
345
+ name: "auto-cache-flow-opt-in",
346
+ stores: [db],
347
+ gate: { policies: [gate.public] },
348
+ }).adopt({ list });
349
+ const t = await createTestApp(app, { capability: "open" });
350
+ const notesApi = requireNotesApi(t);
351
+ await notesApi.list({});
352
+ await notesApi.list({});
353
+ expect(lists).toBe(1);
354
+ await t.close();
355
+ });
356
+
357
+ const sideEffects = [
358
+ { kind: "emit", effects: { reads: ["sql:notes"], emits: ["note.created"] } },
359
+ { kind: "send", effects: { reads: ["sql:notes"], sends: ["welcome"] } },
360
+ { kind: "fetch", effects: { reads: ["sql:notes"], fetches: ["api.example.com"] } },
361
+ { kind: "secret", effects: { reads: ["sql:notes"], secrets: ["stripe"] } },
362
+ { kind: "ask", effects: { reads: ["sql:notes"], asks: ["summarize"] } },
363
+ { kind: "decide", effects: { reads: ["sql:notes"], decides: ["route"] } },
364
+ { kind: "call", effects: { reads: ["sql:notes"], calls: ["notes.create"] } },
365
+ ] satisfies readonly { kind: string; effects: Effects }[];
366
+
367
+ for (const side of sideEffects) {
368
+ test(`${side.kind} effects run the handler on every call`, async () => {
369
+ resetBindings();
370
+ resetFlowSeq();
371
+ const db = store.sql("app", { schema: { notes } });
372
+ let lists = 0;
373
+ const list = on(
374
+ http.get("/notes").public(),
375
+ flow("notes.list", {
376
+ effects: side.effects,
377
+ do: () => {
378
+ lists += 1;
379
+ return [{ id: "n1", title: "Harbor" }];
380
+ },
381
+ }),
382
+ );
383
+ const app = oke({
384
+ name: `auto-cache-${side.kind}`,
385
+ stores: [db],
386
+ cache: { auto: true },
387
+ gate: { policies: [gate.public] },
388
+ }).adopt({ list });
389
+ const t = await createTestApp(app, { capability: "open" });
390
+ const notesApi = requireNotesApi(t);
391
+ await notesApi.list({});
392
+ await notesApi.list({});
393
+ expect(lists).toBe(2);
394
+ await t.close();
395
+ });
396
+ }
397
+
398
+ test("a ledgered call beside a store read is not cached", async () => {
399
+ resetBindings();
400
+ resetFlowSeq();
401
+ const db = store.sql("app", { schema: { notes } });
402
+ let lists = 0;
403
+ const list = on(
404
+ http.get("/notes", { out: z.array(NoteOut) }).public(),
405
+ flow("notes.list", {
406
+ do: async (_input, fx) => {
407
+ lists += 1;
408
+ await fx.store(db).select().from(notes);
409
+ await fx.call("notes.missing", {});
410
+ return [{ id: "n1", title: "Harbor" }];
411
+ },
412
+ }),
413
+ );
414
+ const app = oke({
415
+ name: "auto-cache-ledger-call",
416
+ stores: [db],
417
+ cache: { auto: true },
418
+ gate: { policies: [gate.public] },
419
+ }).adopt({ list });
420
+ const t = await createTestApp(app, { capability: "open" });
421
+ const notesApi = requireNotesApi(t);
422
+ await notesApi.list({});
423
+ await notesApi.list({});
424
+ expect(lists).toBe(2);
425
+ await t.close();
426
+ });
427
+
428
+ test("two tenants do not share a cache hit", async () => {
429
+ resetBindings();
430
+ resetFlowSeq();
431
+ const db = store.sql("app", { schema: { notes } });
432
+ let lists = 0;
433
+ const list = on(
434
+ http.get("/notes").public(),
435
+ flow("notes.list", {
436
+ effects: { reads: ["sql:notes"] },
437
+ do: () => {
438
+ lists += 1;
439
+ return [{ id: "n1", title: "Harbor" }];
440
+ },
441
+ }),
442
+ );
443
+ const app = oke({
444
+ name: "auto-cache-tenant",
445
+ stores: [db],
446
+ cache: { auto: true },
447
+ gate: { policies: [gate.public] },
448
+ }).adopt({ list });
449
+ const t = await createTestApp(app, { capability: "open" });
450
+ const trigger = list.triggers[0];
451
+ if (trigger === undefined) throw new Error("notes.list has no trigger");
452
+ await app.execute(list, {}, trigger, { tenant: { id: "ta" } });
453
+ await app.execute(list, {}, trigger, { tenant: { id: "ta" } });
454
+ expect(lists).toBe(1);
455
+ await app.execute(list, {}, trigger, { tenant: { id: "tb" } });
456
+ expect(lists).toBe(2);
457
+ await t.close();
458
+ });
459
+
460
+ test("two locales do not share a cache hit", async () => {
461
+ resetBindings();
462
+ resetFlowSeq();
463
+ const db = store.sql("app", { schema: { notes } });
464
+ let lists = 0;
465
+ const list = on(
466
+ http.get("/notes").public(),
467
+ flow("notes.list", {
468
+ effects: { reads: ["sql:notes"] },
469
+ do: () => {
470
+ lists += 1;
471
+ return [{ id: "n1", title: "Harbor" }];
472
+ },
473
+ }),
474
+ );
475
+ const app = oke({
476
+ name: "auto-cache-locale",
477
+ stores: [db],
478
+ cache: { auto: true },
479
+ config: { i18n: { locales: ["en", "ar"], default: "en" } },
480
+ gate: { policies: [gate.public] },
481
+ }).adopt({ list });
482
+ const t = await createTestApp(app, { capability: "open" });
483
+ const trigger = list.triggers[0];
484
+ if (trigger === undefined) throw new Error("notes.list has no trigger");
485
+ await app.execute(list, {}, trigger, { locale: "en" });
486
+ await app.execute(list, {}, trigger, { locale: "en" });
487
+ expect(lists).toBe(1);
488
+ await app.execute(list, {}, trigger, { locale: "ar" });
489
+ expect(lists).toBe(2);
490
+ await t.close();
491
+ });
264
492
  });
@@ -285,7 +285,11 @@ describe("oke() auto-registry — stores/secrets/signals/clocks/gates/channel.te
285
285
  });
286
286
 
287
287
  const res = await app.fetch(
288
- new Request("http://127.0.0.1/ai-ping", { method: "POST", body: "{}" }),
288
+ new Request("http://127.0.0.1/ai-ping", {
289
+ method: "POST",
290
+ headers: { "content-type": "application/json" },
291
+ body: "{}",
292
+ }),
289
293
  );
290
294
  expect(res.status).toBe(200);
291
295
  const json = (await res.json()) as { data: { summary: string } };
@@ -4,6 +4,8 @@
4
4
 
5
5
  import { resolveDriverId, type ConfigEnv } from "../../config/index.ts";
6
6
  import { CHANNEL_EMAIL_DEFAULTS, CHANNEL_SMS_DEFAULTS } from "../../config/driver-defaults.ts";
7
+ import { sharedPostgresClient, toPostgresParams } from "../../drivers/postgres.ts";
8
+ import type { PostgresClientLike } from "../../drivers/postgres.ts";
7
9
  import { openConsoleChannel } from "../../drivers/channel-console.ts";
8
10
  import { openMsegatChannel } from "../../drivers/channel-msegat.ts";
9
11
  import { openResendChannel } from "../../drivers/channel-resend.ts";
@@ -16,8 +18,59 @@ import { openUnifonicChannel } from "../../drivers/channel-unifonic.ts";
16
18
  import { openWaCloudChannel } from "../../drivers/channel-wa-cloud.ts";
17
19
  import type { ChannelDriver, ChannelOpenOptions } from "../../drivers/channel-types.ts";
18
20
  import { createChannelRuntime, type ChannelRuntime } from "../../elements/channel.ts";
21
+ import {
22
+ openPostgresChannelLedger,
23
+ type ChannelLedgerSql,
24
+ type PostgresChannelLedger,
25
+ } from "../../elements/channel/sql-ledger.ts";
19
26
  import type { BootOptions } from "../boot.ts";
20
27
 
28
+ /**
29
+ * Query/exec wrapper over the shared Bun.SQL pool.
30
+ *
31
+ * @param client - Shared postgres client
32
+ */
33
+ function asChannelSql(client: PostgresClientLike): ChannelLedgerSql {
34
+ return {
35
+ async query(sql, params = []) {
36
+ const result = await client.unsafe(toPostgresParams(sql, params), [...params]);
37
+ if (Array.isArray(result)) return result as Record<string, unknown>[];
38
+ return Array.from(result as ArrayLike<Record<string, unknown>>);
39
+ },
40
+ async exec(sql, params = []) {
41
+ const result = await client.unsafe(toPostgresParams(sql, params), [...params]);
42
+ if (
43
+ result &&
44
+ typeof result === "object" &&
45
+ "changes" in result &&
46
+ typeof result.changes === "number"
47
+ ) {
48
+ return { changes: result.changes };
49
+ }
50
+ if (Array.isArray(result)) return { changes: result.length };
51
+ return { changes: 0 };
52
+ },
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Durable ledger when a SQL URL is set outside `test`.
58
+ * Injected stores win. `test` stays in memory.
59
+ *
60
+ * @param options - Boot options
61
+ * @param env - Active environment
62
+ */
63
+ async function maybeLedger(
64
+ options: BootOptions,
65
+ env: ConfigEnv,
66
+ ): Promise<PostgresChannelLedger | undefined> {
67
+ if (env === "test") return undefined;
68
+ if (options.channel?.suppression && options.channel.receipts) return undefined;
69
+ const url = process.env.DATABASE_URL ?? process.env.OKE_STORE_SQL_URL;
70
+ if (!url) return undefined;
71
+ return openPostgresChannelLedger(asChannelSql(sharedPostgresClient(url)));
72
+ }
73
+
21
74
  /**
22
75
  * Construct a Channel runtime (console inbox default).
23
76
  *
@@ -29,16 +82,24 @@ import type { BootOptions } from "../boot.ts";
29
82
  * @param now - Clock
30
83
  * @param docker - Prefer compose SMTP when active
31
84
  */
32
- export function bindChannel(
85
+ export async function bindChannel(
33
86
  options: BootOptions,
34
87
  env: ConfigEnv,
35
88
  now: () => number,
36
89
  docker = false,
37
- ): ChannelRuntime {
90
+ ): Promise<ChannelRuntime> {
91
+ const ledger = await maybeLedger(options, env);
38
92
  return createChannelRuntime({
39
93
  ...(options.channel ?? {}),
40
94
  drivers: options.channel?.drivers ?? defaultDrivers(options, env, docker),
41
95
  now,
96
+ ...(ledger
97
+ ? {
98
+ suppression: options.channel?.suppression ?? ledger.suppression,
99
+ receipts: options.channel?.receipts ?? ledger.receipts,
100
+ refresh: () => ledger.reload(),
101
+ }
102
+ : {}),
42
103
  });
43
104
  }
44
105
 
@@ -336,18 +336,48 @@ describe("boot binders honour drivers.* config", () => {
336
336
  }
337
337
  });
338
338
 
339
- test("signal: drivers.signal postgres fails loud (never silent memory)", async () => {
340
- await expect(
341
- bootApplication({
342
- env: "test",
343
- signals: [signal.once("ping")],
344
- config: {
345
- drivers: {
346
- signal: { test: "postgres" },
339
+ test("signal: drivers.signal postgres needs DATABASE_URL (never silent memory)", async () => {
340
+ const prevDb = process.env.DATABASE_URL;
341
+ const prevStore = process.env.OKE_STORE_SQL_URL;
342
+ delete process.env.DATABASE_URL;
343
+ delete process.env.OKE_STORE_SQL_URL;
344
+ try {
345
+ await expect(
346
+ bootApplication({
347
+ env: "test",
348
+ signals: [signal.once("ping")],
349
+ config: {
350
+ drivers: {
351
+ signal: { test: "postgres" },
352
+ },
347
353
  },
354
+ }),
355
+ ).rejects.toThrow(/DATABASE_URL/);
356
+ } finally {
357
+ if (prevDb !== undefined) process.env.DATABASE_URL = prevDb;
358
+ else delete process.env.DATABASE_URL;
359
+ if (prevStore !== undefined) process.env.OKE_STORE_SQL_URL = prevStore;
360
+ else delete process.env.OKE_STORE_SQL_URL;
361
+ }
362
+ });
363
+
364
+ test("signal: drivers.signal postgres binds an injected client", async () => {
365
+ const { createPostgresSignalFake } = await import("../../drivers/signal-postgres.ts");
366
+ const result = await bootApplication({
367
+ env: "test",
368
+ signals: [signal.once("ping")],
369
+ clients: { signalSql: createPostgresSignalFake() },
370
+ config: {
371
+ drivers: {
372
+ signal: { test: "postgres" },
348
373
  },
349
- }),
350
- ).rejects.toThrow(/signal driver "postgres"/);
374
+ },
375
+ });
376
+ try {
377
+ expect(result.signal!.driverId).toBe("postgres");
378
+ } finally {
379
+ await result.close();
380
+ }
351
381
  });
352
382
 
353
383
  test("runs: drivers.runs unset in test binds memory", async () => {
@@ -4,11 +4,23 @@
4
4
 
5
5
  import { resolveDriverId, type ConfigEnv } from "../../config/index.ts";
6
6
  import { SIGNAL_DEFAULTS } from "../../config/driver-defaults.ts";
7
+ import {
8
+ sharedPostgresClient,
9
+ toPostgresParams,
10
+ withPinnedPostgres,
11
+ } from "../../drivers/postgres.ts";
12
+ import type { PostgresClientLike } from "../../drivers/postgres.ts";
7
13
  import { memorySignalDriver } from "../../drivers/signal-memory.ts";
14
+ import type { PostgresSignalSql } from "../../drivers/signal-postgres.ts";
15
+ import {
16
+ POSTGRES_SIGNAL_DEFAULT_LAG_MS,
17
+ openPostgresSignal,
18
+ postgresSignalDriver,
19
+ } from "../../drivers/signal-postgres.ts";
8
20
  import { createBunSignalRedisClient, redisSignalDriver } from "../../drivers/signal-redis.ts";
9
21
  import type { SignalRedisClientLike } from "../../drivers/signal-types.ts";
10
22
  import { createSignalRuntime, type SignalRuntime } from "../../elements/signal.ts";
11
- import { emitBootWarn } from "../../runtime/boot-warn.ts";
23
+ import { resolveInstanceId } from "../instance-id.ts";
12
24
  import type { BootOptions } from "../boot.ts";
13
25
 
14
26
  /**
@@ -32,29 +44,43 @@ function redisUrlFor(docker: boolean): string | undefined {
32
44
  return url;
33
45
  }
34
46
 
35
- let signalRedisWarned = false;
36
-
37
- /** One-shot: redis Signal is emit-relay + process-local outbox consume today. */
38
- function warnSignalRedisProcessLocal(): void {
39
- if (signalRedisWarned) return;
40
- signalRedisWarned = true;
41
- emitBootWarn(
42
- "oke boot: drivers.signal redis — emit relays to Redis, but consume/live/drain use a " +
43
- "process-local outbox (not multi-instance competing consumers). Prefer a shared durable " +
44
- "outbox path for multi-process tests, or a single consumer instance, until Redis Streams " +
45
- "consume ships.",
46
- );
47
- }
48
-
49
- /** Test helper — reset the one-shot redis Signal warn. */
50
- export function resetSignalRedisWarnForTests(): void {
51
- signalRedisWarned = false;
47
+ /**
48
+ * Wrap a Bun.SQL client as the signal driver's query surface.
49
+ * `listen` is omitted — Bun.SQL has no LISTEN/NOTIFY. Boot polls `drain`.
50
+ *
51
+ * @param client - Shared postgres pool
52
+ */
53
+ function asSignalSql(client: PostgresClientLike): PostgresSignalSql {
54
+ return {
55
+ async query(sql, params = []) {
56
+ const result = await client.unsafe(toPostgresParams(sql, params), [...params]);
57
+ if (Array.isArray(result)) return result as Record<string, unknown>[];
58
+ return Array.from(result as ArrayLike<Record<string, unknown>>);
59
+ },
60
+ async exec(sql, params = []) {
61
+ const result = await client.unsafe(toPostgresParams(sql, params), [...params]);
62
+ if (
63
+ result &&
64
+ typeof result === "object" &&
65
+ "changes" in result &&
66
+ typeof result.changes === "number"
67
+ ) {
68
+ return { changes: result.changes };
69
+ }
70
+ if (Array.isArray(result)) return { changes: result.length };
71
+ return { changes: 0 };
72
+ },
73
+ begin: (fn) => withPinnedPostgres(client, (tx) => fn(asSignalSql(tx))),
74
+ async close() {
75
+ /* Shared pool — boot owns the connection. */
76
+ },
77
+ };
52
78
  }
53
79
 
54
80
  /**
55
81
  * Construct a Signal runtime, register decls / binding names, start the bus.
56
82
  *
57
- * Supported ids: `memory` · `redis`. `postgres` / `nats` fail loud until a real
83
+ * Supported ids: `memory` · `redis` · `postgres`. `nats` fails loud until a
58
84
  * native client can be constructed (never silently bind memory).
59
85
  *
60
86
  * @param options - Boot options
@@ -81,19 +107,40 @@ export async function bindSignal(
81
107
  break;
82
108
  case "redis": {
83
109
  const redis = injectedRedis ?? createBunSignalRedisClient(redisUrlFor(docker));
84
- warnSignalRedisProcessLocal();
85
110
  signal = createSignalRuntime({
86
111
  driver: redisSignalDriver,
87
112
  now,
88
113
  redis,
114
+ compete: true,
115
+ consumerId: resolveInstanceId(options.instanceId),
116
+ });
117
+ break;
118
+ }
119
+ case "postgres": {
120
+ const injected = options.clients?.signalSql;
121
+ const url = process.env.DATABASE_URL ?? process.env.OKE_STORE_SQL_URL ?? undefined;
122
+ if (!injected && !url) {
123
+ throw new Error(
124
+ env === "dev"
125
+ ? 'oke boot: signal driver "postgres" needs DATABASE_URL (did `oke dev` write .env.local?)'
126
+ : 'oke boot: signal driver "postgres" needs DATABASE_URL',
127
+ );
128
+ }
129
+ signal = createSignalRuntime({
130
+ driver: {
131
+ id: postgresSignalDriver.id,
132
+ open: (openOptions) =>
133
+ openPostgresSignal({
134
+ ...openOptions,
135
+ lagMs: injected ? undefined : POSTGRES_SIGNAL_DEFAULT_LAG_MS,
136
+ }),
137
+ },
138
+ now,
139
+ sql: injected ?? asSignalSql(sharedPostgresClient(url)),
140
+ pollMs: injected ? undefined : 1_000,
89
141
  });
90
142
  break;
91
143
  }
92
- case "postgres":
93
- throw new Error(
94
- 'oke boot: signal driver "postgres" needs a LISTEN/NOTIFY-capable SQL client — ' +
95
- 'not available via Bun.SQL yet. Use "redis" or "memory", or inject elements.signal.',
96
- );
97
144
  case "nats":
98
145
  throw new Error(
99
146
  'oke boot: signal driver "nats" has no production client bind yet — ' +
@@ -24,6 +24,7 @@ import { postgresDriver } from "../../drivers/postgres.ts";
24
24
  import { redisDriver } from "../../drivers/redis.ts";
25
25
  import { s3Driver } from "../../drivers/s3.ts";
26
26
  import type { FilesDriver, IndexDriver, KvDriver, SqlDriver } from "../../drivers/types.ts";
27
+ import { bunRedisCacheClient } from "../../elements/store/cache-bus.ts";
27
28
  import { createStoreRuntime, type StoreRuntime } from "../../elements/store.ts";
28
29
  import type { SqlRuntimeBinding } from "../../elements/store/runtime.ts";
29
30
  import type { StoreDecl } from "../../elements/store/declare.ts";
@@ -171,7 +172,14 @@ export function bindStore(
171
172
  const autoPush = resolveAutoPush(options);
172
173
  const domainDdl = resolveDomainDdlMode(env, autoPush);
173
174
 
174
- const store = createStoreRuntime({
175
+ let store!: StoreRuntime;
176
+ const cacheBus =
177
+ kvId === "redis" && kvUrl
178
+ ? { kind: "redis" as const, redis: bunRedisCacheClient(kvUrl) }
179
+ : sqlId === "postgres" && sqlUrl
180
+ ? { kind: "postgres" as const, sql: () => store.primarySql() }
181
+ : undefined;
182
+ store = createStoreRuntime({
175
183
  drivers: {
176
184
  sql: sqlDriverFor(sqlId),
177
185
  kv: kvDriverFor(kvId),
@@ -183,6 +191,13 @@ export function bindStore(
183
191
  files: filesBindings,
184
192
  index: indexBindings,
185
193
  now,
194
+ ...(options.cache?.maxEntries !== undefined
195
+ ? { cacheMaxEntries: options.cache.maxEntries }
196
+ : {}),
197
+ ...(options.cache?.defaultTtlMs !== undefined
198
+ ? { cacheDefaultTtlMs: options.cache.defaultTtlMs }
199
+ : {}),
200
+ ...(cacheBus ? { cacheBus } : {}),
186
201
  domainDdl,
187
202
  sqlUrl,
188
203
  });
@@ -285,8 +285,8 @@ describe("boot — cron autostart via real timer loop", () => {
285
285
  // Drive the wall-clock interval that boot installed — not clock.advance /
286
286
  // runNow / dispatchEvery (those prove the harness, not autostart).
287
287
  jest.advanceTimersByTime(1000);
288
- // Interval callback does `void clock.tick()`; drain the async store walk.
289
- for (let i = 0; i < 50; i++) await Promise.resolve();
288
+ // The tick is guarded and async. Wait until it settles, but not forever.
289
+ for (let i = 0; i < 50 && ran < 1; i++) await Promise.resolve();
290
290
 
291
291
  expect(ran).toBeGreaterThanOrEqual(1);
292
292