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.
- package/package.json +1 -1
- package/site/content/docs/ai/mcp.mdx +4 -4
- package/site/content/docs/elements/channel/index.mdx +4 -4
- package/site/content/docs/elements/channel/receipts.mdx +7 -5
- package/site/content/docs/elements/channel/sms.mdx +2 -1
- package/site/content/docs/elements/flow/index.mdx +22 -18
- package/site/content/docs/elements/signal/broadcast.mdx +4 -2
- package/site/content/docs/elements/signal/index.mdx +15 -14
- package/site/content/docs/plugins/otp.mdx +3 -3
- package/site/content/docs/reference/configuration.mdx +16 -0
- package/site/content/docs/reference/errors.mdx +31 -19
- package/site/content/docs/reference/fx.mdx +12 -4
- package/src/auth/tenants.ts +17 -0
- package/src/cli/doctor-diff.test.ts +29 -1
- package/src/cli/doctor-diff.ts +35 -0
- package/src/cli/manifest-pr-diff.ts +16 -0
- package/src/compiler/aot.test.ts +3 -2
- package/src/compiler/aot.ts +45 -1
- package/src/compiler/dynamic.ts +1 -1
- package/src/compiler/effects-fetch.test.ts +77 -0
- package/src/compiler/effects-infer.ts +87 -16
- package/src/compiler/effects-join.test.ts +48 -0
- package/src/compiler/extract.test.ts +28 -0
- package/src/compiler/extract.ts +11 -1
- package/src/compiler/fx-index.ts +58 -4
- package/src/compiler/http-parse.test.ts +101 -0
- package/src/compiler/http-parse.ts +188 -9
- package/src/compiler/interpret.ts +25 -2
- package/src/console/index.ts +1 -1
- package/src/console/ui-next/dist/assets/{access-page-DDKkhT9v.js → access-page-zBMhZnFm.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-62Xts8Xi.js → agent-disclosure-CBj9f7Ju.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-DyeoKHKA.js → cache-glyph-D3MY49h3.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-DvfpLXsU.js → call-pii-button-L9JxdBhp.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-BY03SeCg.js → collapsible-BvFaX-Zt.js} +1 -1
- package/src/console/ui-next/dist/assets/{decisions-page-CgkKlA56.js → decisions-page-DgB76m-X.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-Y6HLjyJ5.js → duration-tone-l1DSJ2Vz.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-DLPfNy-_.js → flows-page-Bb9t1lu6.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-D7nNzpgB.js → highlighted-json-DMvsi3Ti.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-DdL19zzh.js → http-method-BwhJBTcQ.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-C0lc_s9d.js → index-RhV2jT_7.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CRlcyLCC.js → observability-page-BRfUILBq.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-Bb3FWUu9.js → replica-lag-BsceAzIG.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-DYBMWhX8.js → request-meta-C6lTyCXp.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-CcE-SXC8.js → store-page-YmE806bJ.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-CKMSEQ4s.js → trace-detail-sheet-Di4irS49.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-QOfk0M0H.js → tree-expand-toggle-DYeL6Rye.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-DKC1CDDd.js → units-page-07agasRA.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-B54v4Yit.js → vault-page-CHBck4n_.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/seed-parked-approval.ts +5 -1
- package/src/drivers/journal-postgres.test.ts +106 -2
- package/src/drivers/journal-postgres.ts +475 -14
- package/src/drivers/postgres.test.ts +60 -0
- package/src/drivers/postgres.ts +79 -5
- package/src/drivers/signal-compete.test.ts +238 -0
- package/src/drivers/signal-postgres.test.ts +170 -0
- package/src/drivers/signal-postgres.ts +486 -213
- package/src/drivers/signal-redis.test.ts +224 -0
- package/src/drivers/signal-redis.ts +934 -93
- package/src/drivers/signal-types.ts +12 -0
- package/src/elements/ai/approval.test.ts +15 -3
- package/src/elements/ai/approval.ts +15 -9
- package/src/elements/ai/mcp-protocol.ts +5 -6
- package/src/elements/ai/runtime.ts +20 -19
- package/src/elements/channel/runtime.ts +42 -4
- package/src/elements/channel/sql-ledger.test.ts +404 -0
- package/src/elements/channel/sql-ledger.ts +395 -0
- package/src/elements/clock/chaos-child.ts +12 -2
- package/src/elements/clock/durable.ts +11 -0
- package/src/elements/signal/runtime.ts +9 -0
- package/src/elements/signal.test.ts +1 -0
- package/src/elements/store/cache-bus.test.ts +121 -0
- package/src/elements/store/cache-bus.ts +278 -0
- package/src/elements/store/cache.test.ts +179 -15
- package/src/elements/store/cache.ts +357 -51
- package/src/elements/store/prepare-row.test.ts +6 -2
- package/src/elements/store/runtime.ts +44 -2
- package/src/elements/store/sql-errors.test.ts +131 -0
- package/src/elements/store/sql-errors.ts +39 -16
- package/src/elements/store/sql-nested-tx.test.ts +344 -0
- package/src/elements/store/sql-session.test.ts +2 -0
- package/src/elements/store/sql-session.ts +275 -9
- package/src/elements/store.ts +1 -0
- package/src/kernel/app.ts +207 -43
- package/src/kernel/auto-cache.test.ts +228 -0
- package/src/kernel/auto-registry.test.ts +5 -1
- package/src/kernel/boot-bind/channel.ts +63 -2
- package/src/kernel/boot-bind/honor-config.test.ts +40 -10
- package/src/kernel/boot-bind/signal.ts +72 -25
- package/src/kernel/boot-bind/store.ts +16 -1
- package/src/kernel/boot.test.ts +2 -2
- package/src/kernel/boot.ts +54 -10
- package/src/kernel/boundary-contract.ts +7 -0
- package/src/kernel/builtin-errors.ts +2 -0
- package/src/kernel/call.test.ts +1 -0
- package/src/kernel/errors-compiler.ts +11 -0
- package/src/kernel/errors-text.ts +16 -0
- package/src/kernel/errors.ts +8 -0
- package/src/kernel/flow.ts +33 -3
- package/src/kernel/fx-call-types.test.ts +32 -0
- package/src/kernel/fx-decide.ts +11 -7
- package/src/kernel/fx-sql-handle.ts +52 -7
- package/src/kernel/fx.test.ts +1 -3
- package/src/kernel/fx.ts +27 -16
- package/src/kernel/horizontal-child.ts +54 -1
- package/src/kernel/horizontal.integration.test.ts +131 -7
- package/src/kernel/idempotency.test.ts +4 -4
- package/src/kernel/journal-boot.test.ts +83 -6
- package/src/kernel/journal.test.ts +158 -0
- package/src/kernel/journal.ts +515 -54
- package/src/kernel/pipeline-tenant.ts +5 -1
- package/src/kernel/pipeline.ts +5 -0
- package/src/kernel/signal-tx.ts +28 -0
- package/src/kernel/store-transaction.test.ts +95 -0
- package/src/mcp/authorization.ts +5 -11
- package/src/mcp/confirmation.ts +203 -38
- package/src/mcp/docs-server.ts +26 -4
- package/src/mcp/index.ts +4 -0
- package/src/mcp/mcp.test.ts +404 -33
- package/src/mcp/protocol.ts +8 -2
- package/src/mcp/server.ts +31 -5
- package/src/mcp/session.ts +7 -0
- package/src/mcp/tools.ts +68 -19
- package/src/mcp/versions.ts +60 -0
- package/src/plugins/index.ts +1 -0
- package/src/plugins/mena.ts +56 -0
- package/src/runtime/bun.ts +5 -0
- 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("
|
|
83
|
-
|
|
84
|
-
expect(autoCacheEligible({
|
|
85
|
-
expect(autoCacheEligible({
|
|
86
|
-
expect(autoCacheEligible({
|
|
87
|
-
expect(autoCacheEligible({
|
|
88
|
-
expect(autoCacheEligible({
|
|
89
|
-
|
|
90
|
-
);
|
|
91
|
-
expect(autoCacheEligible({
|
|
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({
|
|
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
|
|
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
|
+
});
|