okengine 0.23.1 → 0.23.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/site/content/docs/ai/mcp.mdx +3 -3
- package/site/content/docs/elements/signal/broadcast.mdx +4 -2
- package/site/content/docs/reference/configuration.mdx +16 -0
- package/site/content/docs/reference/errors.mdx +31 -19
- 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/http-parse.test.ts +101 -0
- package/src/compiler/http-parse.ts +188 -9
- package/src/compiler/interpret.ts +25 -2
- package/src/console/ui-next/dist/assets/{access-page-C_N4qvai.js → access-page-zBMhZnFm.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-Cq1TxniW.js → agent-disclosure-CBj9f7Ju.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-BaI4uxZz.js → cache-glyph-D3MY49h3.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-DNyK7IIY.js → call-pii-button-L9JxdBhp.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-CRhBJMZR.js → collapsible-BvFaX-Zt.js} +1 -1
- package/src/console/ui-next/dist/assets/{decisions-page-D9twj8Xy.js → decisions-page-DgB76m-X.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-CsHWlVPU.js → duration-tone-l1DSJ2Vz.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-ZaTJAhR9.js → flows-page-Bb9t1lu6.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-DQ-EMAfz.js → highlighted-json-DMvsi3Ti.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-Cn_Fl79s.js → http-method-BwhJBTcQ.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-CmRFeWdT.js → index-RhV2jT_7.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-Cn6R_dXz.js → observability-page-BRfUILBq.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-Bx6EZhSe.js → replica-lag-BsceAzIG.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-DmdiUrfh.js → request-meta-C6lTyCXp.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-8P6vGXnn.js → store-page-YmE806bJ.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-BfGvpFSv.js → trace-detail-sheet-Di4irS49.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-CWdUHbc0.js → tree-expand-toggle-DYeL6Rye.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-CZOFZzay.js → units-page-07agasRA.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-ki_ishmX.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 +84 -1
- package/src/drivers/journal-postgres.ts +282 -32
- package/src/drivers/postgres.test.ts +60 -0
- package/src/drivers/postgres.ts +79 -5
- package/src/drivers/signal-compete.test.ts +6 -2
- package/src/drivers/signal-postgres.test.ts +170 -0
- package/src/drivers/signal-postgres.ts +449 -196
- package/src/drivers/signal-redis.test.ts +224 -0
- package/src/drivers/signal-redis.ts +835 -150
- package/src/elements/ai/approval.test.ts +15 -3
- package/src/elements/ai/approval.ts +15 -9
- package/src/elements/channel/runtime.ts +31 -4
- package/src/elements/channel/sql-ledger.test.ts +404 -0
- package/src/elements/channel/sql-ledger.ts +177 -33
- package/src/elements/clock/chaos-child.ts +12 -2
- package/src/elements/clock/durable.ts +11 -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 +59 -0
- package/src/elements/store/cache.ts +157 -12
- 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.ts +183 -3
- package/src/kernel/app.ts +176 -28
- package/src/kernel/auto-registry.test.ts +5 -1
- package/src/kernel/boot-bind/signal.ts +13 -2
- package/src/kernel/boot-bind/store.ts +16 -1
- package/src/kernel/boot.test.ts +2 -2
- package/src/kernel/boot.ts +41 -11
- 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/fx-sql-handle.ts +7 -1
- package/src/kernel/horizontal-child.ts +54 -1
- package/src/kernel/horizontal.integration.test.ts +131 -7
- package/src/kernel/idempotency.test.ts +5 -6
- package/src/kernel/journal-boot.test.ts +83 -6
- package/src/kernel/journal.test.ts +44 -0
- package/src/kernel/journal.ts +171 -26
- package/src/mcp/authorization.ts +5 -11
- package/src/mcp/confirmation.ts +203 -38
- package/src/mcp/docs-server.ts +7 -2
- package/src/mcp/index.ts +4 -0
- package/src/mcp/mcp.test.ts +321 -33
- package/src/mcp/server.ts +8 -3
- package/src/mcp/session.ts +7 -0
- package/src/mcp/tools.ts +68 -19
- 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 < ?`;
|
|
@@ -258,3 +258,62 @@ describe("autoCacheEligible", () => {
|
|
|
258
258
|
expect(tier1Lookup((key) => cache.get<number>(key), ["a", "b"])).toBe(1);
|
|
259
259
|
});
|
|
260
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
|
+
});
|
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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. */
|
|
@@ -400,6 +539,10 @@ export interface Tier1Caller {
|
|
|
400
539
|
* Membership role names. Cache identity only — gates do not read this.
|
|
401
540
|
*/
|
|
402
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;
|
|
403
546
|
}
|
|
404
547
|
|
|
405
548
|
/**
|
|
@@ -457,6 +600,8 @@ export function tier1FlowDims(
|
|
|
457
600
|
`l:${identity.locale ?? ""}`,
|
|
458
601
|
`s:${scopes.join(",")}`,
|
|
459
602
|
`r:${roles.join(",")}`,
|
|
603
|
+
`v:${identity.verified === true ? "1" : "0"}`,
|
|
604
|
+
`o:${identity.operatorId ?? ""}`,
|
|
460
605
|
];
|
|
461
606
|
}
|
|
462
607
|
|
|
@@ -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
|
-
|
|
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
|
|
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();
|