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
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Cross-instance cache invalidation.
3
+ *
4
+ * Redis publishes `{ origin, resources }` on `oke:cache:invalidate`.
5
+ * Postgres, when Redis is not configured, inserts into `oke_cache_invalidations`
6
+ * and polls on database time. A process applies remote notices with
7
+ * {@link StoreCache.acceptRemote} and does not publish them again.
8
+ * Memory and single-instance apps do not broadcast. A missed notice is
9
+ * bounded by the cache TTL.
10
+ */
11
+
12
+ import type { ResourceRef } from "../../manifest/types.ts";
13
+ import type { StoreCache } from "./cache.ts";
14
+
15
+ /** Redis pub/sub channel for cache invalidation. */
16
+ export const CACHE_INVALIDATE_CHANNEL = "oke:cache:invalidate";
17
+
18
+ /** Postgres table for cache invalidation when Redis is not configured. */
19
+ export const CACHE_INVALIDATIONS_TABLE = "oke_cache_invalidations";
20
+
21
+ /** Default overlap when polling the Postgres invalidation table. */
22
+ const DEFAULT_LAG_MS = 30_000;
23
+
24
+ /** Drop Postgres rows older than this. */
25
+ const RETAIN_MS = 120_000;
26
+
27
+ /** Redis commands the bus needs. */
28
+ export interface CacheBusRedis {
29
+ /**
30
+ * Publish one message.
31
+ *
32
+ * @param channel - Channel name
33
+ * @param message - JSON payload
34
+ */
35
+ publish(channel: string, message: string): Promise<unknown>;
36
+ /**
37
+ * Subscribe. The returned function removes the listener.
38
+ *
39
+ * @param channel - Channel name
40
+ * @param listener - Message callback
41
+ */
42
+ subscribe(channel: string, listener: (message: string) => void): Promise<() => void>;
43
+ }
44
+
45
+ /** SQL surface the Postgres bus uses. Placeholders are `?`. */
46
+ export interface CacheBusSql {
47
+ /**
48
+ * Run a query and return rows.
49
+ *
50
+ * @param sql - Statement
51
+ * @param params - Bound parameters
52
+ */
53
+ query(sql: string, params?: readonly unknown[]): Promise<ReadonlyArray<Record<string, unknown>>>;
54
+ /**
55
+ * Run a statement.
56
+ *
57
+ * @param sql - Statement
58
+ * @param params - Bound parameters
59
+ */
60
+ exec(sql: string, params?: readonly unknown[]): Promise<{ changes: number }>;
61
+ }
62
+
63
+ /** How {@link openCacheInvalidation} reaches other instances. */
64
+ export type CacheBusOptions =
65
+ | {
66
+ readonly kind: "redis";
67
+ readonly redis: CacheBusRedis;
68
+ /** Sender id. Remote notices with this origin are ignored. */
69
+ readonly origin?: string;
70
+ }
71
+ | {
72
+ readonly kind: "postgres";
73
+ readonly sql: () => Promise<CacheBusSql | undefined>;
74
+ readonly origin?: string;
75
+ /** Overlap behind the cursor. Default 30_000. */
76
+ readonly lagMs?: number;
77
+ };
78
+
79
+ /** Handle returned by {@link openCacheInvalidation}. */
80
+ export interface CacheInvalidationBus {
81
+ /** Tell other instances these resources changed. */
82
+ publish(resources: readonly ResourceRef[]): void;
83
+ /** Pull Postgres notices. No-op for Redis. */
84
+ poll(): Promise<void>;
85
+ /** Stop the subscription or poll. */
86
+ stop(): Promise<void>;
87
+ }
88
+
89
+ /**
90
+ * Bind `cache` to Redis or Postgres invalidation.
91
+ *
92
+ * @param cache - Local cache
93
+ * @param options - Transport
94
+ */
95
+ export function openCacheInvalidation(
96
+ cache: StoreCache,
97
+ options: CacheBusOptions,
98
+ ): CacheInvalidationBus {
99
+ const origin = options.origin ?? crypto.randomUUID();
100
+ if (options.kind === "redis") return openRedis(cache, options.redis, origin);
101
+ return openPostgres(cache, options.sql, origin, options.lagMs ?? DEFAULT_LAG_MS);
102
+ }
103
+
104
+ /**
105
+ * Bun Redis client for the invalidation channel.
106
+ * Subscribe uses its own connection.
107
+ *
108
+ * @param url - Redis URL
109
+ */
110
+ export function bunRedisCacheClient(url: string): CacheBusRedis {
111
+ const pub = new Bun.RedisClient(url);
112
+ const sub = new Bun.RedisClient(url);
113
+ return {
114
+ publish: (channel, message) => pub.publish(channel, message),
115
+ async subscribe(channel, listener) {
116
+ await sub.subscribe(channel, (message) => {
117
+ listener(message);
118
+ });
119
+ return () => {
120
+ void sub.unsubscribe(channel);
121
+ };
122
+ },
123
+ };
124
+ }
125
+
126
+ function openRedis(cache: StoreCache, redis: CacheBusRedis, origin: string): CacheInvalidationBus {
127
+ let unsub: (() => void) | undefined;
128
+ let stopped = false;
129
+ const ready = redis
130
+ .subscribe(CACHE_INVALIDATE_CHANNEL, (message) => {
131
+ if (stopped) return;
132
+ const resources = resourcesFromMessage(message, origin);
133
+ if (resources.length > 0) cache.acceptRemote(resources);
134
+ })
135
+ .then((stop) => {
136
+ unsub = stop;
137
+ })
138
+ .catch((err: unknown) => {
139
+ console.error(err);
140
+ });
141
+ return {
142
+ publish(resources) {
143
+ if (stopped || resources.length === 0) return;
144
+ const body = JSON.stringify({ origin, resources });
145
+ void redis.publish(CACHE_INVALIDATE_CHANNEL, body).catch((err: unknown) => {
146
+ console.error(err);
147
+ });
148
+ },
149
+ poll: async () => {},
150
+ async stop() {
151
+ stopped = true;
152
+ await ready;
153
+ unsub?.();
154
+ unsub = undefined;
155
+ },
156
+ };
157
+ }
158
+
159
+ function openPostgres(
160
+ cache: StoreCache,
161
+ connect: () => Promise<CacheBusSql | undefined>,
162
+ origin: string,
163
+ lagMs: number,
164
+ ): CacheInvalidationBus {
165
+ let stopped = false;
166
+ let ready: Promise<void> | undefined;
167
+ let cursor = 0;
168
+ let head = true;
169
+ const seen = new Set<string>();
170
+
171
+ async function sql(): Promise<CacheBusSql | undefined> {
172
+ if (stopped) return undefined;
173
+ const conn = await connect();
174
+ if (!conn || stopped) return undefined;
175
+ if (!ready) ready = ensure(conn);
176
+ await ready;
177
+ return conn;
178
+ }
179
+
180
+ return {
181
+ publish(resources) {
182
+ if (stopped || resources.length === 0) return;
183
+ void insert(sql, origin, resources);
184
+ },
185
+ async poll() {
186
+ const conn = await sql();
187
+ if (!conn || stopped) return;
188
+ const clock = await conn.query(DB_NOW_SQL);
189
+ const dbNow = asNumber(clock[0]?.db_now);
190
+ const since = head ? 0 : cursor - lagMs;
191
+ const rows = await conn.query(POLL_SQL, [since]);
192
+ const fresh: ResourceRef[] = [];
193
+ for (const row of rows) {
194
+ const id = asString(row.id);
195
+ if (!id || seen.has(id)) continue;
196
+ seen.add(id);
197
+ if (head || asString(row.origin) === origin) continue;
198
+ const resource = asString(row.resource);
199
+ if (resource) fresh.push(resource as ResourceRef);
200
+ }
201
+ if (!head && fresh.length > 0) cache.acceptRemote(fresh);
202
+ head = false;
203
+ cursor = dbNow;
204
+ await conn.exec(DELETE_SQL, [dbNow - RETAIN_MS]);
205
+ if (seen.size > 10_000) {
206
+ seen.clear();
207
+ }
208
+ },
209
+ async stop() {
210
+ stopped = true;
211
+ },
212
+ };
213
+ }
214
+
215
+ async function ensure(conn: CacheBusSql): Promise<void> {
216
+ await conn.exec(CREATE_SQL);
217
+ await conn.exec(INDEX_SQL);
218
+ }
219
+
220
+ async function insert(
221
+ connect: () => Promise<CacheBusSql | undefined>,
222
+ origin: string,
223
+ resources: readonly ResourceRef[],
224
+ ): Promise<void> {
225
+ const conn = await connect();
226
+ if (!conn) return;
227
+ for (const resource of resources) {
228
+ await conn.exec(INSERT_SQL, [crypto.randomUUID(), resource, origin]);
229
+ }
230
+ }
231
+
232
+ function resourcesFromMessage(message: string, origin: string): ResourceRef[] {
233
+ try {
234
+ const parsed: unknown = JSON.parse(message);
235
+ if (parsed === null || typeof parsed !== "object") return [];
236
+ const rec = parsed as Record<string, unknown>;
237
+ if (rec.origin === origin) return [];
238
+ if (!Array.isArray(rec.resources)) return [];
239
+ const out: ResourceRef[] = [];
240
+ for (const item of rec.resources) {
241
+ if (typeof item === "string" && item.length > 0) out.push(item as ResourceRef);
242
+ }
243
+ return out;
244
+ } catch {
245
+ return [];
246
+ }
247
+ }
248
+
249
+ function asString(value: unknown): string {
250
+ return typeof value === "string" ? value : "";
251
+ }
252
+
253
+ function asNumber(value: unknown): number {
254
+ if (typeof value === "number" && Number.isFinite(value)) return value;
255
+ if (typeof value === "bigint") return Number(value);
256
+ if (typeof value === "string" && value.trim() !== "") {
257
+ const n = Number(value);
258
+ if (Number.isFinite(n)) return n;
259
+ }
260
+ return 0;
261
+ }
262
+
263
+ const CREATE_SQL = `CREATE TABLE IF NOT EXISTS ${CACHE_INVALIDATIONS_TABLE} (
264
+ id TEXT PRIMARY KEY,
265
+ resource TEXT NOT NULL,
266
+ origin TEXT NOT NULL,
267
+ created_at BIGINT NOT NULL DEFAULT ((extract(epoch from clock_timestamp()) * 1000)::bigint)
268
+ )`;
269
+
270
+ const INDEX_SQL = `CREATE INDEX IF NOT EXISTS ${CACHE_INVALIDATIONS_TABLE}_created_at ON ${CACHE_INVALIDATIONS_TABLE} (created_at)`;
271
+
272
+ const INSERT_SQL = `INSERT INTO ${CACHE_INVALIDATIONS_TABLE} (id, resource, origin) VALUES (?, ?, ?)`;
273
+
274
+ const DB_NOW_SQL = `SELECT (extract(epoch from clock_timestamp()) * 1000)::bigint AS db_now`;
275
+
276
+ const POLL_SQL = `SELECT id, resource, origin, created_at FROM ${CACHE_INVALIDATIONS_TABLE} WHERE created_at >= ?`;
277
+
278
+ const DELETE_SQL = `DELETE FROM ${CACHE_INVALIDATIONS_TABLE} WHERE created_at < ?`;
@@ -17,6 +17,7 @@ import {
17
17
  isInvalidatedByWrite,
18
18
  resolveCacheEffects,
19
19
  tier1DimsByResource,
20
+ tier1FlowDims,
20
21
  tier1KeysForReads,
21
22
  tier1Lookup,
22
23
  } from "./cache.ts";
@@ -79,21 +80,41 @@ describe("tier-1 cache — exact per-resource invalidation (path b)", () => {
79
80
  });
80
81
 
81
82
  describe("autoCacheEligible", () => {
82
- test("read-only flows cache by default; mutations / asks / durable / cache:false do not", () => {
83
- expect(autoCacheEligible({ effects: { reads: ["sql:notes"] } })).toBe(true);
84
- expect(autoCacheEligible({ cache: true, effects: { reads: ["sql:notes"] } })).toBe(true);
85
- expect(autoCacheEligible({ cache: "30s", effects: { reads: ["sql:notes"] } })).toBe(true);
86
- expect(autoCacheEligible({ cache: false, effects: { reads: ["sql:notes"] } })).toBe(false);
87
- expect(autoCacheEligible({ durable: true, effects: { reads: ["sql:notes"] } })).toBe(false);
88
- expect(autoCacheEligible({ effects: { reads: ["sql:notes"], writes: ["sql:notes"] } })).toBe(
89
- false,
90
- );
91
- expect(autoCacheEligible({ effects: { reads: ["sql:notes"], asks: ["task-suggest"] } })).toBe(
83
+ test("pure store reads cache by default; side effects never cache", () => {
84
+ const reads: Effects = { reads: ["sql:notes"] };
85
+ expect(autoCacheEligible({ effects: reads })).toBe(true);
86
+ expect(autoCacheEligible({ auto: false, effects: reads })).toBe(false);
87
+ expect(autoCacheEligible({ auto: false, cache: true, effects: reads })).toBe(true);
88
+ expect(autoCacheEligible({ auto: true, effects: reads })).toBe(true);
89
+ expect(autoCacheEligible({ cache: true, effects: reads })).toBe(true);
90
+ expect(autoCacheEligible({ cache: "30s", effects: reads })).toBe(true);
91
+ expect(autoCacheEligible({ auto: true, cache: false, effects: reads })).toBe(false);
92
+ expect(autoCacheEligible({ cache: true, durable: true, effects: reads })).toBe(false);
93
+ expect(
94
+ autoCacheEligible({ cache: true, effects: { reads: ["sql:notes"], writes: ["sql:notes"] } }),
95
+ ).toBe(false);
96
+ expect(
97
+ autoCacheEligible({ cache: true, effects: { reads: ["sql:notes"], asks: ["task-suggest"] } }),
98
+ ).toBe(false);
99
+ for (const extra of [
100
+ { emits: ["note.created"] },
101
+ { sends: ["welcome"] },
102
+ { fetches: ["api.example.com"] },
103
+ { secrets: ["stripe"] },
104
+ { calls: ["notes.create"] },
105
+ { embeds: ["embed-small"] },
106
+ { decides: ["route"] },
107
+ ]) {
108
+ expect(autoCacheEligible({ auto: true, effects: { reads: ["sql:notes"], ...extra } })).toBe(
109
+ false,
110
+ );
111
+ }
112
+ expect(autoCacheEligible({ auto: true, effects: { reads: ["runs"] } })).toBe(false);
113
+ expect(autoCacheEligible({ auto: true, effects: { reads: ["signal:notify"] } })).toBe(false);
114
+ expect(autoCacheEligible({ auto: true, effects: { reads: ["sql:notes", "runs"] } })).toBe(
92
115
  false,
93
116
  );
94
- expect(autoCacheEligible({ effects: { reads: ["runs"] } })).toBe(false);
95
- expect(autoCacheEligible({ effects: { reads: ["signal:notify"] } })).toBe(false);
96
- expect(autoCacheEligible({ effects: {} })).toBe(false);
117
+ expect(autoCacheEligible({ auto: true, effects: {} })).toBe(false);
97
118
  });
98
119
 
99
120
  test("flow+input dims keep list and get from colliding; invalidation still uses the resource", () => {
@@ -107,7 +128,7 @@ describe("autoCacheEligible", () => {
107
128
  expect(isInvalidatedByWrite(getKeys[0]!, { writes: ["sql:views"] })).toBe(true);
108
129
  });
109
130
 
110
- test("effectsFromLedger keeps store reads/writes and asks; drops runs", () => {
131
+ test("effectsFromLedger keeps store effects and side effects, including non-store reads", () => {
111
132
  expect(
112
133
  effectsFromLedger([
113
134
  { kind: "read", resource: "sql:views" },
@@ -117,14 +138,98 @@ describe("autoCacheEligible", () => {
117
138
  { kind: "write", resource: "sql:views" },
118
139
  { kind: "ask", resource: "summarize" },
119
140
  { kind: "emit", resource: "view-changed" },
141
+ { kind: "send", resource: "welcome" },
142
+ { kind: "fetch", resource: "api.example.com" },
143
+ { kind: "secret", resource: "stripe" },
144
+ { kind: "call", resource: "notes.create" },
145
+ { kind: "embed", resource: "embed-small" },
146
+ { kind: "decide", resource: "route" },
120
147
  ]),
121
148
  ).toEqual({
122
- reads: ["sql:views"],
149
+ reads: ["sql:views", "runs", "signal:notify"],
123
150
  writes: ["sql:views"],
124
151
  asks: ["summarize"],
152
+ emits: ["view-changed"],
153
+ sends: ["welcome"],
154
+ fetches: ["api.example.com"],
155
+ secrets: ["stripe"],
156
+ calls: ["notes.create"],
157
+ embeds: ["embed-small"],
158
+ decides: ["route"],
125
159
  });
126
160
  });
127
161
 
162
+ test("tier-1 dims always include tenant, locale, scopes, and roles", () => {
163
+ const effects: Effects = { reads: ["sql:notes"] };
164
+ const a = tier1FlowDims(
165
+ "notes.list",
166
+ {},
167
+ {
168
+ userId: "u1",
169
+ tenantId: "ta",
170
+ locale: "en",
171
+ scopes: ["b", "a"],
172
+ roles: ["admin"],
173
+ },
174
+ );
175
+ const b = tier1FlowDims(
176
+ "notes.list",
177
+ {},
178
+ {
179
+ userId: "u1",
180
+ tenantId: "tb",
181
+ locale: "en",
182
+ scopes: ["a", "b"],
183
+ roles: ["admin"],
184
+ },
185
+ );
186
+ const locale = tier1FlowDims(
187
+ "notes.list",
188
+ {},
189
+ {
190
+ userId: "u1",
191
+ tenantId: "ta",
192
+ locale: "ar",
193
+ scopes: ["a", "b"],
194
+ roles: ["member"],
195
+ },
196
+ );
197
+ expect(a).toContain("t:ta");
198
+ expect(a).toContain("l:en");
199
+ expect(a).toContain("s:a,b");
200
+ expect(a).toContain("r:admin");
201
+ expect(a).not.toEqual(b);
202
+ expect(
203
+ tier1DimsByResource(
204
+ effects,
205
+ "notes.list",
206
+ {},
207
+ {
208
+ userId: "u1",
209
+ tenantId: "ta",
210
+ locale: "en",
211
+ scopes: ["a"],
212
+ roles: ["admin"],
213
+ },
214
+ )["sql:notes"],
215
+ ).not.toEqual(
216
+ tier1DimsByResource(
217
+ effects,
218
+ "notes.list",
219
+ {},
220
+ {
221
+ userId: "u1",
222
+ tenantId: "ta",
223
+ locale: "ar",
224
+ scopes: ["a"],
225
+ roles: ["member"],
226
+ },
227
+ )["sql:notes"],
228
+ );
229
+ expect(locale).toContain("l:ar");
230
+ expect(locale).toContain("r:member");
231
+ });
232
+
128
233
  test("resolveCacheEffects prefers stamped reads, then learned reads", () => {
129
234
  expect(resolveCacheEffects({ reads: ["sql:views"] }, ["sql:tasks"])).toEqual({
130
235
  reads: ["sql:views"],
@@ -153,3 +258,62 @@ describe("autoCacheEligible", () => {
153
258
  expect(tier1Lookup((key) => cache.get<number>(key), ["a", "b"])).toBe(1);
154
259
  });
155
260
  });
261
+
262
+ describe("tier-1 cache bounds", () => {
263
+ test("LRU drops the oldest entry past the cap and a remote invalidation is not republished", () => {
264
+ const seen: string[][] = [];
265
+ let now = 1_000;
266
+ const cache = createStoreCache({
267
+ now: () => now,
268
+ maxEntries: 2,
269
+ fanout: (resources) => {
270
+ seen.push([...resources]);
271
+ },
272
+ });
273
+ cache.set({ tier: 1, key: "a", value: 1, resources: ["sql:a"], expiresAt: now + 60_000 });
274
+ cache.set({ tier: 1, key: "b", value: 2, resources: ["sql:b"], expiresAt: now + 60_000 });
275
+ cache.get("a");
276
+ cache.set({ tier: 1, key: "c", value: 3, resources: ["sql:c"], expiresAt: now + 60_000 });
277
+ expect(cache.get<number>("b")).toBeUndefined();
278
+ expect(cache.get<number>("a")).toBe(1);
279
+ cache.invalidate(["sql:a"]);
280
+ expect(seen).toEqual([["sql:a"]]);
281
+ expect(cache.generationOf("sql:a")).toBe(1);
282
+ cache.acceptRemote(["sql:c"]);
283
+ expect(seen).toEqual([["sql:a"]]);
284
+ expect(cache.get("c")).toBeUndefined();
285
+ });
286
+
287
+ test("a flight that is invalidated does not cache, and the waiter refetches", async () => {
288
+ const cache = createStoreCache({ maxEntries: 10 });
289
+ let reads = 0;
290
+ let release!: () => void;
291
+ const gate = new Promise<void>((resolve) => {
292
+ release = resolve;
293
+ });
294
+ const leader = cache.coalesce("k", ["sql:notes"], async () => {
295
+ reads += 1;
296
+ await gate;
297
+ return reads;
298
+ });
299
+ const waiter = cache.coalesce("k", ["sql:notes"], async () => {
300
+ reads += 1;
301
+ return reads;
302
+ });
303
+ cache.invalidate(["sql:notes"]);
304
+ release();
305
+ const first = await leader;
306
+ const second = await waiter;
307
+ expect(first.cacheable).toBe(false);
308
+ expect(second.value).not.toBe(first.value);
309
+ expect(reads).toBe(2);
310
+ });
311
+
312
+ test("verified and operator are part of the key", () => {
313
+ const anon = tier1FlowDims("read", {}, { verified: false, operatorId: null });
314
+ const verified = tier1FlowDims("read", {}, { verified: true, operatorId: "op-1" });
315
+ expect(anon.join("/")).not.toBe(verified.join("/"));
316
+ expect(verified.join("/")).toContain("v:1");
317
+ expect(verified.join("/")).toContain("o:op-1");
318
+ });
319
+ });