okengine 0.23.0 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/ai/mcp.mdx +1 -1
  3. package/site/content/docs/elements/channel/index.mdx +4 -4
  4. package/site/content/docs/elements/channel/receipts.mdx +7 -5
  5. package/site/content/docs/elements/channel/sms.mdx +2 -1
  6. package/site/content/docs/elements/flow/index.mdx +22 -18
  7. package/site/content/docs/elements/signal/index.mdx +15 -14
  8. package/site/content/docs/plugins/otp.mdx +3 -3
  9. package/site/content/docs/reference/fx.mdx +12 -4
  10. package/src/auth/tenants.ts +17 -0
  11. package/src/cli/doctor-diff.test.ts +29 -1
  12. package/src/cli/doctor-diff.ts +35 -0
  13. package/src/cli/manifest-pr-diff.ts +16 -0
  14. package/src/compiler/effects-fetch.test.ts +77 -0
  15. package/src/compiler/effects-infer.ts +87 -16
  16. package/src/compiler/effects-join.test.ts +48 -0
  17. package/src/compiler/extract.test.ts +28 -0
  18. package/src/compiler/extract.ts +11 -1
  19. package/src/compiler/fx-index.ts +58 -4
  20. package/src/console/index.ts +1 -1
  21. package/src/console/ui-next/dist/assets/{access-page-DDKkhT9v.js → access-page-C_N4qvai.js} +1 -1
  22. package/src/console/ui-next/dist/assets/{agent-disclosure-62Xts8Xi.js → agent-disclosure-Cq1TxniW.js} +1 -1
  23. package/src/console/ui-next/dist/assets/{cache-glyph-DyeoKHKA.js → cache-glyph-BaI4uxZz.js} +1 -1
  24. package/src/console/ui-next/dist/assets/{call-pii-button-DvfpLXsU.js → call-pii-button-DNyK7IIY.js} +1 -1
  25. package/src/console/ui-next/dist/assets/{collapsible-BY03SeCg.js → collapsible-CRhBJMZR.js} +1 -1
  26. package/src/console/ui-next/dist/assets/{decisions-page-CgkKlA56.js → decisions-page-D9twj8Xy.js} +1 -1
  27. package/src/console/ui-next/dist/assets/{duration-tone-Y6HLjyJ5.js → duration-tone-CsHWlVPU.js} +1 -1
  28. package/src/console/ui-next/dist/assets/{flows-page-DLPfNy-_.js → flows-page-ZaTJAhR9.js} +1 -1
  29. package/src/console/ui-next/dist/assets/{highlighted-json-D7nNzpgB.js → highlighted-json-DQ-EMAfz.js} +1 -1
  30. package/src/console/ui-next/dist/assets/{http-method-DdL19zzh.js → http-method-Cn_Fl79s.js} +1 -1
  31. package/src/console/ui-next/dist/assets/{index-C0lc_s9d.js → index-CmRFeWdT.js} +3 -3
  32. package/src/console/ui-next/dist/assets/{observability-page-CRlcyLCC.js → observability-page-Cn6R_dXz.js} +1 -1
  33. package/src/console/ui-next/dist/assets/{replica-lag-Bb3FWUu9.js → replica-lag-Bx6EZhSe.js} +1 -1
  34. package/src/console/ui-next/dist/assets/{request-meta-DYBMWhX8.js → request-meta-DmdiUrfh.js} +1 -1
  35. package/src/console/ui-next/dist/assets/{store-page-CcE-SXC8.js → store-page-8P6vGXnn.js} +1 -1
  36. package/src/console/ui-next/dist/assets/{trace-detail-sheet-CKMSEQ4s.js → trace-detail-sheet-BfGvpFSv.js} +1 -1
  37. package/src/console/ui-next/dist/assets/{tree-expand-toggle-QOfk0M0H.js → tree-expand-toggle-CWdUHbc0.js} +1 -1
  38. package/src/console/ui-next/dist/assets/{units-page-DKC1CDDd.js → units-page-CZOFZzay.js} +1 -1
  39. package/src/console/ui-next/dist/assets/{vault-page-B54v4Yit.js → vault-page-ki_ishmX.js} +1 -1
  40. package/src/console/ui-next/dist/index.html +1 -1
  41. package/src/drivers/journal-postgres.test.ts +22 -1
  42. package/src/drivers/journal-postgres.ts +223 -12
  43. package/src/drivers/signal-compete.test.ts +234 -0
  44. package/src/drivers/signal-postgres.ts +39 -19
  45. package/src/drivers/signal-redis.ts +167 -11
  46. package/src/drivers/signal-types.ts +12 -0
  47. package/src/elements/ai/mcp-protocol.ts +5 -6
  48. package/src/elements/ai/runtime.ts +20 -19
  49. package/src/elements/channel/runtime.ts +11 -0
  50. package/src/elements/channel/sql-ledger.ts +251 -0
  51. package/src/elements/signal/runtime.ts +9 -0
  52. package/src/elements/signal.test.ts +1 -0
  53. package/src/elements/store/cache.test.ts +120 -15
  54. package/src/elements/store/cache.ts +200 -39
  55. package/src/elements/store/prepare-row.test.ts +6 -2
  56. package/src/elements/store/sql-session.test.ts +2 -0
  57. package/src/elements/store/sql-session.ts +95 -9
  58. package/src/elements/store.ts +1 -0
  59. package/src/kernel/app.ts +32 -16
  60. package/src/kernel/auto-cache.test.ts +228 -0
  61. package/src/kernel/boot-bind/channel.ts +63 -2
  62. package/src/kernel/boot-bind/honor-config.test.ts +40 -10
  63. package/src/kernel/boot-bind/signal.ts +61 -25
  64. package/src/kernel/boot.ts +16 -2
  65. package/src/kernel/errors-compiler.ts +11 -0
  66. package/src/kernel/errors-text.ts +16 -0
  67. package/src/kernel/errors.ts +8 -0
  68. package/src/kernel/flow.ts +33 -3
  69. package/src/kernel/fx-call-types.test.ts +32 -0
  70. package/src/kernel/fx-decide.ts +11 -7
  71. package/src/kernel/fx-sql-handle.ts +46 -7
  72. package/src/kernel/fx.test.ts +1 -3
  73. package/src/kernel/fx.ts +27 -16
  74. package/src/kernel/idempotency.test.ts +2 -1
  75. package/src/kernel/journal.test.ts +114 -0
  76. package/src/kernel/journal.ts +369 -53
  77. package/src/kernel/pipeline-tenant.ts +5 -1
  78. package/src/kernel/pipeline.ts +5 -0
  79. package/src/kernel/signal-tx.ts +28 -0
  80. package/src/kernel/store-transaction.test.ts +95 -0
  81. package/src/mcp/docs-server.ts +19 -2
  82. package/src/mcp/mcp.test.ts +83 -0
  83. package/src/mcp/protocol.ts +8 -2
  84. package/src/mcp/server.ts +23 -2
  85. package/src/mcp/versions.ts +60 -0
  86. package/src/plugins/index.ts +1 -0
  87. package/src/plugins/mena.ts +56 -0
@@ -174,44 +174,158 @@ export function isStoreResourceRef(ref: string): ref is ResourceRef {
174
174
  return STORE_REF.test(ref) && ref !== "runs";
175
175
  }
176
176
 
177
+ /** Effect list keys that disqualify tier-1 auto-cache when non-empty. */
178
+ const DISQUALIFYING_EFFECTS = [
179
+ "writes",
180
+ "emits",
181
+ "sends",
182
+ "asks",
183
+ "embeds",
184
+ "secrets",
185
+ "calls",
186
+ "fetches",
187
+ "decides",
188
+ ] as const satisfies readonly (keyof Effects)[];
189
+
177
190
  /**
178
- * Store reads / writes / asks recorded on one invocation's ledger.
191
+ * Store and non-store effects recorded on one invocation's ledger.
179
192
  *
180
- * Used when the flow has no stamped `effects` (open capability token) so
181
- * the cache cycle still runs from what `fx.store` actually touched.
193
+ * Side effects stay on the result so auto-cache can refuse a flow that
194
+ * also fetched, emitted, or called — a learned store read is not enough.
182
195
  *
183
196
  * @param entries - Ledger entries from the invocation
184
197
  */
185
198
  export function effectsFromLedger(
186
199
  entries: readonly { readonly kind: string; readonly resource: string }[],
187
200
  ): Effects {
188
- const reads: ResourceRef[] = [];
189
- const writes: ResourceRef[] = [];
201
+ const reads: string[] = [];
202
+ const writes: string[] = [];
190
203
  const asks: string[] = [];
191
- const seenRead = new Set<string>();
192
- const seenWrite = new Set<string>();
204
+ const emits: string[] = [];
205
+ const sends: string[] = [];
206
+ const embeds: string[] = [];
207
+ const secrets: string[] = [];
208
+ const calls: string[] = [];
209
+ const fetches: string[] = [];
210
+ const decides: string[] = [];
211
+ const seen = new Set<string>();
212
+ const push = (bucket: string[], kind: string, resource: string): void => {
213
+ const key = `${kind}:${resource}`;
214
+ if (seen.has(key)) return;
215
+ seen.add(key);
216
+ bucket.push(resource);
217
+ };
193
218
  for (const entry of entries) {
194
- if (entry.kind === "ask") {
195
- asks.push(entry.resource);
196
- continue;
197
- }
198
- if (!isStoreResourceRef(entry.resource)) continue;
199
- if (entry.kind === "read" && !seenRead.has(entry.resource)) {
200
- seenRead.add(entry.resource);
201
- reads.push(entry.resource);
202
- }
203
- if (entry.kind === "write" && !seenWrite.has(entry.resource)) {
204
- seenWrite.add(entry.resource);
205
- writes.push(entry.resource);
219
+ switch (entry.kind) {
220
+ case "read":
221
+ push(reads, entry.kind, entry.resource);
222
+ break;
223
+ case "write":
224
+ push(writes, entry.kind, entry.resource);
225
+ break;
226
+ case "ask":
227
+ push(asks, entry.kind, entry.resource);
228
+ break;
229
+ case "emit":
230
+ push(emits, entry.kind, entry.resource);
231
+ break;
232
+ case "send":
233
+ push(sends, entry.kind, entry.resource);
234
+ break;
235
+ case "embed":
236
+ push(embeds, entry.kind, entry.resource);
237
+ break;
238
+ case "secret":
239
+ push(secrets, entry.kind, entry.resource);
240
+ break;
241
+ case "call":
242
+ push(calls, entry.kind, entry.resource);
243
+ break;
244
+ case "fetch":
245
+ push(fetches, entry.kind, entry.resource);
246
+ break;
247
+ case "decide":
248
+ push(decides, entry.kind, entry.resource);
249
+ break;
250
+ default:
251
+ break;
206
252
  }
207
253
  }
254
+ return {
255
+ ...(reads.length > 0 ? { reads: reads as Effects["reads"] } : {}),
256
+ ...(writes.length > 0 ? { writes: writes as Effects["writes"] } : {}),
257
+ ...(asks.length > 0 ? { asks } : {}),
258
+ ...(emits.length > 0 ? { emits } : {}),
259
+ ...(sends.length > 0 ? { sends } : {}),
260
+ ...(embeds.length > 0 ? { embeds } : {}),
261
+ ...(secrets.length > 0 ? { secrets } : {}),
262
+ ...(calls.length > 0 ? { calls } : {}),
263
+ ...(fetches.length > 0 ? { fetches } : {}),
264
+ ...(decides.length > 0 ? { decides } : {}),
265
+ };
266
+ }
267
+
268
+ /**
269
+ * True when every effect is a store read (`sql:` / `kv:` / `files:` / `index:`).
270
+ *
271
+ * Sends, emits, fetches, vault reads, asks, decides, calls, writes, and
272
+ * non-store reads (`runs`, `signal:`) are not cacheable.
273
+ *
274
+ * @param effects - Declared or ledgered effects
275
+ */
276
+ export function autoCachePure(effects: Effects | undefined): boolean {
277
+ if (!effects) return false;
278
+ for (const key of DISQUALIFYING_EFFECTS) {
279
+ if ((effects[key]?.length ?? 0) > 0) return false;
280
+ }
281
+ const reads = effects.reads ?? [];
282
+ if (reads.length === 0) return false;
283
+ return reads.every((ref) => isStoreResourceRef(ref));
284
+ }
285
+
286
+ /**
287
+ * Union two effect bags. Used so a ledgered fetch cannot be dropped
288
+ * before the auto-cache eligibility check.
289
+ *
290
+ * @param left - Declared or previously resolved effects
291
+ * @param right - Ledgered effects
292
+ */
293
+ export function mergeEffects(left: Effects, right: Effects): Effects {
294
+ const reads = union(left.reads, right.reads);
295
+ const writes = union(left.writes, right.writes);
296
+ const emits = union(left.emits, right.emits);
297
+ const sends = union(left.sends, right.sends);
298
+ const asks = union(left.asks, right.asks);
299
+ const embeds = union(left.embeds, right.embeds);
300
+ const secrets = union(left.secrets, right.secrets);
301
+ const calls = union(left.calls, right.calls);
302
+ const fetches = union(left.fetches, right.fetches);
303
+ const decides = union(left.decides, right.decides);
208
304
  return {
209
305
  ...(reads.length > 0 ? { reads } : {}),
210
306
  ...(writes.length > 0 ? { writes } : {}),
307
+ ...(emits.length > 0 ? { emits } : {}),
308
+ ...(sends.length > 0 ? { sends } : {}),
211
309
  ...(asks.length > 0 ? { asks } : {}),
310
+ ...(embeds.length > 0 ? { embeds } : {}),
311
+ ...(secrets.length > 0 ? { secrets } : {}),
312
+ ...(calls.length > 0 ? { calls } : {}),
313
+ ...(fetches.length > 0 ? { fetches } : {}),
314
+ ...(decides.length > 0 ? { decides } : {}),
212
315
  };
213
316
  }
214
317
 
318
+ /**
319
+ * Stable union of two effect lists.
320
+ *
321
+ * @param left - First list
322
+ * @param right - Second list
323
+ */
324
+ function union<T extends string>(left?: readonly T[], right?: readonly T[]): T[] {
325
+ if ((left?.length ?? 0) === 0 && (right?.length ?? 0) === 0) return [];
326
+ return [...new Set([...(left ?? []), ...(right ?? [])])];
327
+ }
328
+
215
329
  /**
216
330
  * Effects the auto-cache lookup should use: stamped reads when present,
217
331
  * otherwise reads learned from a previous run's ledger.
@@ -223,6 +337,9 @@ export function resolveCacheEffects(
223
337
  declared: Effects | undefined,
224
338
  learnedReads: readonly ResourceRef[] | undefined,
225
339
  ): Effects {
340
+ if (declared && !autoCachePure(declared) && hasAnyEffect(declared)) {
341
+ return declared;
342
+ }
226
343
  const declaredReads = (declared?.reads ?? []).filter((r) => isStoreResourceRef(r));
227
344
  if (declaredReads.length > 0 || (declared?.writes?.length ?? 0) > 0) {
228
345
  return declared ?? {};
@@ -233,6 +350,16 @@ export function resolveCacheEffects(
233
350
  return declared ?? {};
234
351
  }
235
352
 
353
+ /**
354
+ * Whether the effect bag records anything.
355
+ *
356
+ * @param effects - Declared or ledgered effects
357
+ */
358
+ function hasAnyEffect(effects: Effects): boolean {
359
+ const keys = ["reads", ...DISQUALIFYING_EFFECTS] as const;
360
+ return keys.some((key) => (effects[key]?.length ?? 0) > 0);
361
+ }
362
+
236
363
  /**
237
364
  * Tier-1 hit only when every key is still present (a write to any
238
365
  * contributing resource must miss).
@@ -255,48 +382,82 @@ export function tier1Lookup<T>(
255
382
  return value;
256
383
  }
257
384
 
385
+ /**
386
+ * Caller dimensions stamped into a tier-1 cache key.
387
+ *
388
+ * Segments are always present so an empty tenant cannot collide with a set one.
389
+ */
390
+ export interface Tier1Caller {
391
+ /** Authenticated user id. Empty when anonymous. */
392
+ readonly userId?: string | null;
393
+ /** Active tenant id. Empty when tenancy is off. */
394
+ readonly tenantId?: string | null;
395
+ /** Resolved request locale. */
396
+ readonly locale?: string | null;
397
+ /** Effective scopes, including the tenant-role union. */
398
+ readonly scopes?: Iterable<string>;
399
+ /**
400
+ * Membership role names. Cache identity only — gates do not read this.
401
+ */
402
+ readonly roles?: Iterable<string>;
403
+ }
404
+
258
405
  /**
259
406
  * Whether a flow should use automatic tier-1 cache.
260
407
  *
261
- * Read-only flows (inferred, declared, or ledgered `reads`, no `writes`)
262
- * cache by default. Opt out with `cache: false`. Mutations, AI asks,
263
- * durable flows, and empty effect sets stay uncached — no `cache: "30s"`
264
- * or hand-declared `effects` required on the flow.
408
+ * On by default for a pure store read that is not durable. `auto: false`
409
+ * turns the app default off; a flow can still opt in with `cache: true` or a
410
+ * duration. `cache: false` always disables. Sends, emits, fetches, secrets,
411
+ * calls, asks, embeds, decides, and writes are never cached.
265
412
  *
266
- * @param options - Flow cache flag, durability, and effect set
413
+ * @param options - Flow cache flag, app switch, durability, and effect set
267
414
  */
268
415
  export function autoCacheEligible(options: {
269
416
  readonly cache?: boolean | string;
417
+ /** App-level switch. Omitted means on. `false` turns auto-cache off. */
418
+ readonly auto?: boolean;
270
419
  readonly durable?: boolean;
271
420
  readonly effects?: Effects;
272
421
  }): boolean {
273
422
  if (options.cache === false) return false;
274
423
  if (options.durable === true) return false;
275
- const effects = options.effects ?? {};
276
- if ((effects.asks?.length ?? 0) > 0) return false;
277
- if ((effects.writes?.length ?? 0) > 0) return false;
278
- const reads = (effects.reads ?? []).filter((r) => isStoreResourceRef(r));
279
- return reads.length > 0;
424
+ if (options.auto === false && options.cache !== true && typeof options.cache !== "string") {
425
+ return false;
426
+ }
427
+ return autoCachePure(options.effects);
280
428
  }
281
429
 
282
430
  /**
283
431
  * Dimension suffixes for a flow-scoped tier-1 key.
284
432
  *
285
- * Format after {@link computedCacheKey}: `computed:{resource}/{flow}/{input}[/{userId}]`.
433
+ * Format after {@link computedCacheKey}:
434
+ * `computed:{resource}/{flow}/{input}/{userId}/t:{tenant}/l:{locale}/s:{scopes}/r:{roles}`.
286
435
  * Invalidation still keys off the resource segment.
287
436
  *
437
+ * A string third argument is the user id (older call shape).
438
+ *
288
439
  * @param flowName - Flow id
289
440
  * @param input - Validated flow input
290
- * @param userId - Caller id when present (per-user lists)
441
+ * @param caller - Caller identity, or a user id string
291
442
  */
292
443
  export function tier1FlowDims(
293
444
  flowName: string,
294
445
  input: unknown,
295
- userId?: string | null,
446
+ caller?: Tier1Caller | string | null,
296
447
  ): readonly string[] {
297
- const dims = [flowName, fingerprintInput(input)];
298
- if (userId) dims.push(userId);
299
- return dims;
448
+ const identity: Tier1Caller =
449
+ typeof caller === "string" || caller == null ? { userId: caller ?? null } : caller;
450
+ const scopes = [...(identity.scopes ?? [])].sort();
451
+ const roles = [...(identity.roles ?? [])].sort();
452
+ return [
453
+ flowName,
454
+ fingerprintInput(input),
455
+ identity.userId ?? "",
456
+ `t:${identity.tenantId ?? ""}`,
457
+ `l:${identity.locale ?? ""}`,
458
+ `s:${scopes.join(",")}`,
459
+ `r:${roles.join(",")}`,
460
+ ];
300
461
  }
301
462
 
302
463
  /**
@@ -305,18 +466,18 @@ export function tier1FlowDims(
305
466
  * @param effects - Read effects
306
467
  * @param flowName - Flow id
307
468
  * @param input - Validated flow input
308
- * @param userId - Caller id when present
469
+ * @param caller - Caller identity, or a user id string
309
470
  */
310
471
  export function tier1DimsByResource(
311
472
  effects: Effects,
312
473
  flowName: string,
313
474
  input: unknown,
314
- userId?: string | null,
475
+ caller?: Tier1Caller | string | null,
315
476
  ): Readonly<Record<string, readonly string[]>> {
316
- const dims = tier1FlowDims(flowName, input, userId);
477
+ const dims = tier1FlowDims(flowName, input, caller);
317
478
  const out: Record<string, readonly string[]> = {};
318
479
  for (const resource of effects.reads ?? []) {
319
- if (resource === "runs") continue;
480
+ if (!isStoreResourceRef(resource)) continue;
320
481
  out[resource] = dims;
321
482
  }
322
483
  return out;
@@ -142,8 +142,12 @@ describe("SqlStoreHandle upsert — epoch-ms into timestamp (Postgres)", () => {
142
142
  });
143
143
 
144
144
  test("select/update WHERE coerces epoch-ms on timestamp columns", async () => {
145
- await handle.insert(notesTs).values({ id: "old", title: "old", createdAt: 1 });
146
- await handle.insert(notesTs).values({ id: "new", title: "new", createdAt: 100 });
145
+ await handle
146
+ .insert(notesTs)
147
+ .values({ id: "old", title: "old", createdAt: 1 as unknown as Date });
148
+ await handle
149
+ .insert(notesTs)
150
+ .values({ id: "new", title: "new", createdAt: 100 as unknown as Date });
147
151
 
148
152
  // Drizzle types timestamp `{ mode: "date" }` as Date; the store still
149
153
  // coerces epoch-ms binds (`fx.clock.now()`) at WHERE compile time.
@@ -69,6 +69,8 @@ describe("SqlStoreHandle — no relational query surface (path b)", () => {
69
69
  "upsert",
70
70
  "increment",
71
71
  "raw",
72
+ "run",
73
+ "transaction",
72
74
  "count",
73
75
  "page",
74
76
  "ensureTable",
@@ -51,6 +51,24 @@ export type { WhereMap } from "./sql-condition.ts";
51
51
  */
52
52
  export type InferSelectRow<T> = T extends { readonly $inferSelect: infer R } ? R : SqlRow;
53
53
 
54
+ /**
55
+ * Insert row from a Drizzle table's `$inferInsert`, or {@link SqlRow}.
56
+ *
57
+ * @typeParam T - Table handle or Drizzle table
58
+ */
59
+ export type InferInsertRow<T> = T extends { readonly $inferInsert: infer R } ? R : SqlRow;
60
+
61
+ /**
62
+ * Equality map or a Drizzle SQL fragment (`queryChunks`).
63
+ * A bare `unknown` would swallow the row type.
64
+ *
65
+ * @typeParam TRow - Selected or inserted row
66
+ */
67
+ export type SqlPredicate<TRow> =
68
+ | { readonly [K in keyof TRow]?: TRow[K] }
69
+ | { readonly queryChunks: readonly unknown[] }
70
+ | undefined;
71
+
54
72
  /** Serialize stamp frames on a shared connection so concurrent identities cannot interleave. */
55
73
  const rlsStampTails = new WeakMap<SqlConnection, Promise<unknown>>();
56
74
 
@@ -119,6 +137,9 @@ function notifySqlCdc(event: {
119
137
  */
120
138
  const cdcMutationStorage = new AsyncLocalStorage<{ readonly mutationId: string }>();
121
139
 
140
+ /** Pinned connection for {@link SqlStoreHandle.transaction}. */
141
+ const sqlTxStorage = new AsyncLocalStorage<SqlConnection>();
142
+
122
143
  /**
123
144
  * Read the ambient mutation id, or `undefined` outside a stamped request.
124
145
  */
@@ -274,7 +295,7 @@ export interface SelectFromBuilder<TRow = SqlRow> extends PromiseLike<TRow[]> {
274
295
  *
275
296
  * @param where - Condition
276
297
  */
277
- where(where: unknown): SelectWhereBuilder<TRow>;
298
+ where(where: SqlPredicate<TRow>): SelectWhereBuilder<TRow>;
278
299
  /**
279
300
  * Order rows with Drizzle `asc()` / `desc()` terms.
280
301
  *
@@ -312,14 +333,18 @@ export interface SelectBuilder<TLocked extends SqlRow | undefined = undefined> {
312
333
  ): SelectFromBuilder<TLocked extends undefined ? InferSelectRow<TTable> : TLocked>;
313
334
  }
314
335
 
315
- /** Fluent insert builder. */
316
- export interface InsertBuilder {
336
+ /**
337
+ * Fluent insert builder.
338
+ *
339
+ * @typeParam TRow - `$inferInsert` when the table is a Drizzle table
340
+ */
341
+ export interface InsertBuilder<TRow = SqlRow> {
317
342
  /**
318
343
  * Provide row values.
319
344
  *
320
345
  * @param row - Row to insert
321
346
  */
322
- values(row: SqlRow): InsertValuesBuilder;
347
+ values(row: TRow): InsertValuesBuilder;
323
348
  }
324
349
 
325
350
  /**
@@ -340,7 +365,7 @@ export interface DeleteBuilder {
340
365
  *
341
366
  * @param where - Equality map or Drizzle SQL
342
367
  */
343
- where(where: unknown): Promise<number>;
368
+ where(where: SqlPredicate<SqlRow>): Promise<number>;
344
369
  }
345
370
 
346
371
  /** Fluent update builder. */
@@ -360,7 +385,7 @@ export interface UpdateSetBuilder {
360
385
  *
361
386
  * @param where - Equality map or Drizzle SQL
362
387
  */
363
- where(where: unknown): Promise<number>;
388
+ where(where: SqlPredicate<SqlRow>): Promise<number>;
364
389
  }
365
390
 
366
391
  /**
@@ -393,7 +418,7 @@ export interface SqlStoreHandle {
393
418
  *
394
419
  * @param table - Target table
395
420
  */
396
- insert(table: TableHandle | unknown): InsertBuilder;
421
+ insert<TTable>(table: TTable): InsertBuilder<InferInsertRow<TTable>>;
397
422
  /**
398
423
  * Start an update on `table`.
399
424
  *
@@ -415,6 +440,22 @@ export interface SqlStoreHandle {
415
440
  */
416
441
  delete(table: TableHandle | unknown): DeleteBuilder;
417
442
  delete(table: TableHandle | unknown, id: string): Promise<boolean>;
443
+ /**
444
+ * Run `fn` on one pinned connection. `fx.emit` inside `fn` stages on the
445
+ * signal outbox and publishes only after this SQL transaction commits.
446
+ *
447
+ * @param fn - Transaction body. `tx` is this handle, bound to the pin.
448
+ */
449
+ transaction<T>(fn: (tx: SqlStoreHandle) => Promise<T>): Promise<T>;
450
+ /**
451
+ * Execute a Drizzle select, insert, update, or delete builder.
452
+ * Calls `toSQL()` so `drizzle-orm` stays off the edge graph.
453
+ *
454
+ * @param query - Builder with `toSQL()`
455
+ */
456
+ run<T extends SqlRow = SqlRow>(query: {
457
+ toSQL(): { readonly sql: string; readonly params: readonly unknown[] };
458
+ }): Promise<T[]>;
418
459
  /**
419
460
  * True when at least one row matches.
420
461
  *
@@ -564,6 +605,10 @@ export function createSqlStoreHandle(
564
605
  }
565
606
  }
566
607
 
608
+ function activeConnection(): SqlConnection {
609
+ return sqlTxStorage.getStore() ?? connection;
610
+ }
611
+
567
612
  function query(sql: string, params: readonly unknown[] = []): Promise<SqlRow[]> {
568
613
  return withSchemaGuard(() => withRlsStamp((conn) => conn.query(sql, params), sql));
569
614
  }
@@ -572,9 +617,29 @@ export function createSqlStoreHandle(
572
617
  return withSchemaGuard(() => withRlsStamp((conn) => conn.exec(sql, params), sql));
573
618
  }
574
619
 
620
+ /**
621
+ * Rewrite Drizzle `$1` placeholders to the connection's `?` form.
622
+ *
623
+ * @param sql - Dialect SQL
624
+ * @param params - Bindings in `$n` order (1-based)
625
+ */
626
+ function fromDialect(
627
+ sql: string,
628
+ params: readonly unknown[],
629
+ ): { sql: string; params: unknown[] } {
630
+ if (!sql.includes("$")) return { sql, params: [...params] };
631
+ const ordered: unknown[] = [];
632
+ const text = sql.replace(/\$(\d+)/g, (_match, index: string) => {
633
+ ordered.push(params[Number(index) - 1]);
634
+ return "?";
635
+ });
636
+ return { sql: text, params: ordered };
637
+ }
638
+
575
639
  async function withRlsStamp<T>(fn: (conn: SqlConnection) => Promise<T>, sql: string): Promise<T> {
576
- if (!rls || !RLS_CONTEXT_DRIVERS.has(connection.driverId)) return fn(connection);
577
- if (isRlsStampExemptSql(sql)) return fn(connection);
640
+ if (!rls || !RLS_CONTEXT_DRIVERS.has(connection.driverId)) return fn(activeConnection());
641
+ if (isRlsStampExemptSql(sql)) return fn(activeConnection());
642
+ if (sqlTxStorage.getStore()) return fn(activeConnection());
578
643
  const run = (): Promise<T> => applyRlsStamp(fn);
579
644
  // PGlite is one backend session — concurrent identities must not interleave.
580
645
  // Pooled postgres pins each stamp via `transaction()` instead.
@@ -1332,6 +1397,27 @@ export function createSqlStoreHandle(
1332
1397
  });
1333
1398
  },
1334
1399
 
1400
+ async transaction<T>(fn: (tx: SqlStoreHandle) => Promise<T>): Promise<T> {
1401
+ if (!connection.transaction) {
1402
+ throw new Error("fx.store().transaction needs SqlConnection.transaction");
1403
+ }
1404
+ return connection.transaction(async (txConn) =>
1405
+ sqlTxStorage.run(txConn, () => fn(handle as SqlStoreHandle)),
1406
+ );
1407
+ },
1408
+
1409
+ async run<T extends SqlRow = SqlRow>(builder: {
1410
+ toSQL(): { readonly sql: string; readonly params: readonly unknown[] };
1411
+ }): Promise<T[]> {
1412
+ const compiled = builder.toSQL();
1413
+ const dialect = fromDialect(compiled.sql, compiled.params);
1414
+ if (isSqlDml(dialect.sql)) {
1415
+ await exec(dialect.sql, dialect.params);
1416
+ return [];
1417
+ }
1418
+ return query(dialect.sql, dialect.params) as Promise<T[]>;
1419
+ },
1420
+
1335
1421
  async ensureTable(table: TableHandle) {
1336
1422
  const cols = Object.values(table.columns);
1337
1423
  const pk = resolvePkColumn(table);
@@ -137,6 +137,7 @@ export type {
137
137
  SelectWhereBuilder,
138
138
  SelectOrderBuilder,
139
139
  InferSelectRow,
140
+ SqlPredicate,
140
141
  InsertBuilder,
141
142
  InsertValuesBuilder,
142
143
  SqlSessionOptions,
package/src/kernel/app.ts CHANGED
@@ -368,6 +368,15 @@ export interface OkeOptions {
368
368
  };
369
369
  };
370
370
  };
371
+ /**
372
+ * Tier-1 read cache. Omitted caches pure store-read flows. `{ auto: false }`
373
+ * turns that off. A flow can still opt in with `cache: true` or a duration,
374
+ * and `cache: false` always disables. The key includes tenant, locale,
375
+ * scopes, and membership roles.
376
+ */
377
+ readonly cache?: {
378
+ readonly auto?: boolean;
379
+ };
371
380
  /** Channel runtime options. */
372
381
  readonly channel?: BootOptions["channel"];
373
382
  /** AI runtime options. */
@@ -598,6 +607,8 @@ export interface OkeApp<D extends Record<string, unknown> = {}, R extends AppRou
598
607
  readonly runId?: string;
599
608
  /** Tenant identity for cron / `fx.call` (propagated, unlike auth). */
600
609
  readonly tenant?: { readonly id: string | null };
610
+ /** Explicit locale override for {@link Fx.t} / channel sends. */
611
+ readonly locale?: string;
601
612
  },
602
613
  ): Promise<ExecuteResult>;
603
614
  /**
@@ -2085,6 +2096,14 @@ export function oke(options: OkeOptions): OkeApp {
2085
2096
  : undefined;
2086
2097
  // Revealed PII must not hit a prior masked entry or land in cache.
2087
2098
  const reveal = extras?.trustedInvoke === true && extras.revealPii === true;
2099
+ const cacheAuto = options.cache?.auto !== false;
2100
+ const cacheCaller = {
2101
+ userId: fx.auth.userId,
2102
+ tenantId: fx.tenant.id,
2103
+ locale: fx.locale,
2104
+ scopes: fx.auth.scopes,
2105
+ roles: principals?.cacheRoles ?? [],
2106
+ };
2088
2107
  const cacheOk =
2089
2108
  cache !== undefined &&
2090
2109
  storeRt !== undefined &&
@@ -2092,12 +2111,13 @@ export function oke(options: OkeOptions): OkeApp {
2092
2111
  !reveal &&
2093
2112
  cache.autoCacheEligible({
2094
2113
  cache: flowDef.cache,
2114
+ auto: cacheAuto,
2095
2115
  durable: flowDef.durable,
2096
2116
  effects: cacheEffects,
2097
2117
  });
2098
2118
  const dims =
2099
2119
  cacheOk && cache && cacheEffects
2100
- ? cache.tier1DimsByResource(cacheEffects, flowDef.name, input, fx.auth.userId)
2120
+ ? cache.tier1DimsByResource(cacheEffects, flowDef.name, input, cacheCaller)
2101
2121
  : undefined;
2102
2122
  if (cacheOk && cache && dims && storeRt) {
2103
2123
  const keys = cache.tier1KeysForReads(cacheEffects, dims);
@@ -2132,39 +2152,35 @@ export function oke(options: OkeOptions): OkeApp {
2132
2152
  const output = isFlowFailure(raw) ? raw : await projectFlowOut(flowDef.out, raw);
2133
2153
  if (!isFlowFailure(output) && cache && storeRt && cacheEffects) {
2134
2154
  const ledgerFx = cache.effectsFromLedger(ledger.entries);
2155
+ const observed = cache.mergeEffects(cacheEffects, ledgerFx);
2135
2156
  const writeEffects: Effects = {
2136
- writes: [
2137
- ...new Set([...(cacheEffects.writes ?? []), ...(ledgerFx.writes ?? [])]),
2138
- ].filter(cache.isStoreResourceRef),
2157
+ writes: (observed.writes ?? []).filter(cache.isStoreResourceRef),
2139
2158
  };
2140
2159
  if ((writeEffects.writes?.length ?? 0) > 0) {
2141
2160
  storeRt.onWriteEffects(writeEffects);
2142
2161
  }
2143
- const mergedReads = [
2144
- ...new Set([...(cacheEffects.reads ?? []), ...(ledgerFx.reads ?? [])]),
2145
- ].filter(cache.isStoreResourceRef);
2146
- if (mergedReads.length > 0) {
2147
- learnedTier1Reads.set(flowDef.name, mergedReads);
2162
+ const storeReads = (observed.reads ?? []).filter(cache.isStoreResourceRef);
2163
+ if (cache.autoCachePure(observed) && storeReads.length > 0) {
2164
+ learnedTier1Reads.set(flowDef.name, storeReads);
2165
+ } else {
2166
+ learnedTier1Reads.delete(flowDef.name);
2148
2167
  }
2149
- const putEffects: Effects = {
2150
- reads: mergedReads,
2151
- ...(ledgerFx.writes ? { writes: ledgerFx.writes } : {}),
2152
- ...(ledgerFx.asks ? { asks: ledgerFx.asks } : {}),
2153
- };
2154
2168
  const storeAfter =
2155
2169
  !reveal &&
2156
2170
  cache.autoCacheEligible({
2157
2171
  cache: flowDef.cache,
2172
+ auto: cacheAuto,
2158
2173
  durable: flowDef.durable,
2159
- effects: putEffects,
2174
+ effects: observed,
2160
2175
  });
2161
2176
  if (storeAfter && output !== undefined && !loadFx().isJsonStreamResult(output)) {
2162
2177
  const ttlMs =
2163
2178
  typeof flowDef.cache === "string" ? cache.parseTtlMs(flowDef.cache) : undefined;
2179
+ const putEffects: Effects = { reads: storeReads };
2164
2180
  storeRt.putTier1(
2165
2181
  putEffects,
2166
2182
  output,
2167
- cache.tier1DimsByResource(putEffects, flowDef.name, input, fx.auth.userId),
2183
+ cache.tier1DimsByResource(putEffects, flowDef.name, input, cacheCaller),
2168
2184
  ttlMs,
2169
2185
  );
2170
2186
  if (!cacheOk) telemetry.cacheMisses += 1;