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.
- package/package.json +1 -1
- package/site/content/docs/ai/mcp.mdx +1 -1
- package/site/content/docs/elements/channel/index.mdx +4 -4
- package/site/content/docs/elements/channel/receipts.mdx +7 -5
- package/site/content/docs/elements/channel/sms.mdx +2 -1
- package/site/content/docs/elements/flow/index.mdx +22 -18
- package/site/content/docs/elements/signal/index.mdx +15 -14
- package/site/content/docs/plugins/otp.mdx +3 -3
- package/site/content/docs/reference/fx.mdx +12 -4
- package/src/auth/tenants.ts +17 -0
- package/src/cli/doctor-diff.test.ts +29 -1
- package/src/cli/doctor-diff.ts +35 -0
- package/src/cli/manifest-pr-diff.ts +16 -0
- package/src/compiler/effects-fetch.test.ts +77 -0
- package/src/compiler/effects-infer.ts +87 -16
- package/src/compiler/effects-join.test.ts +48 -0
- package/src/compiler/extract.test.ts +28 -0
- package/src/compiler/extract.ts +11 -1
- package/src/compiler/fx-index.ts +58 -4
- package/src/console/index.ts +1 -1
- package/src/console/ui-next/dist/assets/{access-page-DDKkhT9v.js → access-page-C_N4qvai.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-62Xts8Xi.js → agent-disclosure-Cq1TxniW.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-DyeoKHKA.js → cache-glyph-BaI4uxZz.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-DvfpLXsU.js → call-pii-button-DNyK7IIY.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-BY03SeCg.js → collapsible-CRhBJMZR.js} +1 -1
- package/src/console/ui-next/dist/assets/{decisions-page-CgkKlA56.js → decisions-page-D9twj8Xy.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-Y6HLjyJ5.js → duration-tone-CsHWlVPU.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-DLPfNy-_.js → flows-page-ZaTJAhR9.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-D7nNzpgB.js → highlighted-json-DQ-EMAfz.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-DdL19zzh.js → http-method-Cn_Fl79s.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-C0lc_s9d.js → index-CmRFeWdT.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CRlcyLCC.js → observability-page-Cn6R_dXz.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-Bb3FWUu9.js → replica-lag-Bx6EZhSe.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-DYBMWhX8.js → request-meta-DmdiUrfh.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-CcE-SXC8.js → store-page-8P6vGXnn.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-CKMSEQ4s.js → trace-detail-sheet-BfGvpFSv.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-QOfk0M0H.js → tree-expand-toggle-CWdUHbc0.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-DKC1CDDd.js → units-page-CZOFZzay.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-B54v4Yit.js → vault-page-ki_ishmX.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/drivers/journal-postgres.test.ts +22 -1
- package/src/drivers/journal-postgres.ts +223 -12
- package/src/drivers/signal-compete.test.ts +234 -0
- package/src/drivers/signal-postgres.ts +39 -19
- package/src/drivers/signal-redis.ts +167 -11
- package/src/drivers/signal-types.ts +12 -0
- package/src/elements/ai/mcp-protocol.ts +5 -6
- package/src/elements/ai/runtime.ts +20 -19
- package/src/elements/channel/runtime.ts +11 -0
- package/src/elements/channel/sql-ledger.ts +251 -0
- package/src/elements/signal/runtime.ts +9 -0
- package/src/elements/signal.test.ts +1 -0
- package/src/elements/store/cache.test.ts +120 -15
- package/src/elements/store/cache.ts +200 -39
- package/src/elements/store/prepare-row.test.ts +6 -2
- package/src/elements/store/sql-session.test.ts +2 -0
- package/src/elements/store/sql-session.ts +95 -9
- package/src/elements/store.ts +1 -0
- package/src/kernel/app.ts +32 -16
- package/src/kernel/auto-cache.test.ts +228 -0
- package/src/kernel/boot-bind/channel.ts +63 -2
- package/src/kernel/boot-bind/honor-config.test.ts +40 -10
- package/src/kernel/boot-bind/signal.ts +61 -25
- package/src/kernel/boot.ts +16 -2
- package/src/kernel/errors-compiler.ts +11 -0
- package/src/kernel/errors-text.ts +16 -0
- package/src/kernel/errors.ts +8 -0
- package/src/kernel/flow.ts +33 -3
- package/src/kernel/fx-call-types.test.ts +32 -0
- package/src/kernel/fx-decide.ts +11 -7
- package/src/kernel/fx-sql-handle.ts +46 -7
- package/src/kernel/fx.test.ts +1 -3
- package/src/kernel/fx.ts +27 -16
- package/src/kernel/idempotency.test.ts +2 -1
- package/src/kernel/journal.test.ts +114 -0
- package/src/kernel/journal.ts +369 -53
- package/src/kernel/pipeline-tenant.ts +5 -1
- package/src/kernel/pipeline.ts +5 -0
- package/src/kernel/signal-tx.ts +28 -0
- package/src/kernel/store-transaction.test.ts +95 -0
- package/src/mcp/docs-server.ts +19 -2
- package/src/mcp/mcp.test.ts +83 -0
- package/src/mcp/protocol.ts +8 -2
- package/src/mcp/server.ts +23 -2
- package/src/mcp/versions.ts +60 -0
- package/src/plugins/index.ts +1 -0
- 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
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
)
|
|
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 {
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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 — ' +
|
package/src/kernel/boot.ts
CHANGED
|
@@ -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
|
-
|
|
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.",
|
package/src/kernel/errors.ts
CHANGED
|
@@ -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.
|