okengine 0.23.0 → 0.23.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/ai/mcp.mdx +4 -4
  3. package/site/content/docs/elements/channel/index.mdx +4 -4
  4. package/site/content/docs/elements/channel/receipts.mdx +7 -5
  5. package/site/content/docs/elements/channel/sms.mdx +2 -1
  6. package/site/content/docs/elements/flow/index.mdx +22 -18
  7. package/site/content/docs/elements/signal/broadcast.mdx +4 -2
  8. package/site/content/docs/elements/signal/index.mdx +15 -14
  9. package/site/content/docs/plugins/otp.mdx +3 -3
  10. package/site/content/docs/reference/configuration.mdx +16 -0
  11. package/site/content/docs/reference/errors.mdx +31 -19
  12. package/site/content/docs/reference/fx.mdx +12 -4
  13. package/src/auth/tenants.ts +17 -0
  14. package/src/cli/doctor-diff.test.ts +29 -1
  15. package/src/cli/doctor-diff.ts +35 -0
  16. package/src/cli/manifest-pr-diff.ts +16 -0
  17. package/src/compiler/aot.test.ts +3 -2
  18. package/src/compiler/aot.ts +45 -1
  19. package/src/compiler/dynamic.ts +1 -1
  20. package/src/compiler/effects-fetch.test.ts +77 -0
  21. package/src/compiler/effects-infer.ts +87 -16
  22. package/src/compiler/effects-join.test.ts +48 -0
  23. package/src/compiler/extract.test.ts +28 -0
  24. package/src/compiler/extract.ts +11 -1
  25. package/src/compiler/fx-index.ts +58 -4
  26. package/src/compiler/http-parse.test.ts +101 -0
  27. package/src/compiler/http-parse.ts +188 -9
  28. package/src/compiler/interpret.ts +25 -2
  29. package/src/console/index.ts +1 -1
  30. package/src/console/ui-next/dist/assets/{access-page-DDKkhT9v.js → access-page-zBMhZnFm.js} +1 -1
  31. package/src/console/ui-next/dist/assets/{agent-disclosure-62Xts8Xi.js → agent-disclosure-CBj9f7Ju.js} +1 -1
  32. package/src/console/ui-next/dist/assets/{cache-glyph-DyeoKHKA.js → cache-glyph-D3MY49h3.js} +1 -1
  33. package/src/console/ui-next/dist/assets/{call-pii-button-DvfpLXsU.js → call-pii-button-L9JxdBhp.js} +1 -1
  34. package/src/console/ui-next/dist/assets/{collapsible-BY03SeCg.js → collapsible-BvFaX-Zt.js} +1 -1
  35. package/src/console/ui-next/dist/assets/{decisions-page-CgkKlA56.js → decisions-page-DgB76m-X.js} +1 -1
  36. package/src/console/ui-next/dist/assets/{duration-tone-Y6HLjyJ5.js → duration-tone-l1DSJ2Vz.js} +1 -1
  37. package/src/console/ui-next/dist/assets/{flows-page-DLPfNy-_.js → flows-page-Bb9t1lu6.js} +1 -1
  38. package/src/console/ui-next/dist/assets/{highlighted-json-D7nNzpgB.js → highlighted-json-DMvsi3Ti.js} +1 -1
  39. package/src/console/ui-next/dist/assets/{http-method-DdL19zzh.js → http-method-BwhJBTcQ.js} +1 -1
  40. package/src/console/ui-next/dist/assets/{index-C0lc_s9d.js → index-RhV2jT_7.js} +3 -3
  41. package/src/console/ui-next/dist/assets/{observability-page-CRlcyLCC.js → observability-page-BRfUILBq.js} +1 -1
  42. package/src/console/ui-next/dist/assets/{replica-lag-Bb3FWUu9.js → replica-lag-BsceAzIG.js} +1 -1
  43. package/src/console/ui-next/dist/assets/{request-meta-DYBMWhX8.js → request-meta-C6lTyCXp.js} +1 -1
  44. package/src/console/ui-next/dist/assets/{store-page-CcE-SXC8.js → store-page-YmE806bJ.js} +1 -1
  45. package/src/console/ui-next/dist/assets/{trace-detail-sheet-CKMSEQ4s.js → trace-detail-sheet-Di4irS49.js} +1 -1
  46. package/src/console/ui-next/dist/assets/{tree-expand-toggle-QOfk0M0H.js → tree-expand-toggle-DYeL6Rye.js} +1 -1
  47. package/src/console/ui-next/dist/assets/{units-page-DKC1CDDd.js → units-page-07agasRA.js} +1 -1
  48. package/src/console/ui-next/dist/assets/{vault-page-B54v4Yit.js → vault-page-CHBck4n_.js} +1 -1
  49. package/src/console/ui-next/dist/index.html +1 -1
  50. package/src/console/ui-next/seed-parked-approval.ts +5 -1
  51. package/src/drivers/journal-postgres.test.ts +106 -2
  52. package/src/drivers/journal-postgres.ts +475 -14
  53. package/src/drivers/postgres.test.ts +60 -0
  54. package/src/drivers/postgres.ts +79 -5
  55. package/src/drivers/signal-compete.test.ts +238 -0
  56. package/src/drivers/signal-postgres.test.ts +170 -0
  57. package/src/drivers/signal-postgres.ts +486 -213
  58. package/src/drivers/signal-redis.test.ts +224 -0
  59. package/src/drivers/signal-redis.ts +934 -93
  60. package/src/drivers/signal-types.ts +12 -0
  61. package/src/elements/ai/approval.test.ts +15 -3
  62. package/src/elements/ai/approval.ts +15 -9
  63. package/src/elements/ai/mcp-protocol.ts +5 -6
  64. package/src/elements/ai/runtime.ts +20 -19
  65. package/src/elements/channel/runtime.ts +42 -4
  66. package/src/elements/channel/sql-ledger.test.ts +404 -0
  67. package/src/elements/channel/sql-ledger.ts +395 -0
  68. package/src/elements/clock/chaos-child.ts +12 -2
  69. package/src/elements/clock/durable.ts +11 -0
  70. package/src/elements/signal/runtime.ts +9 -0
  71. package/src/elements/signal.test.ts +1 -0
  72. package/src/elements/store/cache-bus.test.ts +121 -0
  73. package/src/elements/store/cache-bus.ts +278 -0
  74. package/src/elements/store/cache.test.ts +179 -15
  75. package/src/elements/store/cache.ts +357 -51
  76. package/src/elements/store/prepare-row.test.ts +6 -2
  77. package/src/elements/store/runtime.ts +44 -2
  78. package/src/elements/store/sql-errors.test.ts +131 -0
  79. package/src/elements/store/sql-errors.ts +39 -16
  80. package/src/elements/store/sql-nested-tx.test.ts +344 -0
  81. package/src/elements/store/sql-session.test.ts +2 -0
  82. package/src/elements/store/sql-session.ts +275 -9
  83. package/src/elements/store.ts +1 -0
  84. package/src/kernel/app.ts +207 -43
  85. package/src/kernel/auto-cache.test.ts +228 -0
  86. package/src/kernel/auto-registry.test.ts +5 -1
  87. package/src/kernel/boot-bind/channel.ts +63 -2
  88. package/src/kernel/boot-bind/honor-config.test.ts +40 -10
  89. package/src/kernel/boot-bind/signal.ts +72 -25
  90. package/src/kernel/boot-bind/store.ts +16 -1
  91. package/src/kernel/boot.test.ts +2 -2
  92. package/src/kernel/boot.ts +54 -10
  93. package/src/kernel/boundary-contract.ts +7 -0
  94. package/src/kernel/builtin-errors.ts +2 -0
  95. package/src/kernel/call.test.ts +1 -0
  96. package/src/kernel/errors-compiler.ts +11 -0
  97. package/src/kernel/errors-text.ts +16 -0
  98. package/src/kernel/errors.ts +8 -0
  99. package/src/kernel/flow.ts +33 -3
  100. package/src/kernel/fx-call-types.test.ts +32 -0
  101. package/src/kernel/fx-decide.ts +11 -7
  102. package/src/kernel/fx-sql-handle.ts +52 -7
  103. package/src/kernel/fx.test.ts +1 -3
  104. package/src/kernel/fx.ts +27 -16
  105. package/src/kernel/horizontal-child.ts +54 -1
  106. package/src/kernel/horizontal.integration.test.ts +131 -7
  107. package/src/kernel/idempotency.test.ts +4 -4
  108. package/src/kernel/journal-boot.test.ts +83 -6
  109. package/src/kernel/journal.test.ts +158 -0
  110. package/src/kernel/journal.ts +515 -54
  111. package/src/kernel/pipeline-tenant.ts +5 -1
  112. package/src/kernel/pipeline.ts +5 -0
  113. package/src/kernel/signal-tx.ts +28 -0
  114. package/src/kernel/store-transaction.test.ts +95 -0
  115. package/src/mcp/authorization.ts +5 -11
  116. package/src/mcp/confirmation.ts +203 -38
  117. package/src/mcp/docs-server.ts +26 -4
  118. package/src/mcp/index.ts +4 -0
  119. package/src/mcp/mcp.test.ts +404 -33
  120. package/src/mcp/protocol.ts +8 -2
  121. package/src/mcp/server.ts +31 -5
  122. package/src/mcp/session.ts +7 -0
  123. package/src/mcp/tools.ts +68 -19
  124. package/src/mcp/versions.ts +60 -0
  125. package/src/plugins/index.ts +1 -0
  126. package/src/plugins/mena.ts +56 -0
  127. package/src/runtime/bun.ts +5 -0
  128. package/src/runtime/types.ts +5 -0
@@ -182,6 +182,13 @@ export interface BootOptions {
182
182
  readonly onDurableResume?: () => void | Promise<void>;
183
183
  /** Durable-run lease duration ms (default 30_000 — matches Signal claims). */
184
184
  readonly journalLeaseMs?: number;
185
+ /** Opt-in durable code version. Unset skips the stamp and the check. */
186
+ readonly codeVersion?: string;
187
+ /** Tier-1 cache bounds. Defaults: 10_000 entries, 60s TTL. */
188
+ readonly cache?: {
189
+ readonly maxEntries?: number;
190
+ readonly defaultTtlMs?: number;
191
+ };
185
192
  /** Injectable clock for test / frozen harnesses. */
186
193
  readonly now?: () => number;
187
194
  /**
@@ -191,6 +198,8 @@ export interface BootOptions {
191
198
  readonly clients?: {
192
199
  readonly kv?: import("../drivers/types.ts").KvClientLike;
193
200
  readonly signalRedis?: import("../drivers/signal-types.ts").SignalRedisClientLike;
201
+ /** Injected postgres signal client (tests). Production opens Bun.SQL. */
202
+ readonly signalSql?: import("../drivers/signal-postgres.ts").PostgresSignalSql;
194
203
  };
195
204
  /**
196
205
  * Process instance id for Clock / Journal / fleet registry.
@@ -537,8 +546,21 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
537
546
 
538
547
  // 4d. Scheduler — one timer drives clock ticks, durable-run resume, heartbeat.
539
548
  let schedulerTimer: ReturnType<typeof setInterval> | undefined;
549
+ const inflight = new Map<string, Promise<void>>();
550
+ let stopping = false;
540
551
  const startScheduler = options.startScheduler ?? env !== "test";
541
- if (startScheduler && (clock !== undefined || journal !== undefined || instances !== undefined)) {
552
+ const signalBus = signal?.bus ?? undefined;
553
+ let refreshChannel: (() => Promise<void>) | undefined;
554
+ const pollCache = store?.cacheInvalidation === "postgres";
555
+ if (
556
+ startScheduler &&
557
+ (clock !== undefined ||
558
+ journal !== undefined ||
559
+ instances !== undefined ||
560
+ signalBus !== undefined ||
561
+ needs.channel ||
562
+ pollCache)
563
+ ) {
542
564
  const period = options.schedulerIntervalMs ?? 1000;
543
565
  const clockRt = clock;
544
566
  const durableResume = options.onDurableResume;
@@ -549,16 +571,30 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
549
571
  // Unexpected scheduler failures stay visible.
550
572
  console.error(err);
551
573
  };
574
+ const spawn = (name: string, task: () => Promise<unknown>): void => {
575
+ if (stopping || inflight.has(name)) return;
576
+ const run = Promise.resolve()
577
+ .then(task)
578
+ .then(() => undefined)
579
+ .catch(ignoreBenignSql)
580
+ .finally(() => {
581
+ inflight.delete(name);
582
+ });
583
+ inflight.set(name, run);
584
+ };
552
585
  schedulerTimer = setInterval(() => {
553
- if (clockRt) void Promise.resolve(clockRt.tick()).catch(ignoreBenignSql);
554
- if (journal && durableResume) void Promise.resolve(durableResume()).catch(ignoreBenignSql);
555
- if (fleet) void fleet.maybeHeartbeat().catch(ignoreBenignSql);
586
+ if (signalBus) spawn("signal", () => signalBus.drain());
587
+ if (refreshChannel) spawn("channel", () => refreshChannel!());
588
+ if (clockRt) spawn("clock", () => Promise.resolve(clockRt.tick()));
589
+ if (journal && durableResume) spawn("durable", () => Promise.resolve(durableResume()));
590
+ if (fleet) spawn("fleet", () => fleet.maybeHeartbeat());
591
+ if (pollCache && store) spawn("cache", () => store.pollCacheInvalidations());
556
592
  const stem = ["run", "events"].join("-");
557
- void import(`../elements/ai/${stem}.ts`)
558
- .then((mod: typeof import("../elements/ai/run-events.ts")) =>
559
- mod.sweepInstalledAgentEvents(),
560
- )
561
- .catch(ignoreBenignSql);
593
+ spawn("agents", () =>
594
+ import(`../elements/ai/${stem}.ts`).then(
595
+ (mod: typeof import("../elements/ai/run-events.ts")) => mod.sweepInstalledAgentEvents(),
596
+ ),
597
+ );
562
598
  }, period);
563
599
  schedulerTimer.unref?.();
564
600
  }
@@ -572,7 +608,8 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
572
608
  // 5. Channel
573
609
  let channel = pre.channel;
574
610
  if (needs.channel && !channel) {
575
- channel = channelBind!.bindChannel(options, env, now, docker);
611
+ channel = await channelBind!.bindChannel(options, env, now, docker);
612
+ refreshChannel = channel.refresh?.bind(channel);
576
613
  }
577
614
 
578
615
  // 6. AI
@@ -639,7 +676,12 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
639
676
  instanceId,
640
677
  instances,
641
678
  capabilities,
679
+ /**
680
+ * Stop the scheduler interval and wake timers.
681
+ * Does not wait for a tick that has already started — {@link BootResult.close} does.
682
+ */
642
683
  stopScheduler() {
684
+ stopping = true;
643
685
  if (schedulerTimer !== undefined) {
644
686
  clearInterval(schedulerTimer);
645
687
  schedulerTimer = undefined;
@@ -647,11 +689,13 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
647
689
  clock?.stopWakes();
648
690
  },
649
691
  async close() {
692
+ stopping = true;
650
693
  if (schedulerTimer !== undefined) {
651
694
  clearInterval(schedulerTimer);
652
695
  schedulerTimer = undefined;
653
696
  }
654
697
  clock?.stopWakes();
698
+ await Promise.all([...inflight.values()]);
655
699
  await signal?.close();
656
700
  await vault?.close();
657
701
  await runs?.flush();
@@ -30,6 +30,13 @@ export interface BoundaryContract<
30
30
  * (`oke doctor --diff` / CI gate).
31
31
  */
32
32
  readonly breaking?: boolean;
33
+ /** Per-route body cap in bytes. Falls back to the app `maxRequestBodySize`. */
34
+ readonly maxBodySize?: number;
35
+ /**
36
+ * `required` (default) rejects a JSON-shaped body that is not
37
+ * `application/json` or `+json`. `any` keeps the previous parse-or-string behavior.
38
+ */
39
+ readonly jsonContentType?: "required" | "any";
33
40
  }
34
41
 
35
42
  /** Infer validated input from a {@link BoundaryContract} bag. */
@@ -15,6 +15,7 @@ export const BUILTIN_ERROR_STATUS = {
15
15
  Conflict: 409,
16
16
  ForeignKey: 409,
17
17
  UnsupportedMediaType: 415,
18
+ PayloadTooLarge: 413,
18
19
  RateLimited: 429,
19
20
  AuthRateLimited: 429,
20
21
  InvalidQuery: 400,
@@ -78,6 +79,7 @@ export type BuiltinErrorMap = {
78
79
  readonly Conflict: BuiltinErrorBag;
79
80
  readonly ForeignKey: BuiltinErrorBag;
80
81
  readonly UnsupportedMediaType: BuiltinErrorBag;
82
+ readonly PayloadTooLarge: BuiltinErrorBag;
81
83
  readonly RateLimited: { readonly retryAfterMs?: number };
82
84
  readonly AuthRateLimited: BuiltinErrorBag;
83
85
  readonly InvalidQuery: BuiltinErrorBag;
@@ -112,6 +112,7 @@ describe("fx.call — untriggered flows", () => {
112
112
  const a = await app.fetch(
113
113
  new Request("http://localhost/work", {
114
114
  method: "POST",
115
+ headers: { "content-type": "application/json" },
115
116
  body: JSON.stringify({ v: 10 }),
116
117
  }),
117
118
  );
@@ -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.
@@ -49,9 +49,9 @@ export interface FlowOptions<I = unknown, O = unknown> {
49
49
  */
50
50
  readonly retry?: FxRetryOptions;
51
51
  /**
52
- * Cache policy. Read-only flows cache automatically from inferred
53
- * effects (`true` / omitted). `false` opts out; a duration string
54
- * (`"30s"`) adds a TTL on top of write invalidation.
52
+ * Cache policy. Omitted caches a pure store-read flow. `true` or a duration
53
+ * (`"30s"`) opts one flow in when the app set `cache: { auto: false }`.
54
+ * `false` always disables. The key includes tenant, locale, scopes, and roles.
55
55
  */
56
56
  readonly cache?: boolean | string;
57
57
  /**
@@ -124,6 +124,10 @@ export type InferFlowOut<Opts> = Opts extends { readonly do: FlowHandler<infer _
124
124
  * `FlowDef<…, undefined>` remains assignable into `on()` before rebinding.
125
125
  */
126
126
  declare const triggerPhantom: unique symbol;
127
+ /** Covariant input phantom so `fx.call` can read `I` without hitting `do`. */
128
+ declare const inputPhantom: unique symbol;
129
+ /** Covariant output phantom so `fx.call` can read `O` without hitting `do`. */
130
+ declare const outputPhantom: unique symbol;
127
131
 
128
132
  /**
129
133
  * A Flow definition — one species, trigger-agnostic until {@link on} binds.
@@ -151,6 +155,10 @@ export interface FlowDef<
151
155
  * `$trigger` / `triggers[0]` for the value.
152
156
  */
153
157
  readonly [triggerPhantom]?: T;
158
+ /** Type-level input. Not present at runtime. */
159
+ readonly [inputPhantom]?: I;
160
+ /** Type-level output. Not present at runtime. */
161
+ readonly [outputPhantom]?: O;
154
162
  /** Stable name (auto-assigned when omitted). */
155
163
  readonly name: string;
156
164
  /** Unit scope — derived from `name`'s first dot segment (e.g. `"auth.refresh"` → `"auth"`). */
@@ -255,6 +263,28 @@ export interface FlowDef<
255
263
  ): FlowDef<I, O, E, D & (P extends PluginDef<infer PD> ? PD : Record<string, never>), T>;
256
264
  }
257
265
 
266
+ /**
267
+ * Input type of a Flow handle. `unknown` when `F` is a string name.
268
+ *
269
+ * @typeParam F - Flow handle or other call target
270
+ */
271
+ export type FlowInput<F> = F extends { readonly [inputPhantom]?: infer I }
272
+ ? [I] extends [undefined]
273
+ ? unknown
274
+ : Exclude<I, undefined>
275
+ : unknown;
276
+
277
+ /**
278
+ * Output type of a Flow handle. `unknown` when `F` is a string name.
279
+ *
280
+ * @typeParam F - Flow handle or other call target
281
+ */
282
+ export type FlowOutput<F> = F extends { readonly [outputPhantom]?: infer O }
283
+ ? [O] extends [undefined]
284
+ ? unknown
285
+ : Exclude<O, undefined>
286
+ : unknown;
287
+
258
288
  /** Unique brand symbol for {@link FlowDef}. */
259
289
  export const flowBrand: unique symbol = Symbol("oke.flow");
260
290
 
@@ -0,0 +1,32 @@
1
+ /**
2
+ * `fx.call(FlowDef)` is typed; a string name stays `Promise<unknown>`.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { flow } from "./flow.ts";
7
+ import { createFxContext, type Fx } from "./fx.ts";
8
+
9
+ describe("fx.call — FlowDef inference", () => {
10
+ test("a flow handle types input and output; a string name does not", () => {
11
+ const load = flow("notes.load", {
12
+ do: async (input: { id: string }) => ({ id: input.id, title: "n" }),
13
+ });
14
+ const { fx } = createFxContext({
15
+ flow: "notes.copy",
16
+ effects: { calls: ["notes.load"] },
17
+ });
18
+
19
+ const typed: (id: string) => Promise<{ id: string; title: string }> = (id) =>
20
+ fx.call(load, { id });
21
+ const untyped: (name: string) => Promise<unknown> = (name) => fx.call(name, { id: "1" });
22
+
23
+ expect(typeof typed).toBe("function");
24
+ expect(typeof untyped).toBe("function");
25
+
26
+ function _wrongInput(fxArg: Fx): void {
27
+ // @ts-expect-error input must match the flow handler
28
+ void fxArg.call(load, { id: 1 });
29
+ }
30
+ expect(typeof _wrongInput).toBe("function");
31
+ });
32
+ });
@@ -208,19 +208,23 @@ async function executeDecide(options: FxDecideInput, name: string): Promise<unkn
208
208
  const stored = (await options.journal.step(step, () =>
209
209
  pendingRecord(options, view, false, decl),
210
210
  )) as DecisionReviewRecord;
211
- if (stored.status === "pending") {
212
- await options.journal.sleep(step, "876000h", () => 876000 * 60 * 60 * 1000);
213
- }
211
+ // Always consume the sleep row. Resolve moves wakeAt to now.
212
+ await options.journal.sleep(step, "876000h", () => 876000 * 60 * 60 * 1000);
214
213
  const resolved = readStepRecord(options.journal, step) ?? stored;
215
214
  return reviewed(view, resolved.values ?? {});
216
215
  }
217
216
 
218
- /** Completed outer `decide` effects for this name. The current call is not stored yet. */
217
+ /** Completed `decide-provider` rows for this name. The current call is not included. */
219
218
  function decideOrdinal(journal: JournalSession | undefined, name: string): number {
220
219
  if (!journal) return 0;
221
- return journal.run.entries.filter(
222
- (entry) => entry.kind === "effect" && entry.effectKind === "decide" && entry.resource === name,
223
- ).length;
220
+ return journal
221
+ .recordedBeforeCursor()
222
+ .filter(
223
+ (entry) =>
224
+ entry.kind === "effect" &&
225
+ entry.effectKind === "decide-provider" &&
226
+ entry.resource.startsWith(`${name}#`),
227
+ ).length;
224
228
  }
225
229
 
226
230
  function readStepRecord(journal: JournalSession, step: string): DecisionReviewRecord | undefined {
@@ -9,6 +9,7 @@ import type { SqlRow } from "../drivers/types.ts";
9
9
  import type {
10
10
  SelectFromBuilder,
11
11
  SelectOrderBuilder,
12
+ SqlPredicate,
12
13
  SqlStoreHandle,
13
14
  StoreDecl,
14
15
  } from "../elements/store.ts";
@@ -16,6 +17,7 @@ import { schemaTableName, sqlTableRef } from "../manifest/sql-resource.ts";
16
17
  import type { CapabilityToken } from "./capability.ts";
17
18
  import { DryRunWriteIsolationError, isDryRun } from "./dry-run.ts";
18
19
  import type { EffectExternal } from "./effects.ts";
20
+ import { currentSignalTransaction, runInSignalTransaction } from "./signal-tx.ts";
19
21
 
20
22
  /** Capability-gated effect runner from {@link createFx}. */
21
23
  type Gated = <T>(
@@ -39,6 +41,13 @@ export interface GatedSqlHandleOptions {
39
41
  readonly capability: CapabilityToken;
40
42
  /** `fx.ask` — optional search rerank. */
41
43
  readonly ask: (model: string, input: unknown) => Promise<unknown>;
44
+ /**
45
+ * Open the signal outbox for emits inside {@link SqlStoreHandle.transaction}.
46
+ * Absent when the app has no signal runtime.
47
+ */
48
+ readonly beginSignal?: () => Promise<
49
+ import("../drivers/signal-types.ts").SignalTransaction | undefined
50
+ >;
42
51
  }
43
52
 
44
53
  /**
@@ -90,7 +99,7 @@ export function createGatedSqlHandle(options: GatedSqlHandleOptions): SqlStoreHa
90
99
  return gated(kind, ref, body, externalOf);
91
100
  };
92
101
 
93
- return {
102
+ const api = {
94
103
  ref,
95
104
  get routedRole() {
96
105
  return cached?.routedRole ?? "primary";
@@ -102,7 +111,7 @@ export function createGatedSqlHandle(options: GatedSqlHandleOptions): SqlStoreHa
102
111
  return {
103
112
  from(table: unknown) {
104
113
  const run = (plan: {
105
- where?: unknown;
114
+ where?: SqlPredicate<SqlRow> | null;
106
115
  orders?: readonly unknown[];
107
116
  limit?: number;
108
117
  offset?: number;
@@ -110,7 +119,8 @@ export function createGatedSqlHandle(options: GatedSqlHandleOptions): SqlStoreHa
110
119
  gatedTable("read", table, async () => {
111
120
  const h = await ensure();
112
121
  const from = h.select(columns).from(table) as SelectFromBuilder;
113
- const filtered = plan.where === undefined ? from : from.where(plan.where);
122
+ const filtered =
123
+ plan.where === undefined || plan.where === null ? from : from.where(plan.where);
114
124
  const ordered =
115
125
  plan.orders === undefined ? filtered : filtered.orderBy(...plan.orders);
116
126
  if (plan.offset !== undefined) return ordered.offset(plan.offset);
@@ -118,7 +128,7 @@ export function createGatedSqlHandle(options: GatedSqlHandleOptions): SqlStoreHa
118
128
  });
119
129
 
120
130
  const tail = (plan: {
121
- where?: unknown;
131
+ where?: SqlPredicate<SqlRow> | null;
122
132
  orders?: readonly unknown[];
123
133
  }): SelectOrderBuilder => ({
124
134
  limit(n) {
@@ -133,7 +143,7 @@ export function createGatedSqlHandle(options: GatedSqlHandleOptions): SqlStoreHa
133
143
  });
134
144
 
135
145
  return {
136
- where(where: unknown) {
146
+ where(where: SqlPredicate<SqlRow>) {
137
147
  return {
138
148
  ...tail({ where }),
139
149
  orderBy: (...orders: readonly unknown[]) => tail({ where, orders }),
@@ -185,7 +195,7 @@ export function createGatedSqlHandle(options: GatedSqlHandleOptions): SqlStoreHa
185
195
  return {
186
196
  set(row) {
187
197
  return {
188
- where(where) {
198
+ where(where: SqlPredicate<SqlRow>) {
189
199
  return gatedTable("write", table, async () => {
190
200
  refuseDryRunWrite();
191
201
  const h = await ensure();
@@ -211,7 +221,7 @@ export function createGatedSqlHandle(options: GatedSqlHandleOptions): SqlStoreHa
211
221
  });
212
222
  }
213
223
  return {
214
- where(where: unknown) {
224
+ where(where: SqlPredicate<SqlRow>) {
215
225
  return gatedTable("write", table, async () => {
216
226
  refuseDryRunWrite();
217
227
  const h = await ensure();
@@ -240,6 +250,40 @@ export function createGatedSqlHandle(options: GatedSqlHandleOptions): SqlStoreHa
240
250
  return h.increment(table, id, column, by);
241
251
  });
242
252
  },
253
+ async transaction(fn) {
254
+ refuseDryRunWrite();
255
+ const h = await ensure();
256
+ // Already inside a signal transaction: do not begin or commit another
257
+ // one. sql-session opens a child staging scope and merges it; only the
258
+ // outermost commit publishes.
259
+ if (currentSignalTransaction()) {
260
+ return h.transaction(() => fn(api));
261
+ }
262
+ const signalTx = options.beginSignal ? await options.beginSignal() : undefined;
263
+ try {
264
+ const result = await h.transaction(async () => {
265
+ const body = () => fn(api);
266
+ if (!signalTx) return body();
267
+ return runInSignalTransaction(signalTx, body);
268
+ });
269
+ if (signalTx) await signalTx.commit();
270
+ return result;
271
+ } catch (err) {
272
+ if (signalTx) await signalTx.rollback();
273
+ throw err;
274
+ }
275
+ },
276
+ run(builder) {
277
+ const text = builder.toSQL().sql;
278
+ const kind = /^(insert|update|delete|create|drop|alter|truncate|replace)\b/i.test(text.trim())
279
+ ? "write"
280
+ : "read";
281
+ return gated(kind, ref, async () => {
282
+ if (kind === "write") refuseDryRunWrite();
283
+ const h = await ensure();
284
+ return h.run(builder);
285
+ });
286
+ },
243
287
  raw(sql, params) {
244
288
  return gated("read", ref, async () => {
245
289
  const h = await ensure();
@@ -302,4 +346,5 @@ export function createGatedSqlHandle(options: GatedSqlHandleOptions): SqlStoreHa
302
346
  });
303
347
  },
304
348
  } as SqlStoreHandle;
349
+ return api;
305
350
  }
@@ -436,9 +436,7 @@ describe("fx — wholesale swap", () => {
436
436
  live() {
437
437
  throw new Error("live must not be used");
438
438
  },
439
- async call() {
440
- return null;
441
- },
439
+ call: (async () => null) as Fx["call"],
442
440
  clock: {
443
441
  now: () => 0,
444
442
  ago: () => 0,
package/src/kernel/fx.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  type JsonStreamResult,
18
18
  } from "./json-result.ts";
19
19
  import type { Effects, ResourceRef, SignalResourceRef } from "../manifest/types.ts";
20
+ import type { FlowInput, FlowOutput } from "./flow.ts";
20
21
  import { isMcpToolRef } from "../manifest/mcp-ref.ts";
21
22
  import type { QueryPageSpec } from "./list-page.ts";
22
23
  import type {
@@ -36,6 +37,7 @@ import { rlsIdentityFromAuth } from "../elements/store.ts";
36
37
  import type { RlsIdentity } from "../drivers/pg-rls.ts";
37
38
  import type { SignalDecl, SignalRuntime } from "../elements/signal.ts";
38
39
  import type { DeadLetter, SignalEmitOptions } from "../drivers/signal-types.ts";
40
+ import { currentSignalTransaction } from "./signal-tx.ts";
39
41
  import type { VaultActor, VaultAdapter, VaultRuntime } from "../elements/vault.ts";
40
42
  import type { ChannelRuntime } from "../elements/channel.ts";
41
43
  import type { AgentApprovalResolveResult } from "../elements/ai/approval.ts";
@@ -172,6 +174,12 @@ export type { FxRetryOptions, FxThunk } from "./concurrency.ts";
172
174
  /** Named ref: plain string or `{ name }` element handle (optional pin). */
173
175
  export type NamedRef = string | { readonly name: string; readonly version?: number };
174
176
 
177
+ /** Input of {@link Fx.call}: the flow's input, or `unknown` for a name. */
178
+ type FxCallInput<F> = FlowInput<F>;
179
+
180
+ /** Result of {@link Fx.call}: the flow's output, or `unknown` for a name. */
181
+ type FxCallResult<F> = Promise<FlowOutput<F>>;
182
+
175
183
  /** Resolve a {@link NamedRef} to its string id (`name@version` when pinned). */
176
184
  export function resolveName(ref: NamedRef): string {
177
185
  if (typeof ref === "string") return ref;
@@ -768,7 +776,7 @@ export interface Fx {
768
776
  * @param flow - Flow name or handle
769
777
  * @param input - Input payload
770
778
  */
771
- call(flow: NamedRef, input?: unknown): Promise<unknown>;
779
+ call<F extends NamedRef>(flow: F, input?: FxCallInput<F>): FxCallResult<F>;
772
780
  /**
773
781
  * Query the Runs wide-event store (records `read` on `"runs"`).
774
782
  * Requires a bound runs runtime and `effects.reads` including `"runs"`.
@@ -790,10 +798,6 @@ export interface Fx {
790
798
  /**
791
799
  * Send a provider-managed SMS OTP (records `send` on `sms-otp`).
792
800
  *
793
- * Vendor extra (Taqnyat Verify API) — requires a bound SMS driver that
794
- * supports provider-managed OTP. Dry-run records would-have-fired without
795
- * contacting the provider.
796
- *
797
801
  * @param opts - Recipient + requestId (+ lang / note / from)
798
802
  */
799
803
  sendOtp(opts: FxSendOtpOptions): Promise<{ ok: true }>;
@@ -806,9 +810,6 @@ export interface Fx {
806
810
  /**
807
811
  * Deliver an app-owned OTP across declared channels (records `send` on `auth-otp`).
808
812
  *
809
- * Tier-2 only — uses Channel `deliverOtp` (sently FallbackTransport). Pass
810
- * `only` for explicit user resend (single channel, no cross-medium failover).
811
- *
812
813
  * @param opts - Channels, templates, addresses, OTP data
813
814
  */
814
815
  deliverOtp(opts: FxDeliverOtpOptions): Promise<{
@@ -1407,6 +1408,11 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1407
1408
  gated,
1408
1409
  capability,
1409
1410
  ask: (model, input) => fx.ask(model, input),
1411
+ beginSignal: async () => {
1412
+ const bus = options.signalRuntime?.bus;
1413
+ if (!bus) return undefined;
1414
+ return bus.begin();
1415
+ },
1410
1416
  });
1411
1417
  }
1412
1418
 
@@ -1783,13 +1789,18 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1783
1789
  emit(signal: NamedRef, payload?: unknown, emitOptions?: SignalEmitOptions) {
1784
1790
  const name = resolveName(signal);
1785
1791
  return gated("emit", name, async () => {
1792
+ const merged: SignalEmitOptions = {
1793
+ ...emitOptions,
1794
+ ...(options.runId !== undefined && emitOptions?.parentRunId === undefined
1795
+ ? { parentRunId: options.runId }
1796
+ : {}),
1797
+ };
1798
+ const pending = currentSignalTransaction();
1799
+ if (pending) {
1800
+ await pending.emit(name, payload, merged);
1801
+ return;
1802
+ }
1786
1803
  if (options.signalRuntime) {
1787
- const merged: SignalEmitOptions = {
1788
- ...emitOptions,
1789
- ...(options.runId !== undefined && emitOptions?.parentRunId === undefined
1790
- ? { parentRunId: options.runId }
1791
- : {}),
1792
- };
1793
1804
  await options.signalRuntime.emit(name, payload, merged);
1794
1805
  }
1795
1806
  });
@@ -1815,7 +1826,7 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1815
1826
  signalRuntime: options.signalRuntime,
1816
1827
  }) as JsonStreamResult;
1817
1828
  },
1818
- call(flow, input) {
1829
+ call<F extends NamedRef>(flow: F, input?: FxCallInput<F>): FxCallResult<F> {
1819
1830
  const name = resolveName(flow);
1820
1831
  return gated("call", name, async () => {
1821
1832
  if (isMcpToolRef(name)) {
@@ -1828,7 +1839,7 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1828
1839
  return options.callHandler(name, input);
1829
1840
  }
1830
1841
  return undefined;
1831
- });
1842
+ }) as FxCallResult<F>;
1832
1843
  },
1833
1844
  clock,
1834
1845
  vault: vaultSurface,