okengine 0.23.1 → 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 (83) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/ai/mcp.mdx +3 -3
  3. package/site/content/docs/elements/signal/broadcast.mdx +4 -2
  4. package/site/content/docs/reference/configuration.mdx +16 -0
  5. package/site/content/docs/reference/errors.mdx +31 -19
  6. package/src/compiler/aot.test.ts +3 -2
  7. package/src/compiler/aot.ts +45 -1
  8. package/src/compiler/dynamic.ts +1 -1
  9. package/src/compiler/http-parse.test.ts +101 -0
  10. package/src/compiler/http-parse.ts +188 -9
  11. package/src/compiler/interpret.ts +25 -2
  12. package/src/console/ui-next/dist/assets/{access-page-C_N4qvai.js → access-page-zBMhZnFm.js} +1 -1
  13. package/src/console/ui-next/dist/assets/{agent-disclosure-Cq1TxniW.js → agent-disclosure-CBj9f7Ju.js} +1 -1
  14. package/src/console/ui-next/dist/assets/{cache-glyph-BaI4uxZz.js → cache-glyph-D3MY49h3.js} +1 -1
  15. package/src/console/ui-next/dist/assets/{call-pii-button-DNyK7IIY.js → call-pii-button-L9JxdBhp.js} +1 -1
  16. package/src/console/ui-next/dist/assets/{collapsible-CRhBJMZR.js → collapsible-BvFaX-Zt.js} +1 -1
  17. package/src/console/ui-next/dist/assets/{decisions-page-D9twj8Xy.js → decisions-page-DgB76m-X.js} +1 -1
  18. package/src/console/ui-next/dist/assets/{duration-tone-CsHWlVPU.js → duration-tone-l1DSJ2Vz.js} +1 -1
  19. package/src/console/ui-next/dist/assets/{flows-page-ZaTJAhR9.js → flows-page-Bb9t1lu6.js} +1 -1
  20. package/src/console/ui-next/dist/assets/{highlighted-json-DQ-EMAfz.js → highlighted-json-DMvsi3Ti.js} +1 -1
  21. package/src/console/ui-next/dist/assets/{http-method-Cn_Fl79s.js → http-method-BwhJBTcQ.js} +1 -1
  22. package/src/console/ui-next/dist/assets/{index-CmRFeWdT.js → index-RhV2jT_7.js} +3 -3
  23. package/src/console/ui-next/dist/assets/{observability-page-Cn6R_dXz.js → observability-page-BRfUILBq.js} +1 -1
  24. package/src/console/ui-next/dist/assets/{replica-lag-Bx6EZhSe.js → replica-lag-BsceAzIG.js} +1 -1
  25. package/src/console/ui-next/dist/assets/{request-meta-DmdiUrfh.js → request-meta-C6lTyCXp.js} +1 -1
  26. package/src/console/ui-next/dist/assets/{store-page-8P6vGXnn.js → store-page-YmE806bJ.js} +1 -1
  27. package/src/console/ui-next/dist/assets/{trace-detail-sheet-BfGvpFSv.js → trace-detail-sheet-Di4irS49.js} +1 -1
  28. package/src/console/ui-next/dist/assets/{tree-expand-toggle-CWdUHbc0.js → tree-expand-toggle-DYeL6Rye.js} +1 -1
  29. package/src/console/ui-next/dist/assets/{units-page-CZOFZzay.js → units-page-07agasRA.js} +1 -1
  30. package/src/console/ui-next/dist/assets/{vault-page-ki_ishmX.js → vault-page-CHBck4n_.js} +1 -1
  31. package/src/console/ui-next/dist/index.html +1 -1
  32. package/src/console/ui-next/seed-parked-approval.ts +5 -1
  33. package/src/drivers/journal-postgres.test.ts +84 -1
  34. package/src/drivers/journal-postgres.ts +282 -32
  35. package/src/drivers/postgres.test.ts +60 -0
  36. package/src/drivers/postgres.ts +79 -5
  37. package/src/drivers/signal-compete.test.ts +6 -2
  38. package/src/drivers/signal-postgres.test.ts +170 -0
  39. package/src/drivers/signal-postgres.ts +449 -196
  40. package/src/drivers/signal-redis.test.ts +224 -0
  41. package/src/drivers/signal-redis.ts +835 -150
  42. package/src/elements/ai/approval.test.ts +15 -3
  43. package/src/elements/ai/approval.ts +15 -9
  44. package/src/elements/channel/runtime.ts +31 -4
  45. package/src/elements/channel/sql-ledger.test.ts +404 -0
  46. package/src/elements/channel/sql-ledger.ts +177 -33
  47. package/src/elements/clock/chaos-child.ts +12 -2
  48. package/src/elements/clock/durable.ts +11 -0
  49. package/src/elements/store/cache-bus.test.ts +121 -0
  50. package/src/elements/store/cache-bus.ts +278 -0
  51. package/src/elements/store/cache.test.ts +59 -0
  52. package/src/elements/store/cache.ts +157 -12
  53. package/src/elements/store/runtime.ts +44 -2
  54. package/src/elements/store/sql-errors.test.ts +131 -0
  55. package/src/elements/store/sql-errors.ts +39 -16
  56. package/src/elements/store/sql-nested-tx.test.ts +344 -0
  57. package/src/elements/store/sql-session.ts +183 -3
  58. package/src/kernel/app.ts +176 -28
  59. package/src/kernel/auto-registry.test.ts +5 -1
  60. package/src/kernel/boot-bind/signal.ts +13 -2
  61. package/src/kernel/boot-bind/store.ts +16 -1
  62. package/src/kernel/boot.test.ts +2 -2
  63. package/src/kernel/boot.ts +41 -11
  64. package/src/kernel/boundary-contract.ts +7 -0
  65. package/src/kernel/builtin-errors.ts +2 -0
  66. package/src/kernel/call.test.ts +1 -0
  67. package/src/kernel/fx-sql-handle.ts +7 -1
  68. package/src/kernel/horizontal-child.ts +54 -1
  69. package/src/kernel/horizontal.integration.test.ts +131 -7
  70. package/src/kernel/idempotency.test.ts +5 -6
  71. package/src/kernel/journal-boot.test.ts +83 -6
  72. package/src/kernel/journal.test.ts +44 -0
  73. package/src/kernel/journal.ts +171 -26
  74. package/src/mcp/authorization.ts +5 -11
  75. package/src/mcp/confirmation.ts +203 -38
  76. package/src/mcp/docs-server.ts +7 -2
  77. package/src/mcp/index.ts +4 -0
  78. package/src/mcp/mcp.test.ts +321 -33
  79. package/src/mcp/server.ts +8 -3
  80. package/src/mcp/session.ts +7 -0
  81. package/src/mcp/tools.ts +68 -19
  82. package/src/runtime/bun.ts +5 -0
  83. package/src/runtime/types.ts +5 -0
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { DomainDdlMode } from "../../config/index.ts";
9
+ import type { SignalEmitOptions, SignalTransaction } from "../../drivers/signal-types.ts";
9
10
  import type { ClassificationMap, SqlConnection, SqlRow } from "../../drivers/types.ts";
10
11
  import {
11
12
  buildRlsIdentityPreludeSql,
@@ -16,6 +17,7 @@ import {
16
17
  import { throwOke } from "../../kernel/errors.ts";
17
18
  import { isFlowFailure } from "../../kernel/hooks.ts";
18
19
  import { lazyRequire } from "../../kernel/lazy-require.ts";
20
+ import { currentSignalTransaction, runInSignalTransaction } from "../../kernel/signal-tx.ts";
19
21
  import { isRetryableSqlError, sqlErrorToFailure } from "./sql-errors.ts";
20
22
  import { AsyncLocalStorage } from "node:async_hooks";
21
23
  import { maskRows, tableFromSql } from "./classify.ts";
@@ -137,8 +139,28 @@ function notifySqlCdc(event: {
137
139
  */
138
140
  const cdcMutationStorage = new AsyncLocalStorage<{ readonly mutationId: string }>();
139
141
 
142
+ /**
143
+ * One frame of {@link SqlStoreHandle.transaction}.
144
+ * Depth 0 is the driver `BEGIN`. Deeper frames are savepoints on that pin.
145
+ */
146
+ interface SqlTxFrame {
147
+ readonly connection: SqlConnection;
148
+ /** 0 for the driver transaction; 1+ for `SAVEPOINT`. */
149
+ readonly depth: number;
150
+ /**
151
+ * Promise chain for direct child transactions on this pin.
152
+ * Siblings append here so their statements cannot interleave. A child
153
+ * waits on this chain, not on a connection-wide tail, so a nested call
154
+ * does not deadlock behind its own parent.
155
+ */
156
+ childTail: Promise<void>;
157
+ }
158
+
140
159
  /** Pinned connection for {@link SqlStoreHandle.transaction}. */
141
- const sqlTxStorage = new AsyncLocalStorage<SqlConnection>();
160
+ const sqlTxStorage = new AsyncLocalStorage<SqlTxFrame>();
161
+
162
+ /** Monotonic savepoint counter, keyed by the pinned connection. */
163
+ const savepointSeq = new WeakMap<SqlConnection, number>();
142
164
 
143
165
  /**
144
166
  * Read the ambient mutation id, or `undefined` outside a stamped request.
@@ -214,6 +236,110 @@ async function withRlsStampLock<T>(connection: SqlConnection, fn: () => Promise<
214
236
  }
215
237
  }
216
238
 
239
+ /**
240
+ * Next `oke_sp_<depth>_<n>` name. `n` is per pinned connection so siblings
241
+ * and nested savepoints never reuse a name, even after `ROLLBACK TO`.
242
+ *
243
+ * @param connection - Pinned connection
244
+ * @param depth - Savepoint depth (1 for the first nested call)
245
+ */
246
+ function nextSavepointId(connection: SqlConnection, depth: number): string {
247
+ const n = (savepointSeq.get(connection) ?? 0) + 1;
248
+ savepointSeq.set(connection, n);
249
+ return `oke_sp_${String(depth)}_${String(n)}`;
250
+ }
251
+
252
+ /**
253
+ * Run `fn` after earlier direct children of `frame` finish.
254
+ *
255
+ * @param frame - Parent transaction frame
256
+ * @param fn - Child body, including savepoint control
257
+ */
258
+ async function withSqlChildLock<T>(frame: SqlTxFrame, fn: () => Promise<T>): Promise<T> {
259
+ const prev = frame.childTail;
260
+ let release!: () => void;
261
+ const gate = new Promise<void>((resolve) => {
262
+ release = resolve;
263
+ });
264
+ frame.childTail = prev.then(
265
+ () => gate,
266
+ () => gate,
267
+ );
268
+ await prev.catch(() => undefined);
269
+ try {
270
+ return await fn();
271
+ } finally {
272
+ release();
273
+ }
274
+ }
275
+
276
+ /** One staged signal op inside a nested transaction. */
277
+ type StagedSignalOp =
278
+ | { readonly kind: "write"; readonly key: string; readonly value: unknown }
279
+ | {
280
+ readonly kind: "emit";
281
+ readonly signal: string;
282
+ readonly payload: unknown;
283
+ readonly options?: SignalEmitOptions;
284
+ };
285
+
286
+ /**
287
+ * Child staging scope. Emits buffer here and merge into `parent` on success.
288
+ * Discard drops them. Neither path publishes — the outermost commit does.
289
+ *
290
+ * @param parent - Ambient signal transaction
291
+ */
292
+ function openChildSignalScope(parent: SignalTransaction): {
293
+ readonly transaction: SignalTransaction;
294
+ readonly merge: () => Promise<void>;
295
+ readonly discard: () => void;
296
+ } {
297
+ const staged: StagedSignalOp[] = [];
298
+ let open = true;
299
+
300
+ function assertOpen(): void {
301
+ if (!open) throw new Error("transaction finished");
302
+ }
303
+
304
+ const transaction: SignalTransaction = {
305
+ async write(key, value) {
306
+ assertOpen();
307
+ staged.push({ kind: "write", key, value });
308
+ },
309
+ async emit(signal, payload, options) {
310
+ assertOpen();
311
+ staged.push(
312
+ options === undefined
313
+ ? { kind: "emit", signal, payload }
314
+ : { kind: "emit", signal, payload, options },
315
+ );
316
+ },
317
+ async commit() {
318
+ throw new Error("nested signal scope publishes on the outermost commit");
319
+ },
320
+ async rollback() {
321
+ open = false;
322
+ staged.length = 0;
323
+ },
324
+ };
325
+
326
+ return {
327
+ transaction,
328
+ async merge() {
329
+ if (!open) return;
330
+ open = false;
331
+ for (const op of staged) {
332
+ if (op.kind === "write") await parent.write(op.key, op.value);
333
+ else await parent.emit(op.signal, op.payload, op.options);
334
+ }
335
+ },
336
+ discard() {
337
+ open = false;
338
+ staged.length = 0;
339
+ },
340
+ };
341
+ }
342
+
217
343
  /** Options for a SQL session. */
218
344
  export interface SqlSessionOptions {
219
345
  /** Open connection (already role-routed). */
@@ -443,6 +569,9 @@ export interface SqlStoreHandle {
443
569
  /**
444
570
  * Run `fn` on one pinned connection. `fx.emit` inside `fn` stages on the
445
571
  * signal outbox and publishes only after this SQL transaction commits.
572
+ * A call that already has a pinned connection opens `SAVEPOINT` on that
573
+ * connection. Nested emits stage on a child scope and merge into the
574
+ * parent on success; only the outermost commit publishes.
446
575
  *
447
576
  * @param fn - Transaction body. `tx` is this handle, bound to the pin.
448
577
  */
@@ -606,7 +735,7 @@ export function createSqlStoreHandle(
606
735
  }
607
736
 
608
737
  function activeConnection(): SqlConnection {
609
- return sqlTxStorage.getStore() ?? connection;
738
+ return sqlTxStorage.getStore()?.connection ?? connection;
610
739
  }
611
740
 
612
741
  function query(sql: string, params: readonly unknown[] = []): Promise<SqlRow[]> {
@@ -975,6 +1104,53 @@ export function createSqlStoreHandle(
975
1104
  };
976
1105
  }
977
1106
 
1107
+ /**
1108
+ * Nested `transaction()` on the pinned connection: `SAVEPOINT`, then
1109
+ * `RELEASE` or `ROLLBACK TO`. Sibling calls share `parent.childTail`.
1110
+ *
1111
+ * @param parent - Frame already on the async store
1112
+ * @param fn - Nested body
1113
+ */
1114
+ async function runNestedTransaction<T>(
1115
+ parent: SqlTxFrame,
1116
+ fn: (tx: SqlStoreHandle) => Promise<T>,
1117
+ ): Promise<T> {
1118
+ return withSqlChildLock(parent, async () => {
1119
+ const depth = parent.depth + 1;
1120
+ const name = quoteIdent(nextSavepointId(parent.connection, depth));
1121
+ const conn = parent.connection;
1122
+ await conn.exec(`SAVEPOINT ${name}`);
1123
+ const child: SqlTxFrame = {
1124
+ connection: conn,
1125
+ depth,
1126
+ childTail: Promise.resolve(),
1127
+ };
1128
+ const parentSignal = currentSignalTransaction();
1129
+ const staging = parentSignal ? openChildSignalScope(parentSignal) : undefined;
1130
+ let released = false;
1131
+ try {
1132
+ const result = await sqlTxStorage.run(child, () => {
1133
+ const body = () => fn(handle as SqlStoreHandle);
1134
+ return staging ? runInSignalTransaction(staging.transaction, body) : body();
1135
+ });
1136
+ await conn.exec(`RELEASE SAVEPOINT ${name}`);
1137
+ released = true;
1138
+ if (staging) await staging.merge();
1139
+ return result;
1140
+ } catch (err) {
1141
+ if (!released) {
1142
+ try {
1143
+ await conn.exec(`ROLLBACK TO SAVEPOINT ${name}`);
1144
+ } catch {
1145
+ // The pin may already be aborted; the original error still wins.
1146
+ }
1147
+ }
1148
+ staging?.discard();
1149
+ throw err;
1150
+ }
1151
+ });
1152
+ }
1153
+
978
1154
  const handle = {
979
1155
  ref,
980
1156
  routedRole: options.routedRole,
@@ -1398,11 +1574,15 @@ export function createSqlStoreHandle(
1398
1574
  },
1399
1575
 
1400
1576
  async transaction<T>(fn: (tx: SqlStoreHandle) => Promise<T>): Promise<T> {
1577
+ const current = sqlTxStorage.getStore();
1578
+ if (current) return runNestedTransaction(current, fn);
1401
1579
  if (!connection.transaction) {
1402
1580
  throw new Error("fx.store().transaction needs SqlConnection.transaction");
1403
1581
  }
1404
1582
  return connection.transaction(async (txConn) =>
1405
- sqlTxStorage.run(txConn, () => fn(handle as SqlStoreHandle)),
1583
+ sqlTxStorage.run({ connection: txConn, depth: 0, childTail: Promise.resolve() }, () =>
1584
+ fn(handle as SqlStoreHandle),
1585
+ ),
1406
1586
  );
1407
1587
  },
1408
1588
 
package/src/kernel/app.ts CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { bindAgentApprovalFlows } from "../elements/ai/approval-http.ts";
10
10
  import { compileRoute } from "../compiler/dynamic.ts";
11
+ import { HttpBodyRejected, parseBody, type ParseBodyOptions } from "../compiler/http-parse.ts";
11
12
  import type { CompiledRoute } from "../compiler/aot.ts";
12
13
  import { encodeExecuteResult, encodeFailure } from "../compiler/response.ts";
13
14
  import { validate } from "../validation/standard-schema.ts";
@@ -87,9 +88,12 @@ import {
87
88
  createJournal,
88
89
  createJournalSlot,
89
90
  createMemoryJournalStore,
91
+ clearJournalHeartbeats,
90
92
  hasJournalLease,
91
93
  isJournalLeaseBusy,
92
94
  isJournalSuspend,
95
+ isLostJournalLease,
96
+ JournalLeaseBusy,
93
97
  JOURNAL_DEFAULT_LEASE_MS,
94
98
  type JournalSession,
95
99
  type JournalStore,
@@ -376,6 +380,10 @@ export interface OkeOptions {
376
380
  */
377
381
  readonly cache?: {
378
382
  readonly auto?: boolean;
383
+ /** Auto-cache entry cap. Default 10_000. */
384
+ readonly maxEntries?: number;
385
+ /** Auto-cache TTL when a flow sets none. Default 60_000. */
386
+ readonly defaultTtlMs?: number;
379
387
  };
380
388
  /** Channel runtime options. */
381
389
  readonly channel?: BootOptions["channel"];
@@ -398,6 +406,15 @@ export interface OkeOptions {
398
406
  readonly schedulerIntervalMs?: number;
399
407
  /** Durable-run lease duration ms (default 30_000). Forwarded to boot. */
400
408
  readonly journalLeaseMs?: number;
409
+ /** Request body cap in bytes. Default 1 MiB. A route `maxBodySize` overrides it. */
410
+ readonly maxRequestBodySize?: number;
411
+ /**
412
+ * Opt-in durable code version. Unset stamps nothing and checks nothing.
413
+ * When set, new runs are stamped `app:<version>`. A stored stamp without
414
+ * the `app:` prefix (0.23.1 and earlier) is treated as compatible and
415
+ * restamped. An `app:` stamp that differs fails the run with OKE1076.
416
+ */
417
+ readonly codeVersion?: string;
401
418
  }
402
419
 
403
420
  /** Readiness probe state — see `GET /_/ready`. */
@@ -803,6 +820,7 @@ function registerHttpRoute(
803
820
  smart: { add(method: string, path: string, value: Binding): void },
804
821
  compiled: WeakMap<Binding, CompiledRoute>,
805
822
  aot: boolean,
823
+ maxRequestBodySize?: number,
806
824
  ): void {
807
825
  const trigger = binding.trigger;
808
826
  if (trigger.kind !== "http") return;
@@ -834,7 +852,7 @@ function registerHttpRoute(
834
852
  seenLiveExposures.set(exposure, binding.flow.name);
835
853
  }
836
854
  smart.add(trigger.method, trigger.path, binding);
837
- compiled.set(binding, compileHttpBinding(binding, aot));
855
+ compiled.set(binding, compileHttpBinding(binding, aot, maxRequestBodySize));
838
856
  }
839
857
 
840
858
  /**
@@ -1125,7 +1143,15 @@ export function oke(options: OkeOptions): OkeApp {
1125
1143
  for (const b of adopted) {
1126
1144
  if (b.trigger.kind === "http") {
1127
1145
  assertHttpBindingReady(b);
1128
- registerHttpRoute(b, seenHttpRoutes, seenLiveExposures, smart, compiled, aot);
1146
+ registerHttpRoute(
1147
+ b,
1148
+ seenHttpRoutes,
1149
+ seenLiveExposures,
1150
+ smart,
1151
+ compiled,
1152
+ aot,
1153
+ options.maxRequestBodySize,
1154
+ );
1129
1155
  }
1130
1156
  if (b.trigger.kind === "mcp") {
1131
1157
  registerMcpTool(b, seenMcpTools, mcpToolsByName);
@@ -1148,7 +1174,15 @@ export function oke(options: OkeOptions): OkeApp {
1148
1174
  registerFlow(b.flow);
1149
1175
  if (b.trigger.kind === "http") {
1150
1176
  assertHttpBindingReady(b);
1151
- registerHttpRoute(b, seenHttpRoutes, seenLiveExposures, smart, compiled, aot);
1177
+ registerHttpRoute(
1178
+ b,
1179
+ seenHttpRoutes,
1180
+ seenLiveExposures,
1181
+ smart,
1182
+ compiled,
1183
+ aot,
1184
+ options.maxRequestBodySize,
1185
+ );
1152
1186
  }
1153
1187
  if (b.trigger.kind === "mcp") {
1154
1188
  registerMcpTool(b, seenMcpTools, mcpToolsByName);
@@ -1400,6 +1434,7 @@ export function oke(options: OkeOptions): OkeApp {
1400
1434
  journalStore: store,
1401
1435
  runId,
1402
1436
  ...(hasJournalLease(store) ? { lease: { instanceId, leaseMs } } : {}),
1437
+ ...(options.codeVersion ? { codeVersion: options.codeVersion } : {}),
1403
1438
  now,
1404
1439
  fx: {
1405
1440
  ...durableResumeFx(),
@@ -1422,7 +1457,13 @@ export function oke(options: OkeOptions): OkeApp {
1422
1457
  });
1423
1458
  } catch (err) {
1424
1459
  if (isJournalLeaseBusy(err)) return;
1425
- throw err;
1460
+ console.warn(
1461
+ JSON.stringify({
1462
+ event: "journal.resume.failed",
1463
+ runId,
1464
+ message: err instanceof Error ? err.message : String(err),
1465
+ }),
1466
+ );
1426
1467
  }
1427
1468
  } finally {
1428
1469
  inflightRuns.delete(runId);
@@ -1598,6 +1639,19 @@ export function oke(options: OkeOptions): OkeApp {
1598
1639
  startScheduler: overrides?.startScheduler ?? options.startScheduler,
1599
1640
  schedulerIntervalMs: overrides?.schedulerIntervalMs ?? options.schedulerIntervalMs,
1600
1641
  journalLeaseMs: overrides?.journalLeaseMs ?? options.journalLeaseMs,
1642
+ codeVersion: overrides?.codeVersion ?? options.codeVersion,
1643
+ ...(options.cache
1644
+ ? {
1645
+ cache: {
1646
+ ...(options.cache.maxEntries !== undefined
1647
+ ? { maxEntries: options.cache.maxEntries }
1648
+ : {}),
1649
+ ...(options.cache.defaultTtlMs !== undefined
1650
+ ? { defaultTtlMs: options.cache.defaultTtlMs }
1651
+ : {}),
1652
+ },
1653
+ }
1654
+ : {}),
1601
1655
  bindings: adopted,
1602
1656
  flows: [...flowsByName.values()],
1603
1657
  onCronFire: overrides?.onCronFire ?? handleCronFire,
@@ -1981,6 +2035,7 @@ export function oke(options: OkeOptions): OkeApp {
1981
2035
  // Hold the run lease for the request's lifetime — a crash mid-run
1982
2036
  // leaves an expired lease another instance can reclaim and resume.
1983
2037
  ...(hasJournalLease(store) ? { lease: { instanceId, leaseMs } } : {}),
2038
+ ...(options.codeVersion ? { codeVersion: options.codeVersion } : {}),
1984
2039
  });
1985
2040
  };
1986
2041
 
@@ -2103,6 +2158,8 @@ export function oke(options: OkeOptions): OkeApp {
2103
2158
  locale: fx.locale,
2104
2159
  scopes: fx.auth.scopes,
2105
2160
  roles: principals?.cacheRoles ?? [],
2161
+ verified: fx.auth.verified === true,
2162
+ operatorId: fx.operator.id,
2106
2163
  };
2107
2164
  const cacheOk =
2108
2165
  cache !== undefined &&
@@ -2148,17 +2205,52 @@ export function oke(options: OkeOptions): OkeApp {
2148
2205
  journalSession?.rewind();
2149
2206
  return flowDef.do(input as never, fx);
2150
2207
  };
2151
- const raw = await (flowDef.retry ? fxRetry(run, flowDef.retry) : run());
2208
+ const flightKey =
2209
+ cacheOk && cache && dims && cacheEffects
2210
+ ? cache.tier1KeysForReads(cacheEffects, dims)[0]
2211
+ : undefined;
2212
+ const flightResources = (cacheEffects?.reads ?? []).filter(
2213
+ cache?.isStoreResourceRef ?? (() => false),
2214
+ );
2215
+ const generation =
2216
+ storeRt && flightKey
2217
+ ? Object.fromEntries(
2218
+ flightResources.map((resource) => [
2219
+ resource,
2220
+ storeRt.cache.generationOf(resource),
2221
+ ]),
2222
+ )
2223
+ : undefined;
2224
+ let raw: unknown;
2225
+ let cacheable = true;
2226
+ try {
2227
+ const load = () => (flowDef.retry ? fxRetry(run, flowDef.retry) : run());
2228
+ if (flightKey && storeRt) {
2229
+ const flight = await storeRt.cache.coalesce(flightKey, flightResources, load);
2230
+ raw = flight.value;
2231
+ cacheable =
2232
+ flight.cacheable &&
2233
+ (generation === undefined || storeRt.cache.sameGeneration(generation));
2234
+ } else {
2235
+ raw = await load();
2236
+ }
2237
+ } finally {
2238
+ if (cache && storeRt && cacheEffects) {
2239
+ const ledgerFx = cache.effectsFromLedger(ledger.entries);
2240
+ const writeEffects: Effects = {
2241
+ writes: [
2242
+ ...new Set([...(cacheEffects.writes ?? []), ...(ledgerFx.writes ?? [])]),
2243
+ ].filter(cache.isStoreResourceRef),
2244
+ };
2245
+ if ((writeEffects.writes?.length ?? 0) > 0) {
2246
+ storeRt.onWriteEffects(writeEffects);
2247
+ }
2248
+ }
2249
+ }
2152
2250
  const output = isFlowFailure(raw) ? raw : await projectFlowOut(flowDef.out, raw);
2153
2251
  if (!isFlowFailure(output) && cache && storeRt && cacheEffects) {
2154
2252
  const ledgerFx = cache.effectsFromLedger(ledger.entries);
2155
2253
  const observed = cache.mergeEffects(cacheEffects, ledgerFx);
2156
- const writeEffects: Effects = {
2157
- writes: (observed.writes ?? []).filter(cache.isStoreResourceRef),
2158
- };
2159
- if ((writeEffects.writes?.length ?? 0) > 0) {
2160
- storeRt.onWriteEffects(writeEffects);
2161
- }
2162
2254
  const storeReads = (observed.reads ?? []).filter(cache.isStoreResourceRef);
2163
2255
  if (cache.autoCachePure(observed) && storeReads.length > 0) {
2164
2256
  learnedTier1Reads.set(flowDef.name, storeReads);
@@ -2173,7 +2265,12 @@ export function oke(options: OkeOptions): OkeApp {
2173
2265
  durable: flowDef.durable,
2174
2266
  effects: observed,
2175
2267
  });
2176
- if (storeAfter && output !== undefined && !loadFx().isJsonStreamResult(output)) {
2268
+ if (
2269
+ cacheable &&
2270
+ storeAfter &&
2271
+ output !== undefined &&
2272
+ !loadFx().isJsonStreamResult(output)
2273
+ ) {
2177
2274
  const ttlMs =
2178
2275
  typeof flowDef.cache === "string" ? cache.parseTtlMs(flowDef.cache) : undefined;
2179
2276
  const putEffects: Effects = { reads: storeReads };
@@ -2231,6 +2328,9 @@ export function oke(options: OkeOptions): OkeApp {
2231
2328
  const journal = openJournal();
2232
2329
  const resumeId = claimed.kind === "reclaimed" ? claimed.row.runId : undefined;
2233
2330
  try {
2331
+ if (resumeId && inflightRuns.has(resumeId)) {
2332
+ throw new JournalLeaseBusy(resumeId);
2333
+ }
2234
2334
  const session = resumeId
2235
2335
  ? await journal.resume(resumeId)
2236
2336
  : await journal.start(flowDef.name, validated);
@@ -2335,6 +2435,9 @@ export function oke(options: OkeOptions): OkeApp {
2335
2435
  };
2336
2436
  return undefined;
2337
2437
  }
2438
+ if (flowDef.durable && journalSession && isLostJournalLease(err)) {
2439
+ return fail("JournalLeaseBusy", {});
2440
+ }
2338
2441
  throw err;
2339
2442
  }
2340
2443
  },
@@ -2383,13 +2486,22 @@ export function oke(options: OkeOptions): OkeApp {
2383
2486
  });
2384
2487
  } else if (!sleeping && isTerminalFailure(result)) {
2385
2488
  const terminalErr = result.failure ?? result.ctx.error;
2386
- await runCompensationPhase({
2387
- flow: flowDef,
2388
- input: ctx.input,
2389
- session: journalSession,
2390
- fx,
2391
- error: terminalErr,
2392
- });
2489
+ const lost =
2490
+ isLostJournalLease(terminalErr) ||
2491
+ isJournalLeaseBusy(terminalErr) ||
2492
+ (terminalErr != null &&
2493
+ typeof terminalErr === "object" &&
2494
+ "error" in terminalErr &&
2495
+ (terminalErr as { error?: { code?: string } }).error?.code === "JournalLeaseBusy");
2496
+ if (!lost) {
2497
+ await runCompensationPhase({
2498
+ flow: flowDef,
2499
+ input: ctx.input,
2500
+ session: journalSession,
2501
+ fx,
2502
+ error: terminalErr,
2503
+ });
2504
+ }
2393
2505
  } else if (parked && sleeping) {
2394
2506
  await journalSession.commit("sleeping", { wakeAt: sleeping.wakeAt });
2395
2507
  } else if (!sleeping) {
@@ -2503,6 +2615,7 @@ export function oke(options: OkeOptions): OkeApp {
2503
2615
  },
2504
2616
  async stop() {
2505
2617
  if (!bootResult) return;
2618
+ clearJournalHeartbeats();
2506
2619
  bootResult.stopScheduler();
2507
2620
  // Realtime bridge down before connections close.
2508
2621
  {
@@ -2530,7 +2643,11 @@ export function oke(options: OkeOptions): OkeApp {
2530
2643
  /** Shared identity store when `gate.auth` is enabled. */
2531
2644
  identities: gateConfig.auth?.identities,
2532
2645
  async resumeDurable(now) {
2533
- const t = now ?? bootResult?.clock?.now() ?? options.fx?.now?.() ?? Date.now();
2646
+ // Due selection uses one sample. The run itself must not: a frozen
2647
+ // closure wrote leases that were already expired by the time persist ran.
2648
+ const sample = (): number => bootResult?.clock?.now() ?? options.fx?.now?.() ?? Date.now();
2649
+ const t = now ?? sample();
2650
+ const live = typeof now === "number" ? (): number => now : sample;
2534
2651
  const { store, instanceId, leaseMs } = activeJournal();
2535
2652
  if (hasJournalLease(store)) {
2536
2653
  // Shared store is the wake schedule — claim due sleeps across all
@@ -2539,14 +2656,14 @@ export function oke(options: OkeOptions): OkeApp {
2539
2656
  const due = await store.claimDueSleep(instanceId, t, leaseMs);
2540
2657
  if (!due) break;
2541
2658
  sleepingRuns.delete(due.id);
2542
- await resumeDurableRun(due.id, due.flow, due.input, () => t);
2659
+ await resumeDurableRun(due.id, due.flow, due.input, live);
2543
2660
  }
2544
2661
  // Crash sweep: the boot orphan scan is once-only, so each tick also
2545
2662
  // reclaims `running` runs whose holder's lease has expired (same
2546
2663
  // takeover physics as Clock: lease expiry + next tick).
2547
2664
  for (const orphan of await store.listOrphans(t)) {
2548
2665
  if (orphan.status !== "running") continue;
2549
- await resumeDurableRun(orphan.id, orphan.flow, orphan.input, () => t);
2666
+ await resumeDurableRun(orphan.id, orphan.flow, orphan.input, live);
2550
2667
  }
2551
2668
  return;
2552
2669
  }
@@ -2559,7 +2676,7 @@ export function oke(options: OkeOptions): OkeApp {
2559
2676
  input: sleeper.input,
2560
2677
  journalStore: store,
2561
2678
  runId,
2562
- now: () => t,
2679
+ now: live,
2563
2680
  fx: durableResumeFx(),
2564
2681
  });
2565
2682
  if (result.status === "sleeping") {
@@ -2700,9 +2817,15 @@ export function oke(options: OkeOptions): OkeApp {
2700
2817
  flowLabel = target.name;
2701
2818
  let internalInput: unknown;
2702
2819
  try {
2703
- internalInput = await request.json();
2704
- } catch {
2705
- internalInput = undefined;
2820
+ internalInput = await parseBody(request, {
2821
+ maxBytes: options.maxRequestBodySize,
2822
+ jsonContentType: "required",
2823
+ });
2824
+ } catch (err) {
2825
+ if (err instanceof HttpBodyRejected) {
2826
+ return respond(encodeFailure(bodyFailure(err, request)));
2827
+ }
2828
+ throw err;
2706
2829
  }
2707
2830
  const internalResult = await execute(
2708
2831
  target,
@@ -2769,7 +2892,7 @@ export function oke(options: OkeOptions): OkeApp {
2769
2892
 
2770
2893
  let route = compiled.get(binding);
2771
2894
  if (!route && binding.trigger.kind === "http") {
2772
- route = compileHttpBinding(binding, aot);
2895
+ route = compileHttpBinding(binding, aot, options.maxRequestBodySize);
2773
2896
  compiled.set(binding, route);
2774
2897
  }
2775
2898
 
@@ -2875,7 +2998,31 @@ export function oke(options: OkeOptions): OkeApp {
2875
2998
  return app;
2876
2999
  }
2877
3000
 
2878
- function compileHttpBinding(binding: Binding, aot: boolean): CompiledRoute {
3001
+ function httpBodyOptions(trigger: HttpTrigger, maxRequestBodySize?: number): ParseBodyOptions {
3002
+ const contract = trigger.contract as
3003
+ | { readonly maxBodySize?: number; readonly jsonContentType?: "required" | "any" }
3004
+ | undefined;
3005
+ return {
3006
+ maxBytes: contract?.maxBodySize ?? maxRequestBodySize,
3007
+ ...(contract?.jsonContentType ? { jsonContentType: contract.jsonContentType } : {}),
3008
+ };
3009
+ }
3010
+
3011
+ function bodyFailure(err: HttpBodyRejected, request: Request): ReturnType<typeof fail> {
3012
+ if (err.code === "InvalidQuery") {
3013
+ return fail("InvalidQuery", { reason: err.reason ?? "malformed_body" });
3014
+ }
3015
+ if (err.code === "UnsupportedMediaType") {
3016
+ return fail("UnsupportedMediaType", { contentType: request.headers.get("content-type") ?? "" });
3017
+ }
3018
+ return fail("PayloadTooLarge", {});
3019
+ }
3020
+
3021
+ function compileHttpBinding(
3022
+ binding: Binding,
3023
+ aot: boolean,
3024
+ maxRequestBodySize?: number,
3025
+ ): CompiledRoute {
2879
3026
  const trigger = binding.trigger as HttpTrigger;
2880
3027
  const hookFns: Array<(...args: never[]) => unknown> = [];
2881
3028
  for (const list of Object.values(binding.flow.hooks)) {
@@ -2891,6 +3038,7 @@ function compileHttpBinding(binding: Binding, aot: boolean): CompiledRoute {
2891
3038
  handler: binding.flow.do as (...args: never[]) => unknown,
2892
3039
  hooks: hookFns,
2893
3040
  schema: binding.flow.in,
3041
+ body: httpBodyOptions(trigger, maxRequestBodySize),
2894
3042
  },
2895
3043
  aot,
2896
3044
  );
@@ -285,7 +285,11 @@ describe("oke() auto-registry — stores/secrets/signals/clocks/gates/channel.te
285
285
  });
286
286
 
287
287
  const res = await app.fetch(
288
- new Request("http://127.0.0.1/ai-ping", { method: "POST", body: "{}" }),
288
+ new Request("http://127.0.0.1/ai-ping", {
289
+ method: "POST",
290
+ headers: { "content-type": "application/json" },
291
+ body: "{}",
292
+ }),
289
293
  );
290
294
  expect(res.status).toBe(200);
291
295
  const json = (await res.json()) as { data: { summary: string } };
@@ -12,7 +12,11 @@ import {
12
12
  import type { PostgresClientLike } from "../../drivers/postgres.ts";
13
13
  import { memorySignalDriver } from "../../drivers/signal-memory.ts";
14
14
  import type { PostgresSignalSql } from "../../drivers/signal-postgres.ts";
15
- import { postgresSignalDriver } from "../../drivers/signal-postgres.ts";
15
+ import {
16
+ POSTGRES_SIGNAL_DEFAULT_LAG_MS,
17
+ openPostgresSignal,
18
+ postgresSignalDriver,
19
+ } from "../../drivers/signal-postgres.ts";
16
20
  import { createBunSignalRedisClient, redisSignalDriver } from "../../drivers/signal-redis.ts";
17
21
  import type { SignalRedisClientLike } from "../../drivers/signal-types.ts";
18
22
  import { createSignalRuntime, type SignalRuntime } from "../../elements/signal.ts";
@@ -123,7 +127,14 @@ export async function bindSignal(
123
127
  );
124
128
  }
125
129
  signal = createSignalRuntime({
126
- driver: postgresSignalDriver,
130
+ driver: {
131
+ id: postgresSignalDriver.id,
132
+ open: (openOptions) =>
133
+ openPostgresSignal({
134
+ ...openOptions,
135
+ lagMs: injected ? undefined : POSTGRES_SIGNAL_DEFAULT_LAG_MS,
136
+ }),
137
+ },
127
138
  now,
128
139
  sql: injected ?? asSignalSql(sharedPostgresClient(url)),
129
140
  pollMs: injected ? undefined : 1_000,