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.
- package/package.json +1 -1
- package/site/content/docs/ai/mcp.mdx +4 -4
- 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/broadcast.mdx +4 -2
- package/site/content/docs/elements/signal/index.mdx +15 -14
- package/site/content/docs/plugins/otp.mdx +3 -3
- package/site/content/docs/reference/configuration.mdx +16 -0
- package/site/content/docs/reference/errors.mdx +31 -19
- 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/aot.test.ts +3 -2
- package/src/compiler/aot.ts +45 -1
- package/src/compiler/dynamic.ts +1 -1
- 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/compiler/http-parse.test.ts +101 -0
- package/src/compiler/http-parse.ts +188 -9
- package/src/compiler/interpret.ts +25 -2
- package/src/console/index.ts +1 -1
- package/src/console/ui-next/dist/assets/{access-page-DDKkhT9v.js → access-page-zBMhZnFm.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-62Xts8Xi.js → agent-disclosure-CBj9f7Ju.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-DyeoKHKA.js → cache-glyph-D3MY49h3.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-DvfpLXsU.js → call-pii-button-L9JxdBhp.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-BY03SeCg.js → collapsible-BvFaX-Zt.js} +1 -1
- package/src/console/ui-next/dist/assets/{decisions-page-CgkKlA56.js → decisions-page-DgB76m-X.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-Y6HLjyJ5.js → duration-tone-l1DSJ2Vz.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-DLPfNy-_.js → flows-page-Bb9t1lu6.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-D7nNzpgB.js → highlighted-json-DMvsi3Ti.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-DdL19zzh.js → http-method-BwhJBTcQ.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-C0lc_s9d.js → index-RhV2jT_7.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CRlcyLCC.js → observability-page-BRfUILBq.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-Bb3FWUu9.js → replica-lag-BsceAzIG.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-DYBMWhX8.js → request-meta-C6lTyCXp.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-CcE-SXC8.js → store-page-YmE806bJ.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-CKMSEQ4s.js → trace-detail-sheet-Di4irS49.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-QOfk0M0H.js → tree-expand-toggle-DYeL6Rye.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-DKC1CDDd.js → units-page-07agasRA.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-B54v4Yit.js → vault-page-CHBck4n_.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/seed-parked-approval.ts +5 -1
- package/src/drivers/journal-postgres.test.ts +106 -2
- package/src/drivers/journal-postgres.ts +475 -14
- package/src/drivers/postgres.test.ts +60 -0
- package/src/drivers/postgres.ts +79 -5
- package/src/drivers/signal-compete.test.ts +238 -0
- package/src/drivers/signal-postgres.test.ts +170 -0
- package/src/drivers/signal-postgres.ts +486 -213
- package/src/drivers/signal-redis.test.ts +224 -0
- package/src/drivers/signal-redis.ts +934 -93
- package/src/drivers/signal-types.ts +12 -0
- package/src/elements/ai/approval.test.ts +15 -3
- package/src/elements/ai/approval.ts +15 -9
- package/src/elements/ai/mcp-protocol.ts +5 -6
- package/src/elements/ai/runtime.ts +20 -19
- package/src/elements/channel/runtime.ts +42 -4
- package/src/elements/channel/sql-ledger.test.ts +404 -0
- package/src/elements/channel/sql-ledger.ts +395 -0
- package/src/elements/clock/chaos-child.ts +12 -2
- package/src/elements/clock/durable.ts +11 -0
- package/src/elements/signal/runtime.ts +9 -0
- package/src/elements/signal.test.ts +1 -0
- package/src/elements/store/cache-bus.test.ts +121 -0
- package/src/elements/store/cache-bus.ts +278 -0
- package/src/elements/store/cache.test.ts +179 -15
- package/src/elements/store/cache.ts +357 -51
- package/src/elements/store/prepare-row.test.ts +6 -2
- package/src/elements/store/runtime.ts +44 -2
- package/src/elements/store/sql-errors.test.ts +131 -0
- package/src/elements/store/sql-errors.ts +39 -16
- package/src/elements/store/sql-nested-tx.test.ts +344 -0
- package/src/elements/store/sql-session.test.ts +2 -0
- package/src/elements/store/sql-session.ts +275 -9
- package/src/elements/store.ts +1 -0
- package/src/kernel/app.ts +207 -43
- package/src/kernel/auto-cache.test.ts +228 -0
- package/src/kernel/auto-registry.test.ts +5 -1
- 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 +72 -25
- package/src/kernel/boot-bind/store.ts +16 -1
- package/src/kernel/boot.test.ts +2 -2
- package/src/kernel/boot.ts +54 -10
- package/src/kernel/boundary-contract.ts +7 -0
- package/src/kernel/builtin-errors.ts +2 -0
- package/src/kernel/call.test.ts +1 -0
- 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 +52 -7
- package/src/kernel/fx.test.ts +1 -3
- package/src/kernel/fx.ts +27 -16
- package/src/kernel/horizontal-child.ts +54 -1
- package/src/kernel/horizontal.integration.test.ts +131 -7
- package/src/kernel/idempotency.test.ts +4 -4
- package/src/kernel/journal-boot.test.ts +83 -6
- package/src/kernel/journal.test.ts +158 -0
- package/src/kernel/journal.ts +515 -54
- 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/authorization.ts +5 -11
- package/src/mcp/confirmation.ts +203 -38
- package/src/mcp/docs-server.ts +26 -4
- package/src/mcp/index.ts +4 -0
- package/src/mcp/mcp.test.ts +404 -33
- package/src/mcp/protocol.ts +8 -2
- package/src/mcp/server.ts +31 -5
- package/src/mcp/session.ts +7 -0
- package/src/mcp/tools.ts +68 -19
- package/src/mcp/versions.ts +60 -0
- package/src/plugins/index.ts +1 -0
- package/src/plugins/mena.ts +56 -0
- package/src/runtime/bun.ts +5 -0
- 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", {
|
|
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
|
|
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,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 {
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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
|
-
|
|
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
|
});
|
package/src/kernel/boot.test.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|