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
@@ -81,6 +81,21 @@ export function isInvalidatedByWrite(key: string, writeEffects: Effects): boolea
81
81
  return (writeEffects.writes ?? []).some((w) => w === resource);
82
82
  }
83
83
 
84
+ /** Options for {@link createStoreCache}. */
85
+ export interface CreateStoreCacheOptions {
86
+ /** Clock for TTL expiry. */
87
+ readonly now?: () => number;
88
+ /** Entry cap. Default 10_000. Oldest entry is dropped. */
89
+ readonly maxEntries?: number;
90
+ /** TTL applied when a put does not set one. Default 60_000. */
91
+ readonly defaultTtlMs?: number;
92
+ /**
93
+ * Tell other instances which resources changed. Not called for
94
+ * {@link StoreCache.acceptRemote}.
95
+ */
96
+ readonly fanout?: (resources: readonly ResourceRef[]) => void;
97
+ }
98
+
84
99
  /** In-memory multi-tier cache used by the store runtime and tests. */
85
100
  export interface StoreCache {
86
101
  /**
@@ -111,6 +126,32 @@ export interface StoreCache {
111
126
  keys(): string[];
112
127
  /** Clear all tiers. */
113
128
  clear(): void;
129
+ /** Default TTL for puts that omit one. */
130
+ readonly defaultTtlMs: number;
131
+ /** Current generation for a resource. Invalidation increments it. */
132
+ generationOf(resource: string): number;
133
+ /** True when every resource is still at the captured generation. */
134
+ sameGeneration(snapshot: Readonly<Record<string, number>>): boolean;
135
+ /**
136
+ * Apply an invalidation that arrived from another instance.
137
+ * Does not call `fanout`.
138
+ *
139
+ * @param resources - Resources the other instance wrote
140
+ */
141
+ acceptRemote(resources: readonly ResourceRef[]): InvalidationEvent;
142
+ /**
143
+ * Run `task` once per key. A caller that joined before an invalidation
144
+ * refetches once instead of keeping a value the cache will not store.
145
+ *
146
+ * @param key - Flight key
147
+ * @param resources - Resources read by the flight
148
+ * @param task - Loader
149
+ */
150
+ coalesce<T>(
151
+ key: string,
152
+ resources: readonly string[],
153
+ task: () => Promise<T>,
154
+ ): Promise<{ readonly value: T; readonly cacheable: boolean }>;
114
155
  }
115
156
 
116
157
  /**
@@ -118,37 +159,133 @@ export interface StoreCache {
118
159
  *
119
160
  * @param now - Clock for TTL expiry
120
161
  */
121
- export function createStoreCache(now: () => number = () => Date.now()): StoreCache {
162
+ export function createStoreCache(
163
+ nowOrOptions: (() => number) | CreateStoreCacheOptions = {},
164
+ ): StoreCache {
165
+ const options: CreateStoreCacheOptions =
166
+ typeof nowOrOptions === "function" ? { now: nowOrOptions } : nowOrOptions;
167
+ const now = options.now ?? (() => Date.now());
168
+ const maxEntries = options.maxEntries ?? 10_000;
169
+ const defaultTtlMs = options.defaultTtlMs ?? 60_000;
122
170
  const entries = new Map<string, CacheEntry>();
171
+ const byResource = new Map<string, Set<string>>();
172
+ const generations = new Map<string, number>();
173
+ const flights = new Map<
174
+ string,
175
+ {
176
+ readonly promise: Promise<unknown>;
177
+ readonly resources: readonly string[];
178
+ readonly generation: Readonly<Record<string, number>>;
179
+ }
180
+ >();
123
181
 
124
182
  function alive(entry: CacheEntry): boolean {
125
183
  return entry.expiresAt === null || entry.expiresAt > now();
126
184
  }
127
185
 
128
- return {
186
+ function forget(key: string): void {
187
+ const entry = entries.get(key);
188
+ entries.delete(key);
189
+ if (!entry) return;
190
+ for (const resource of entry.resources) byResource.get(resource)?.delete(key);
191
+ }
192
+
193
+ function snapshot(resources: readonly string[]): Record<string, number> {
194
+ const out: Record<string, number> = {};
195
+ for (const resource of resources) out[resource] = generations.get(resource) ?? 0;
196
+ return out;
197
+ }
198
+
199
+ function dropFlights(resources: readonly ResourceRef[]): void {
200
+ const set = new Set(resources);
201
+ for (const [key, flight] of flights) {
202
+ if (flight.resources.some((resource) => set.has(resource as ResourceRef))) {
203
+ flights.delete(key);
204
+ }
205
+ }
206
+ }
207
+
208
+ function invalidateLocal(resources: readonly ResourceRef[]): InvalidationEvent {
209
+ const keys: string[] = [];
210
+ for (const resource of resources) {
211
+ generations.set(resource, (generations.get(resource) ?? 0) + 1);
212
+ const held = byResource.get(resource);
213
+ if (!held) continue;
214
+ for (const key of held) {
215
+ keys.push(key);
216
+ forget(key);
217
+ }
218
+ held.clear();
219
+ }
220
+ dropFlights(resources);
221
+ return { resources: [...resources], keys };
222
+ }
223
+
224
+ const api: StoreCache = {
225
+ defaultTtlMs,
129
226
  get<T = unknown>(key: string): T | undefined {
130
227
  const entry = entries.get(key);
131
228
  if (!entry) return undefined;
132
229
  if (!alive(entry)) {
133
- entries.delete(key);
230
+ forget(key);
134
231
  return undefined;
135
232
  }
233
+ entries.delete(key);
234
+ entries.set(key, entry);
136
235
  return entry.value as T;
137
236
  },
138
237
  set<T>(entry: CacheEntry<T>): void {
238
+ forget(entry.key);
139
239
  entries.set(entry.key, entry as CacheEntry);
240
+ for (const resource of entry.resources) {
241
+ const set = byResource.get(resource) ?? new Set<string>();
242
+ set.add(entry.key);
243
+ byResource.set(resource, set);
244
+ }
245
+ while (entries.size > maxEntries) {
246
+ const oldest = entries.keys().next().value;
247
+ if (oldest === undefined) break;
248
+ forget(oldest);
249
+ }
140
250
  },
141
251
  invalidate(resources: readonly ResourceRef[]): InvalidationEvent {
142
- const set = new Set(resources);
143
- const keys: string[] = [];
144
- for (const [key, entry] of entries) {
145
- if (entry.tier !== 1) continue;
146
- if (entry.resources.some((r) => set.has(r))) {
147
- keys.push(key);
148
- entries.delete(key);
149
- }
252
+ const event = invalidateLocal(resources);
253
+ options.fanout?.(resources);
254
+ return event;
255
+ },
256
+ generationOf(resource: string): number {
257
+ return generations.get(resource) ?? 0;
258
+ },
259
+ sameGeneration(shot: Readonly<Record<string, number>>): boolean {
260
+ for (const [resource, generation] of Object.entries(shot)) {
261
+ if ((generations.get(resource) ?? 0) !== generation) return false;
262
+ }
263
+ return true;
264
+ },
265
+ acceptRemote(resources: readonly ResourceRef[]): InvalidationEvent {
266
+ return invalidateLocal(resources);
267
+ },
268
+ async coalesce<T>(
269
+ key: string,
270
+ resources: readonly string[],
271
+ task: () => Promise<T>,
272
+ ): Promise<{ readonly value: T; readonly cacheable: boolean }> {
273
+ const existing = flights.get(key);
274
+ if (existing) {
275
+ const value = (await existing.promise) as T;
276
+ if (api.sameGeneration(existing.generation)) return { value, cacheable: true };
277
+ const again = await task();
278
+ return { value: again, cacheable: api.sameGeneration(snapshot(resources)) };
279
+ }
280
+ const generation = snapshot(resources);
281
+ const promise = task();
282
+ flights.set(key, { promise, resources, generation });
283
+ try {
284
+ const value = await promise;
285
+ return { value, cacheable: api.sameGeneration(generation) };
286
+ } finally {
287
+ if (flights.get(key)?.promise === promise) flights.delete(key);
150
288
  }
151
- return { resources: [...resources], keys };
152
289
  },
153
290
  invalidateFromEffects(writeEffects: Effects): InvalidationEvent {
154
291
  return this.invalidate(resourcesTouchedByWrites(writeEffects));
@@ -158,8 +295,10 @@ export function createStoreCache(now: () => number = () => Date.now()): StoreCac
158
295
  },
159
296
  clear(): void {
160
297
  entries.clear();
298
+ byResource.clear();
161
299
  },
162
300
  };
301
+ return api;
163
302
  }
164
303
 
165
304
  /** Store resource refs that participate in the automatic cache cycle. */
@@ -174,44 +313,158 @@ export function isStoreResourceRef(ref: string): ref is ResourceRef {
174
313
  return STORE_REF.test(ref) && ref !== "runs";
175
314
  }
176
315
 
316
+ /** Effect list keys that disqualify tier-1 auto-cache when non-empty. */
317
+ const DISQUALIFYING_EFFECTS = [
318
+ "writes",
319
+ "emits",
320
+ "sends",
321
+ "asks",
322
+ "embeds",
323
+ "secrets",
324
+ "calls",
325
+ "fetches",
326
+ "decides",
327
+ ] as const satisfies readonly (keyof Effects)[];
328
+
177
329
  /**
178
- * Store reads / writes / asks recorded on one invocation's ledger.
330
+ * Store and non-store effects recorded on one invocation's ledger.
179
331
  *
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.
332
+ * Side effects stay on the result so auto-cache can refuse a flow that
333
+ * also fetched, emitted, or called — a learned store read is not enough.
182
334
  *
183
335
  * @param entries - Ledger entries from the invocation
184
336
  */
185
337
  export function effectsFromLedger(
186
338
  entries: readonly { readonly kind: string; readonly resource: string }[],
187
339
  ): Effects {
188
- const reads: ResourceRef[] = [];
189
- const writes: ResourceRef[] = [];
340
+ const reads: string[] = [];
341
+ const writes: string[] = [];
190
342
  const asks: string[] = [];
191
- const seenRead = new Set<string>();
192
- const seenWrite = new Set<string>();
343
+ const emits: string[] = [];
344
+ const sends: string[] = [];
345
+ const embeds: string[] = [];
346
+ const secrets: string[] = [];
347
+ const calls: string[] = [];
348
+ const fetches: string[] = [];
349
+ const decides: string[] = [];
350
+ const seen = new Set<string>();
351
+ const push = (bucket: string[], kind: string, resource: string): void => {
352
+ const key = `${kind}:${resource}`;
353
+ if (seen.has(key)) return;
354
+ seen.add(key);
355
+ bucket.push(resource);
356
+ };
193
357
  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);
358
+ switch (entry.kind) {
359
+ case "read":
360
+ push(reads, entry.kind, entry.resource);
361
+ break;
362
+ case "write":
363
+ push(writes, entry.kind, entry.resource);
364
+ break;
365
+ case "ask":
366
+ push(asks, entry.kind, entry.resource);
367
+ break;
368
+ case "emit":
369
+ push(emits, entry.kind, entry.resource);
370
+ break;
371
+ case "send":
372
+ push(sends, entry.kind, entry.resource);
373
+ break;
374
+ case "embed":
375
+ push(embeds, entry.kind, entry.resource);
376
+ break;
377
+ case "secret":
378
+ push(secrets, entry.kind, entry.resource);
379
+ break;
380
+ case "call":
381
+ push(calls, entry.kind, entry.resource);
382
+ break;
383
+ case "fetch":
384
+ push(fetches, entry.kind, entry.resource);
385
+ break;
386
+ case "decide":
387
+ push(decides, entry.kind, entry.resource);
388
+ break;
389
+ default:
390
+ break;
206
391
  }
207
392
  }
393
+ return {
394
+ ...(reads.length > 0 ? { reads: reads as Effects["reads"] } : {}),
395
+ ...(writes.length > 0 ? { writes: writes as Effects["writes"] } : {}),
396
+ ...(asks.length > 0 ? { asks } : {}),
397
+ ...(emits.length > 0 ? { emits } : {}),
398
+ ...(sends.length > 0 ? { sends } : {}),
399
+ ...(embeds.length > 0 ? { embeds } : {}),
400
+ ...(secrets.length > 0 ? { secrets } : {}),
401
+ ...(calls.length > 0 ? { calls } : {}),
402
+ ...(fetches.length > 0 ? { fetches } : {}),
403
+ ...(decides.length > 0 ? { decides } : {}),
404
+ };
405
+ }
406
+
407
+ /**
408
+ * True when every effect is a store read (`sql:` / `kv:` / `files:` / `index:`).
409
+ *
410
+ * Sends, emits, fetches, vault reads, asks, decides, calls, writes, and
411
+ * non-store reads (`runs`, `signal:`) are not cacheable.
412
+ *
413
+ * @param effects - Declared or ledgered effects
414
+ */
415
+ export function autoCachePure(effects: Effects | undefined): boolean {
416
+ if (!effects) return false;
417
+ for (const key of DISQUALIFYING_EFFECTS) {
418
+ if ((effects[key]?.length ?? 0) > 0) return false;
419
+ }
420
+ const reads = effects.reads ?? [];
421
+ if (reads.length === 0) return false;
422
+ return reads.every((ref) => isStoreResourceRef(ref));
423
+ }
424
+
425
+ /**
426
+ * Union two effect bags. Used so a ledgered fetch cannot be dropped
427
+ * before the auto-cache eligibility check.
428
+ *
429
+ * @param left - Declared or previously resolved effects
430
+ * @param right - Ledgered effects
431
+ */
432
+ export function mergeEffects(left: Effects, right: Effects): Effects {
433
+ const reads = union(left.reads, right.reads);
434
+ const writes = union(left.writes, right.writes);
435
+ const emits = union(left.emits, right.emits);
436
+ const sends = union(left.sends, right.sends);
437
+ const asks = union(left.asks, right.asks);
438
+ const embeds = union(left.embeds, right.embeds);
439
+ const secrets = union(left.secrets, right.secrets);
440
+ const calls = union(left.calls, right.calls);
441
+ const fetches = union(left.fetches, right.fetches);
442
+ const decides = union(left.decides, right.decides);
208
443
  return {
209
444
  ...(reads.length > 0 ? { reads } : {}),
210
445
  ...(writes.length > 0 ? { writes } : {}),
446
+ ...(emits.length > 0 ? { emits } : {}),
447
+ ...(sends.length > 0 ? { sends } : {}),
211
448
  ...(asks.length > 0 ? { asks } : {}),
449
+ ...(embeds.length > 0 ? { embeds } : {}),
450
+ ...(secrets.length > 0 ? { secrets } : {}),
451
+ ...(calls.length > 0 ? { calls } : {}),
452
+ ...(fetches.length > 0 ? { fetches } : {}),
453
+ ...(decides.length > 0 ? { decides } : {}),
212
454
  };
213
455
  }
214
456
 
457
+ /**
458
+ * Stable union of two effect lists.
459
+ *
460
+ * @param left - First list
461
+ * @param right - Second list
462
+ */
463
+ function union<T extends string>(left?: readonly T[], right?: readonly T[]): T[] {
464
+ if ((left?.length ?? 0) === 0 && (right?.length ?? 0) === 0) return [];
465
+ return [...new Set([...(left ?? []), ...(right ?? [])])];
466
+ }
467
+
215
468
  /**
216
469
  * Effects the auto-cache lookup should use: stamped reads when present,
217
470
  * otherwise reads learned from a previous run's ledger.
@@ -223,6 +476,9 @@ export function resolveCacheEffects(
223
476
  declared: Effects | undefined,
224
477
  learnedReads: readonly ResourceRef[] | undefined,
225
478
  ): Effects {
479
+ if (declared && !autoCachePure(declared) && hasAnyEffect(declared)) {
480
+ return declared;
481
+ }
226
482
  const declaredReads = (declared?.reads ?? []).filter((r) => isStoreResourceRef(r));
227
483
  if (declaredReads.length > 0 || (declared?.writes?.length ?? 0) > 0) {
228
484
  return declared ?? {};
@@ -233,6 +489,16 @@ export function resolveCacheEffects(
233
489
  return declared ?? {};
234
490
  }
235
491
 
492
+ /**
493
+ * Whether the effect bag records anything.
494
+ *
495
+ * @param effects - Declared or ledgered effects
496
+ */
497
+ function hasAnyEffect(effects: Effects): boolean {
498
+ const keys = ["reads", ...DISQUALIFYING_EFFECTS] as const;
499
+ return keys.some((key) => (effects[key]?.length ?? 0) > 0);
500
+ }
501
+
236
502
  /**
237
503
  * Tier-1 hit only when every key is still present (a write to any
238
504
  * contributing resource must miss).
@@ -255,48 +521,88 @@ export function tier1Lookup<T>(
255
521
  return value;
256
522
  }
257
523
 
524
+ /**
525
+ * Caller dimensions stamped into a tier-1 cache key.
526
+ *
527
+ * Segments are always present so an empty tenant cannot collide with a set one.
528
+ */
529
+ export interface Tier1Caller {
530
+ /** Authenticated user id. Empty when anonymous. */
531
+ readonly userId?: string | null;
532
+ /** Active tenant id. Empty when tenancy is off. */
533
+ readonly tenantId?: string | null;
534
+ /** Resolved request locale. */
535
+ readonly locale?: string | null;
536
+ /** Effective scopes, including the tenant-role union. */
537
+ readonly scopes?: Iterable<string>;
538
+ /**
539
+ * Membership role names. Cache identity only — gates do not read this.
540
+ */
541
+ readonly roles?: Iterable<string>;
542
+ /** Email / session verified bit. `v:0` and `v:1` must not share an entry. */
543
+ readonly verified?: boolean;
544
+ /** Operator id when the caller is on the operator plane. */
545
+ readonly operatorId?: string | null;
546
+ }
547
+
258
548
  /**
259
549
  * Whether a flow should use automatic tier-1 cache.
260
550
  *
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.
551
+ * On by default for a pure store read that is not durable. `auto: false`
552
+ * turns the app default off; a flow can still opt in with `cache: true` or a
553
+ * duration. `cache: false` always disables. Sends, emits, fetches, secrets,
554
+ * calls, asks, embeds, decides, and writes are never cached.
265
555
  *
266
- * @param options - Flow cache flag, durability, and effect set
556
+ * @param options - Flow cache flag, app switch, durability, and effect set
267
557
  */
268
558
  export function autoCacheEligible(options: {
269
559
  readonly cache?: boolean | string;
560
+ /** App-level switch. Omitted means on. `false` turns auto-cache off. */
561
+ readonly auto?: boolean;
270
562
  readonly durable?: boolean;
271
563
  readonly effects?: Effects;
272
564
  }): boolean {
273
565
  if (options.cache === false) return false;
274
566
  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;
567
+ if (options.auto === false && options.cache !== true && typeof options.cache !== "string") {
568
+ return false;
569
+ }
570
+ return autoCachePure(options.effects);
280
571
  }
281
572
 
282
573
  /**
283
574
  * Dimension suffixes for a flow-scoped tier-1 key.
284
575
  *
285
- * Format after {@link computedCacheKey}: `computed:{resource}/{flow}/{input}[/{userId}]`.
576
+ * Format after {@link computedCacheKey}:
577
+ * `computed:{resource}/{flow}/{input}/{userId}/t:{tenant}/l:{locale}/s:{scopes}/r:{roles}`.
286
578
  * Invalidation still keys off the resource segment.
287
579
  *
580
+ * A string third argument is the user id (older call shape).
581
+ *
288
582
  * @param flowName - Flow id
289
583
  * @param input - Validated flow input
290
- * @param userId - Caller id when present (per-user lists)
584
+ * @param caller - Caller identity, or a user id string
291
585
  */
292
586
  export function tier1FlowDims(
293
587
  flowName: string,
294
588
  input: unknown,
295
- userId?: string | null,
589
+ caller?: Tier1Caller | string | null,
296
590
  ): readonly string[] {
297
- const dims = [flowName, fingerprintInput(input)];
298
- if (userId) dims.push(userId);
299
- return dims;
591
+ const identity: Tier1Caller =
592
+ typeof caller === "string" || caller == null ? { userId: caller ?? null } : caller;
593
+ const scopes = [...(identity.scopes ?? [])].sort();
594
+ const roles = [...(identity.roles ?? [])].sort();
595
+ return [
596
+ flowName,
597
+ fingerprintInput(input),
598
+ identity.userId ?? "",
599
+ `t:${identity.tenantId ?? ""}`,
600
+ `l:${identity.locale ?? ""}`,
601
+ `s:${scopes.join(",")}`,
602
+ `r:${roles.join(",")}`,
603
+ `v:${identity.verified === true ? "1" : "0"}`,
604
+ `o:${identity.operatorId ?? ""}`,
605
+ ];
300
606
  }
301
607
 
302
608
  /**
@@ -305,18 +611,18 @@ export function tier1FlowDims(
305
611
  * @param effects - Read effects
306
612
  * @param flowName - Flow id
307
613
  * @param input - Validated flow input
308
- * @param userId - Caller id when present
614
+ * @param caller - Caller identity, or a user id string
309
615
  */
310
616
  export function tier1DimsByResource(
311
617
  effects: Effects,
312
618
  flowName: string,
313
619
  input: unknown,
314
- userId?: string | null,
620
+ caller?: Tier1Caller | string | null,
315
621
  ): Readonly<Record<string, readonly string[]>> {
316
- const dims = tier1FlowDims(flowName, input, userId);
622
+ const dims = tier1FlowDims(flowName, input, caller);
317
623
  const out: Record<string, readonly string[]> = {};
318
624
  for (const resource of effects.reads ?? []) {
319
- if (resource === "runs") continue;
625
+ if (!isStoreResourceRef(resource)) continue;
320
626
  out[resource] = dims;
321
627
  }
322
628
  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.
@@ -33,6 +33,7 @@ import {
33
33
  tier1KeysForReads,
34
34
  type StoreCache,
35
35
  } from "./cache.ts";
36
+ import { openCacheInvalidation, type CacheBusOptions } from "./cache-bus.ts";
36
37
  import { resolveSqlTarget, type SqlBindingConfig } from "./replica.ts";
37
38
  import { createSqlStoreHandle, type SqlStoreHandle } from "./sql-session.ts";
38
39
  import { classificationsFromTable, type TableHandle } from "./table.ts";
@@ -96,6 +97,15 @@ export interface CreateStoreRuntimeOptions {
96
97
  >;
97
98
  /** Clock for cache TTLs. */
98
99
  readonly now?: () => number;
100
+ /** Auto-cache entry cap. Default 10_000. */
101
+ readonly cacheMaxEntries?: number;
102
+ /** Auto-cache TTL when a flow sets none. Default 60_000. */
103
+ readonly cacheDefaultTtlMs?: number;
104
+ /**
105
+ * Cross-instance invalidation. Omit for a single process.
106
+ * Redis is push. Postgres is polled from the scheduler.
107
+ */
108
+ readonly cacheBus?: CacheBusOptions;
99
109
  /**
100
110
  * Domain DDL policy for SQL handles. Default `ensure` (test-friendly).
101
111
  * Boot sets `off` for docker/prod and local+autoPush.
@@ -277,6 +287,13 @@ export interface StoreRuntime {
277
287
  ): string[];
278
288
  /** Close all open connections. */
279
289
  close(): Promise<void>;
290
+ /**
291
+ * Pull Postgres cache invalidations. No-op unless {@link CreateStoreRuntimeOptions.cacheBus}
292
+ * is `postgres`.
293
+ */
294
+ pollCacheInvalidations(): Promise<void>;
295
+ /** Which invalidation transport this runtime opened. */
296
+ readonly cacheInvalidation: "off" | "redis" | "postgres";
280
297
  /**
281
298
  * Capability-gated files handle for `fx.store` (CRUD + image pipeline).
282
299
  *
@@ -300,7 +317,26 @@ export interface StoreRuntime {
300
317
  */
301
318
  export function createStoreRuntime(options: CreateStoreRuntimeOptions): StoreRuntime {
302
319
  const now = options.now ?? (() => Date.now());
303
- const cache = createStoreCache(now);
320
+ let publishInvalidation: ((resources: readonly ResourceRef[]) => void) | undefined;
321
+ const cache = createStoreCache({
322
+ now,
323
+ fanout: (resources) => {
324
+ publishInvalidation?.(resources);
325
+ },
326
+ ...(options.cacheMaxEntries !== undefined ? { maxEntries: options.cacheMaxEntries } : {}),
327
+ ...(options.cacheDefaultTtlMs !== undefined ? { defaultTtlMs: options.cacheDefaultTtlMs } : {}),
328
+ });
329
+ const cacheInvalidation = options.cacheBus?.kind ?? "off";
330
+ let pollInvalidation: (() => Promise<void>) | undefined;
331
+ let stopInvalidation: (() => Promise<void>) | undefined;
332
+ if (options.cacheBus) {
333
+ const bus = openCacheInvalidation(cache, options.cacheBus);
334
+ publishInvalidation = (resources) => {
335
+ bus.publish(resources);
336
+ };
337
+ pollInvalidation = () => bus.poll();
338
+ stopInvalidation = () => bus.stop();
339
+ }
304
340
  const declarations = new Map<string, StoreDecl>();
305
341
  const sqlConns = new Map<string, SqlConnection>();
306
342
  const kvNs = new Map<string, KvNamespace>();
@@ -570,7 +606,8 @@ export function createStoreRuntime(options: CreateStoreRuntimeOptions): StoreRun
570
606
  putTier1(effects, value, dimsByResource, ttlMs) {
571
607
  const keys = tier1KeysForReads(effects, dimsByResource);
572
608
  const resources = (effects.reads ?? []).filter((r): r is ResourceRef => r !== "runs");
573
- const expiresAt = ttlMs !== undefined && ttlMs !== null && ttlMs > 0 ? now() + ttlMs : null;
609
+ const ttl = ttlMs !== undefined && ttlMs !== null && ttlMs > 0 ? ttlMs : cache.defaultTtlMs;
610
+ const expiresAt = now() + ttl;
574
611
  for (const key of keys) {
575
612
  cache.set({
576
613
  tier: 1,
@@ -582,7 +619,12 @@ export function createStoreRuntime(options: CreateStoreRuntimeOptions): StoreRun
582
619
  }
583
620
  return keys;
584
621
  },
622
+ cacheInvalidation,
623
+ async pollCacheInvalidations() {
624
+ if (pollInvalidation) await pollInvalidation();
625
+ },
585
626
  async close() {
627
+ if (stopInvalidation) await stopInvalidation();
586
628
  for (const c of sqlConns.values()) await c.close();
587
629
  for (const n of kvNs.values()) await n.close();
588
630
  for (const b of fileBuckets.values()) await b.close();