okengine 0.23.0 → 0.23.1

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 (87) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/ai/mcp.mdx +1 -1
  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/index.mdx +15 -14
  8. package/site/content/docs/plugins/otp.mdx +3 -3
  9. package/site/content/docs/reference/fx.mdx +12 -4
  10. package/src/auth/tenants.ts +17 -0
  11. package/src/cli/doctor-diff.test.ts +29 -1
  12. package/src/cli/doctor-diff.ts +35 -0
  13. package/src/cli/manifest-pr-diff.ts +16 -0
  14. package/src/compiler/effects-fetch.test.ts +77 -0
  15. package/src/compiler/effects-infer.ts +87 -16
  16. package/src/compiler/effects-join.test.ts +48 -0
  17. package/src/compiler/extract.test.ts +28 -0
  18. package/src/compiler/extract.ts +11 -1
  19. package/src/compiler/fx-index.ts +58 -4
  20. package/src/console/index.ts +1 -1
  21. package/src/console/ui-next/dist/assets/{access-page-DDKkhT9v.js → access-page-C_N4qvai.js} +1 -1
  22. package/src/console/ui-next/dist/assets/{agent-disclosure-62Xts8Xi.js → agent-disclosure-Cq1TxniW.js} +1 -1
  23. package/src/console/ui-next/dist/assets/{cache-glyph-DyeoKHKA.js → cache-glyph-BaI4uxZz.js} +1 -1
  24. package/src/console/ui-next/dist/assets/{call-pii-button-DvfpLXsU.js → call-pii-button-DNyK7IIY.js} +1 -1
  25. package/src/console/ui-next/dist/assets/{collapsible-BY03SeCg.js → collapsible-CRhBJMZR.js} +1 -1
  26. package/src/console/ui-next/dist/assets/{decisions-page-CgkKlA56.js → decisions-page-D9twj8Xy.js} +1 -1
  27. package/src/console/ui-next/dist/assets/{duration-tone-Y6HLjyJ5.js → duration-tone-CsHWlVPU.js} +1 -1
  28. package/src/console/ui-next/dist/assets/{flows-page-DLPfNy-_.js → flows-page-ZaTJAhR9.js} +1 -1
  29. package/src/console/ui-next/dist/assets/{highlighted-json-D7nNzpgB.js → highlighted-json-DQ-EMAfz.js} +1 -1
  30. package/src/console/ui-next/dist/assets/{http-method-DdL19zzh.js → http-method-Cn_Fl79s.js} +1 -1
  31. package/src/console/ui-next/dist/assets/{index-C0lc_s9d.js → index-CmRFeWdT.js} +3 -3
  32. package/src/console/ui-next/dist/assets/{observability-page-CRlcyLCC.js → observability-page-Cn6R_dXz.js} +1 -1
  33. package/src/console/ui-next/dist/assets/{replica-lag-Bb3FWUu9.js → replica-lag-Bx6EZhSe.js} +1 -1
  34. package/src/console/ui-next/dist/assets/{request-meta-DYBMWhX8.js → request-meta-DmdiUrfh.js} +1 -1
  35. package/src/console/ui-next/dist/assets/{store-page-CcE-SXC8.js → store-page-8P6vGXnn.js} +1 -1
  36. package/src/console/ui-next/dist/assets/{trace-detail-sheet-CKMSEQ4s.js → trace-detail-sheet-BfGvpFSv.js} +1 -1
  37. package/src/console/ui-next/dist/assets/{tree-expand-toggle-QOfk0M0H.js → tree-expand-toggle-CWdUHbc0.js} +1 -1
  38. package/src/console/ui-next/dist/assets/{units-page-DKC1CDDd.js → units-page-CZOFZzay.js} +1 -1
  39. package/src/console/ui-next/dist/assets/{vault-page-B54v4Yit.js → vault-page-ki_ishmX.js} +1 -1
  40. package/src/console/ui-next/dist/index.html +1 -1
  41. package/src/drivers/journal-postgres.test.ts +22 -1
  42. package/src/drivers/journal-postgres.ts +223 -12
  43. package/src/drivers/signal-compete.test.ts +234 -0
  44. package/src/drivers/signal-postgres.ts +39 -19
  45. package/src/drivers/signal-redis.ts +167 -11
  46. package/src/drivers/signal-types.ts +12 -0
  47. package/src/elements/ai/mcp-protocol.ts +5 -6
  48. package/src/elements/ai/runtime.ts +20 -19
  49. package/src/elements/channel/runtime.ts +11 -0
  50. package/src/elements/channel/sql-ledger.ts +251 -0
  51. package/src/elements/signal/runtime.ts +9 -0
  52. package/src/elements/signal.test.ts +1 -0
  53. package/src/elements/store/cache.test.ts +120 -15
  54. package/src/elements/store/cache.ts +200 -39
  55. package/src/elements/store/prepare-row.test.ts +6 -2
  56. package/src/elements/store/sql-session.test.ts +2 -0
  57. package/src/elements/store/sql-session.ts +95 -9
  58. package/src/elements/store.ts +1 -0
  59. package/src/kernel/app.ts +32 -16
  60. package/src/kernel/auto-cache.test.ts +228 -0
  61. package/src/kernel/boot-bind/channel.ts +63 -2
  62. package/src/kernel/boot-bind/honor-config.test.ts +40 -10
  63. package/src/kernel/boot-bind/signal.ts +61 -25
  64. package/src/kernel/boot.ts +16 -2
  65. package/src/kernel/errors-compiler.ts +11 -0
  66. package/src/kernel/errors-text.ts +16 -0
  67. package/src/kernel/errors.ts +8 -0
  68. package/src/kernel/flow.ts +33 -3
  69. package/src/kernel/fx-call-types.test.ts +32 -0
  70. package/src/kernel/fx-decide.ts +11 -7
  71. package/src/kernel/fx-sql-handle.ts +46 -7
  72. package/src/kernel/fx.test.ts +1 -3
  73. package/src/kernel/fx.ts +27 -16
  74. package/src/kernel/idempotency.test.ts +2 -1
  75. package/src/kernel/journal.test.ts +114 -0
  76. package/src/kernel/journal.ts +369 -53
  77. package/src/kernel/pipeline-tenant.ts +5 -1
  78. package/src/kernel/pipeline.ts +5 -0
  79. package/src/kernel/signal-tx.ts +28 -0
  80. package/src/kernel/store-transaction.test.ts +95 -0
  81. package/src/mcp/docs-server.ts +19 -2
  82. package/src/mcp/mcp.test.ts +83 -0
  83. package/src/mcp/protocol.ts +8 -2
  84. package/src/mcp/server.ts +23 -2
  85. package/src/mcp/versions.ts +60 -0
  86. package/src/plugins/index.ts +1 -0
  87. package/src/plugins/mena.ts +56 -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
  });
@@ -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,19 @@
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 { postgresSignalDriver } from "../../drivers/signal-postgres.ts";
8
16
  import { createBunSignalRedisClient, redisSignalDriver } from "../../drivers/signal-redis.ts";
9
17
  import type { SignalRedisClientLike } from "../../drivers/signal-types.ts";
10
18
  import { createSignalRuntime, type SignalRuntime } from "../../elements/signal.ts";
11
- import { emitBootWarn } from "../../runtime/boot-warn.ts";
19
+ import { resolveInstanceId } from "../instance-id.ts";
12
20
  import type { BootOptions } from "../boot.ts";
13
21
 
14
22
  /**
@@ -32,29 +40,43 @@ function redisUrlFor(docker: boolean): string | undefined {
32
40
  return url;
33
41
  }
34
42
 
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;
43
+ /**
44
+ * Wrap a Bun.SQL client as the signal driver's query surface.
45
+ * `listen` is omitted — Bun.SQL has no LISTEN/NOTIFY. Boot polls `drain`.
46
+ *
47
+ * @param client - Shared postgres pool
48
+ */
49
+ function asSignalSql(client: PostgresClientLike): PostgresSignalSql {
50
+ return {
51
+ async query(sql, params = []) {
52
+ const result = await client.unsafe(toPostgresParams(sql, params), [...params]);
53
+ if (Array.isArray(result)) return result as Record<string, unknown>[];
54
+ return Array.from(result as ArrayLike<Record<string, unknown>>);
55
+ },
56
+ async exec(sql, params = []) {
57
+ const result = await client.unsafe(toPostgresParams(sql, params), [...params]);
58
+ if (
59
+ result &&
60
+ typeof result === "object" &&
61
+ "changes" in result &&
62
+ typeof result.changes === "number"
63
+ ) {
64
+ return { changes: result.changes };
65
+ }
66
+ if (Array.isArray(result)) return { changes: result.length };
67
+ return { changes: 0 };
68
+ },
69
+ begin: (fn) => withPinnedPostgres(client, (tx) => fn(asSignalSql(tx))),
70
+ async close() {
71
+ /* Shared pool — boot owns the connection. */
72
+ },
73
+ };
52
74
  }
53
75
 
54
76
  /**
55
77
  * Construct a Signal runtime, register decls / binding names, start the bus.
56
78
  *
57
- * Supported ids: `memory` · `redis`. `postgres` / `nats` fail loud until a real
79
+ * Supported ids: `memory` · `redis` · `postgres`. `nats` fails loud until a
58
80
  * native client can be constructed (never silently bind memory).
59
81
  *
60
82
  * @param options - Boot options
@@ -81,19 +103,33 @@ export async function bindSignal(
81
103
  break;
82
104
  case "redis": {
83
105
  const redis = injectedRedis ?? createBunSignalRedisClient(redisUrlFor(docker));
84
- warnSignalRedisProcessLocal();
85
106
  signal = createSignalRuntime({
86
107
  driver: redisSignalDriver,
87
108
  now,
88
109
  redis,
110
+ compete: true,
111
+ consumerId: resolveInstanceId(options.instanceId),
112
+ });
113
+ break;
114
+ }
115
+ case "postgres": {
116
+ const injected = options.clients?.signalSql;
117
+ const url = process.env.DATABASE_URL ?? process.env.OKE_STORE_SQL_URL ?? undefined;
118
+ if (!injected && !url) {
119
+ throw new Error(
120
+ env === "dev"
121
+ ? 'oke boot: signal driver "postgres" needs DATABASE_URL (did `oke dev` write .env.local?)'
122
+ : 'oke boot: signal driver "postgres" needs DATABASE_URL',
123
+ );
124
+ }
125
+ signal = createSignalRuntime({
126
+ driver: postgresSignalDriver,
127
+ now,
128
+ sql: injected ?? asSignalSql(sharedPostgresClient(url)),
129
+ pollMs: injected ? undefined : 1_000,
89
130
  });
90
131
  break;
91
132
  }
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
133
  case "nats":
98
134
  throw new Error(
99
135
  'oke boot: signal driver "nats" has no production client bind yet — ' +
@@ -191,6 +191,8 @@ export interface BootOptions {
191
191
  readonly clients?: {
192
192
  readonly kv?: import("../drivers/types.ts").KvClientLike;
193
193
  readonly signalRedis?: import("../drivers/signal-types.ts").SignalRedisClientLike;
194
+ /** Injected postgres signal client (tests). Production opens Bun.SQL. */
195
+ readonly signalSql?: import("../drivers/signal-postgres.ts").PostgresSignalSql;
194
196
  };
195
197
  /**
196
198
  * Process instance id for Clock / Journal / fleet registry.
@@ -538,7 +540,16 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
538
540
  // 4d. Scheduler — one timer drives clock ticks, durable-run resume, heartbeat.
539
541
  let schedulerTimer: ReturnType<typeof setInterval> | undefined;
540
542
  const startScheduler = options.startScheduler ?? env !== "test";
541
- if (startScheduler && (clock !== undefined || journal !== undefined || instances !== undefined)) {
543
+ const signalBus = signal?.bus ?? undefined;
544
+ let refreshChannel: (() => Promise<void>) | undefined;
545
+ if (
546
+ startScheduler &&
547
+ (clock !== undefined ||
548
+ journal !== undefined ||
549
+ instances !== undefined ||
550
+ signalBus !== undefined ||
551
+ needs.channel)
552
+ ) {
542
553
  const period = options.schedulerIntervalMs ?? 1000;
543
554
  const clockRt = clock;
544
555
  const durableResume = options.onDurableResume;
@@ -550,6 +561,8 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
550
561
  console.error(err);
551
562
  };
552
563
  schedulerTimer = setInterval(() => {
564
+ if (signalBus) void signalBus.drain().catch(ignoreBenignSql);
565
+ if (refreshChannel) void refreshChannel().catch(ignoreBenignSql);
553
566
  if (clockRt) void Promise.resolve(clockRt.tick()).catch(ignoreBenignSql);
554
567
  if (journal && durableResume) void Promise.resolve(durableResume()).catch(ignoreBenignSql);
555
568
  if (fleet) void fleet.maybeHeartbeat().catch(ignoreBenignSql);
@@ -572,7 +585,8 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
572
585
  // 5. Channel
573
586
  let channel = pre.channel;
574
587
  if (needs.channel && !channel) {
575
- channel = channelBind!.bindChannel(options, env, now, docker);
588
+ channel = await channelBind!.bindChannel(options, env, now, docker);
589
+ refreshChannel = channel.refresh?.bind(channel);
576
590
  }
577
591
 
578
592
  // 6. AI
@@ -12,6 +12,17 @@ import type { OkeErrorDefinition } from "./errors.ts";
12
12
  * A Flow hides `fx` from effect inference, or an explicit `effects` block
13
13
  * omits an effect the walk can see.
14
14
  */
15
+ /**
16
+ * A durable Flow calls `fx.fetch` or `fx.send` outside `fx.step`.
17
+ * Those effects are irreversible and must be an explicit step so replay skips them.
18
+ */
19
+ export const DURABLE_BARE_EFFECT: OkeErrorDefinition = {
20
+ code: 1901,
21
+ domain: "compiler",
22
+ cause: 'Durable flow "{flow}" calls fx.{effect} outside fx.step.',
23
+ fix: "Wrap fx.fetch and fx.send in fx.step so replay returns the journaled value instead of repeating the call.",
24
+ };
25
+
15
26
  export const FX_INFERENCE_OPAQUE: OkeErrorDefinition = {
16
27
  code: 1900,
17
28
  domain: "compiler",
@@ -84,6 +84,22 @@ export const OKE_ERROR_TEXT: Readonly<Record<string, OkeErrorText>> = {
84
84
  'Live signal "{signal}" is exposed twice with the same gates ({gates}) and match ({match}).',
85
85
  fix: "Use a different gate or path-param filter, or drop the extra route.",
86
86
  },
87
+ JOURNAL_STALE_LEASE: {
88
+ cause: 'Journal run "{runId}" rejected a write from an expired lease holder.',
89
+ fix: "Resume the run so the current holder acquires a new lease token. Do not write with a token from a previous holder.",
90
+ },
91
+ JOURNAL_REPLAY_DIVERGENCE: {
92
+ cause: 'Journal run "{runId}" expected {expected} but the code called {actual}.',
93
+ fix: "Keep step names, sleep labels, and effect order stable for in-flight runs. A code change that reorders journal calls cannot resume the old run.",
94
+ },
95
+ JOURNAL_CODE_VERSION: {
96
+ cause: 'Journal run "{runId}" was started on code {expected} and this process is {actual}.',
97
+ fix: "Finish or abandon in-flight runs before deploying a different code version. Do not replay them on the new build.",
98
+ },
99
+ JOURNAL_VALUE_NOT_JSON: {
100
+ cause: "A durable journal entry held a value that is not JSON ({detail}).",
101
+ fix: "Return plain JSON from fx.step. fx.fetch and fx.send must run inside fx.step so the journal can store a JSON snapshot.",
102
+ },
87
103
  MCP_TOOL_DUPLICATE: {
88
104
  cause: 'MCP tool "{tool}" is bound twice (flow "{flow}").',
89
105
  fix: "Give each MCP tool exposure a unique tool name.",
@@ -294,6 +294,14 @@ const OKE_ERROR_ROWS = {
294
294
  LIVE_EXPOSURE_DUPLICATE: { code: 1050, domain: "kernel" },
295
295
  /** Two MCP tool bindings share the same exposed tool name. */
296
296
  MCP_TOOL_DUPLICATE: { code: 1060, domain: "kernel" },
297
+ /** A journal write arrived from a holder whose lease token no longer matches. */
298
+ JOURNAL_STALE_LEASE: { code: 1074, domain: "kernel" },
299
+ /** Replay called a different step or effect than the next journal entry. */
300
+ JOURNAL_REPLAY_DIVERGENCE: { code: 1075, domain: "kernel" },
301
+ /** Resume of a run stamped with a different code version. */
302
+ JOURNAL_CODE_VERSION: { code: 1076, domain: "kernel" },
303
+ /** A journaled value could not be stored as JSON. */
304
+ JOURNAL_VALUE_NOT_JSON: { code: 1077, domain: "kernel" },
297
305
  /**
298
306
  * Emit target has no subscriber (unified-theory §21).
299
307
  * Thrown at emit when `optional` is false and nobody is subscribed.