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
|
@@ -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. */
|
|
@@ -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
|
|
330
|
+
* Store and non-store effects recorded on one invocation's ledger.
|
|
179
331
|
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
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:
|
|
189
|
-
const writes:
|
|
340
|
+
const reads: string[] = [];
|
|
341
|
+
const writes: string[] = [];
|
|
190
342
|
const asks: string[] = [];
|
|
191
|
-
const
|
|
192
|
-
const
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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}:
|
|
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
|
|
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
|
-
|
|
589
|
+
caller?: Tier1Caller | string | null,
|
|
296
590
|
): readonly string[] {
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
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
|
-
|
|
620
|
+
caller?: Tier1Caller | string | null,
|
|
315
621
|
): Readonly<Record<string, readonly string[]>> {
|
|
316
|
-
const dims = tier1FlowDims(flowName, input,
|
|
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
|
|
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
|
|
146
|
-
|
|
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
|
-
|
|
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();
|