@ultimat3/cache 1.0.0

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.
@@ -0,0 +1,119 @@
1
+ // Single responsibility: Fastly's surrogate-key purge. One `POST /service/<id>/purge` per batch
2
+ // of keys, one `POST /service/<id>/purge_all` for the whole service — no SDK, `fetch` is the whole
3
+ // client. The keys are Ultimate's wire tags unchanged (`post`, `post:1`), which is the property
4
+ // that keeps an edge purge and an app-level invalidation from ever meaning different things.
5
+
6
+ import type { PurgeDriver } from './cdn';
7
+ import { CachePurgeFailedError } from './errors';
8
+ import type { PurgeFetch } from './purge-http';
9
+ import {
10
+ assertPurgeableKeys,
11
+ chunked,
12
+ DEFAULT_PURGE_TIMEOUT_MS,
13
+ defaultPurgeFetch,
14
+ detailFrom,
15
+ isRecord,
16
+ isRetryableStatus,
17
+ type PurgeBody,
18
+ purgeBody,
19
+ purgePost,
20
+ requireCredential,
21
+ } from './purge-http';
22
+
23
+ export const FASTLY_API_URL = 'https://api.fastly.com';
24
+
25
+ /** Fastly accepts 256 surrogate keys in one batch purge; more is a second request, not a refusal. */
26
+ export const FASTLY_MAX_KEYS_PER_REQUEST = 256;
27
+
28
+ export interface FastlyPurgeOptions {
29
+ /** Read from `FASTLY_API_TOKEN`. A literal token in app.config.ts is a token in git. */
30
+ readonly apiToken: string;
31
+ /** Read from `FASTLY_SERVICE_ID` — which service this deployment fronts. */
32
+ readonly serviceId: string;
33
+ /** Override for a proxy or a test double. Defaults to `FASTLY_API_URL`. */
34
+ readonly baseUrl?: string | undefined;
35
+ readonly timeoutMs?: number | undefined;
36
+ /** Injected in tests; production uses the global. */
37
+ readonly fetch?: PurgeFetch | undefined;
38
+ }
39
+
40
+ // Every branch names the env key to edit or a command to run. "raise the rate limit, or bust fewer
41
+ // tags" was the one that named neither: Fastly answers every API call with `Fastly-RateLimit-*`,
42
+ // so the remaining budget and its reset are readable — which is the half an agent can act on.
43
+ const fixFor = (status: number): string => {
44
+ if (status === 401 || status === 403) {
45
+ return 'set FASTLY_API_TOKEN in .env.production to a token with the purge scope from https://manage.fastly.com/account/personal/tokens';
46
+ }
47
+ if (status === 404) {
48
+ return 'set FASTLY_SERVICE_ID in .env.production to the id at https://manage.fastly.com/configure/services';
49
+ }
50
+ if (status === 429) {
51
+ return 'curl -sS -D - -o /dev/null -H "Fastly-Key: $FASTLY_API_TOKEN" https://api.fastly.com/service/$FASTLY_SERVICE_ID | grep -i fastly-ratelimit';
52
+ }
53
+ return 'curl -sS -H "Fastly-Key: $FASTLY_API_TOKEN" https://api.fastly.com/service/$FASTLY_SERVICE_ID';
54
+ };
55
+
56
+ /**
57
+ * Fastly answers a batch purge with `{ "<key>": "<purge id>" }`, and a single-key purge with
58
+ * `{ "status": "ok", "id": … }`. Only the first shape names keys, so anything else is read as
59
+ * "the whole batch was accepted" — which a 2xx already means.
60
+ */
61
+ function acceptedFrom(body: PurgeBody, batch: readonly string[]): string[] {
62
+ const payload = body.json;
63
+ if (!isRecord(payload)) return [...batch];
64
+ const named = batch.filter((key) => key in payload);
65
+ return named.length > 0 ? named : [...batch];
66
+ }
67
+
68
+ export function fastlyPurgeDriver(options: FastlyPurgeOptions): PurgeDriver {
69
+ const apiToken = requireCredential(options.apiToken, 'FASTLY_API_TOKEN', 'fastly');
70
+ const serviceId = requireCredential(options.serviceId, 'FASTLY_SERVICE_ID', 'fastly');
71
+ const baseUrl = options.baseUrl ?? FASTLY_API_URL;
72
+ const timeoutMs = options.timeoutMs ?? DEFAULT_PURGE_TIMEOUT_MS;
73
+ const doFetch = options.fetch ?? defaultPurgeFetch;
74
+ const headers = { 'Fastly-Key': apiToken };
75
+
76
+ const post = (path: string, body: unknown): Promise<Response> =>
77
+ purgePost({
78
+ driver: 'fastly',
79
+ url: `${baseUrl}/service/${serviceId}${path}`,
80
+ headers,
81
+ body,
82
+ fetch: doFetch,
83
+ timeoutMs,
84
+ });
85
+
86
+ /** The body is read here whether or not the call failed, because a `Response` gives it up once. */
87
+ const settle = async (response: Response): Promise<PurgeBody> => {
88
+ const body = await purgeBody(response);
89
+ if (response.ok) return body;
90
+ throw new CachePurgeFailedError({
91
+ driver: 'fastly',
92
+ detail: detailFrom(body),
93
+ status: response.status,
94
+ retryable: isRetryableStatus(response.status),
95
+ fix: fixFor(response.status),
96
+ });
97
+ };
98
+
99
+ return {
100
+ name: 'fastly',
101
+
102
+ async purge(keys: readonly string[]): Promise<readonly string[]> {
103
+ if (keys.length === 0) return [];
104
+ assertPurgeableKeys('fastly', keys);
105
+ const accepted: string[] = [];
106
+ // Sequential on purpose: a bust of thousands of keys must not open thousands of sockets
107
+ // against an API that rate-limits, and nothing downstream reads a purge before it lands.
108
+ for (const batch of chunked('fastly', keys, FASTLY_MAX_KEYS_PER_REQUEST)) {
109
+ const body = await settle(await post('/purge', { surrogate_keys: batch }));
110
+ accepted.push(...acceptedFrom(body, batch));
111
+ }
112
+ return accepted;
113
+ },
114
+
115
+ async purgeAll(): Promise<void> {
116
+ await settle(await post('/purge_all', {}));
117
+ },
118
+ };
119
+ }
@@ -0,0 +1,163 @@
1
+ // Single responsibility: the HTTP half both remote purge drivers share — one POST with a
2
+ // deadline, the status → retryable table, and the batching a provider's per-request key cap
3
+ // forces. Kept apart from the drivers because "which failure can succeed unchanged" is one
4
+ // judgement, and two copies of it would drift into two answers for the same 429.
5
+
6
+ import { CacheDriverUnavailableError, CachePurgeFailedError } from './errors';
7
+
8
+ /** Just the call. `typeof fetch` also carries `preconnect`, which no test double should have to. */
9
+ export type PurgeFetch = (input: string, init: RequestInit) => Promise<Response>;
10
+
11
+ /** A purge is behind the write, not in front of it: a slow CDN must not hold the fan-out open. */
12
+ export const DEFAULT_PURGE_TIMEOUT_MS = 10_000;
13
+
14
+ const MAX_DETAIL_LENGTH = 200;
15
+
16
+ // A 4xx here means the same request, unchanged, might land: a throttle or a momentary conflict.
17
+ // Every other 4xx is a credential or a plan, which no retry fixes.
18
+ const RETRYABLE_STATUSES = new Set([408, 409, 425, 429]);
19
+
20
+ export const isRetryableStatus = (status: number): boolean =>
21
+ status >= 500 || RETRYABLE_STATUSES.has(status);
22
+
23
+ /**
24
+ * A bare reference to `globalThis.fetch` risks "Illegal invocation" on some hosts; closing over
25
+ * the call keeps it detached from any receiver, in production and in tests alike.
26
+ */
27
+ export const defaultPurgeFetch: PurgeFetch = (input, init) => globalThis.fetch(input, init);
28
+
29
+ /**
30
+ * Providers cap keys per request. A bust of 300 tags is still one purge — several requests.
31
+ *
32
+ * The size is refused before the loop, not trusted: a `0` or a negative never advances `index`, so
33
+ * the fan-out hangs holding the write's invalidation open, and a `NaN` ends the loop after one pass
34
+ * that slices to nothing — an empty key list posted to a CDN that answers 200 and clears nothing.
35
+ * `X_CACHE_DRIVER_UNAVAILABLE` rather than `X_CACHE_PURGE_FAILED`: the only sizes this ever sees
36
+ * are the drivers' own caps, so a bad one is this package miswired, and no CDN refused anything.
37
+ */
38
+ export function chunked<T>(
39
+ driver: string,
40
+ values: readonly T[],
41
+ size: number,
42
+ ): readonly (readonly T[])[] {
43
+ if (!Number.isSafeInteger(size) || size < 1) {
44
+ throw new CacheDriverUnavailableError({
45
+ driver,
46
+ cause: `batch size ${String(size)} is not a positive integer, so ${values.length} keys cannot be split into requests`,
47
+ fix: 'pass a positive integer batch size to chunked(), as FASTLY_MAX_KEYS_PER_REQUEST does',
48
+ });
49
+ }
50
+ const batches: T[][] = [];
51
+ for (let index = 0; index < values.length; index += size) {
52
+ batches.push(values.slice(index, index + size));
53
+ }
54
+ return batches;
55
+ }
56
+
57
+ /**
58
+ * A credential the driver cannot run without, refused at construction — where the env key is
59
+ * still nameable — rather than on the first purge nobody watches. "no CDN token" is what
60
+ * `X_CACHE_DRIVER_UNAVAILABLE` already means, so this is that code and not a second one.
61
+ */
62
+ export function requireCredential(value: string, envKey: string, driver: string): string {
63
+ if (value.trim() !== '') return value.trim();
64
+ throw new CacheDriverUnavailableError({
65
+ driver,
66
+ cause: `${envKey} is unset, so the ${driver} purge driver has no credential`,
67
+ fix: `set ${envKey} in .env.production, or use noopPurgeDriver() to purge nothing`,
68
+ });
69
+ }
70
+
71
+ // Every CDN splits a key list on whitespace or a comma, so a key carrying either purges two
72
+ // things that do not exist instead of the one that does — silently, since the request succeeds.
73
+ const UNSAFE_KEY = /[\s,]/;
74
+ const MAX_KEY_LENGTH = 1024;
75
+
76
+ const keyProblem = (key: string): string | undefined => {
77
+ if (key === '') return 'is empty';
78
+ if (UNSAFE_KEY.test(key))
79
+ return 'contains whitespace or a comma, which a CDN reads as a separator';
80
+ if (key.length > MAX_KEY_LENGTH)
81
+ return `is ${key.length} characters, over the 1024-byte key limit`;
82
+ return undefined;
83
+ };
84
+
85
+ /**
86
+ * Refused before the request, not after: a malformed key comes back as an accepted purge that
87
+ * cleared nothing, which is the one CDN failure no later read can catch.
88
+ */
89
+ export function assertPurgeableKeys(driver: string, keys: readonly string[]): void {
90
+ for (const key of keys) {
91
+ const problem = keyProblem(key);
92
+ if (problem === undefined) continue;
93
+ throw new CachePurgeFailedError({
94
+ driver,
95
+ detail: `surrogate key ${JSON.stringify(key)} ${problem}`,
96
+ retryable: false,
97
+ fix: 'rename the tag in its declareTags(...) call so the key carries no space or comma',
98
+ });
99
+ }
100
+ }
101
+
102
+ export interface PurgeBody {
103
+ readonly text: string;
104
+ /** `undefined` when the provider sent something that is not JSON — an html error page, or nothing. */
105
+ readonly json: unknown;
106
+ }
107
+
108
+ /**
109
+ * The body, read exactly once. A `Response` streams: `json()` followed by `text()` throws "Body
110
+ * already used", so the failure path would lose the very message it exists to report.
111
+ */
112
+ export async function purgeBody(response: Response): Promise<PurgeBody> {
113
+ const text = await response.text().catch(() => '');
114
+ try {
115
+ return { text, json: JSON.parse(text) as unknown };
116
+ } catch {
117
+ return { text, json: undefined };
118
+ }
119
+ }
120
+
121
+ /** Raw text, capped so a provider's error page cannot flood a log line. */
122
+ export function detailFrom(body: PurgeBody): string {
123
+ if (body.text === '') return 'the response body was empty';
124
+ return body.text.length > MAX_DETAIL_LENGTH
125
+ ? `${body.text.slice(0, MAX_DETAIL_LENGTH)}…`
126
+ : body.text;
127
+ }
128
+
129
+ export function isRecord(value: unknown): value is Record<string, unknown> {
130
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
131
+ }
132
+
133
+ export interface PurgePostInput {
134
+ readonly driver: string;
135
+ readonly url: string;
136
+ readonly headers: Readonly<Record<string, string>>;
137
+ readonly body: unknown;
138
+ readonly fetch: PurgeFetch;
139
+ readonly timeoutMs: number;
140
+ }
141
+
142
+ /**
143
+ * One POST, with the transport failure already translated. A request that never got a status —
144
+ * DNS, TLS, a reset, the deadline — is retryable by definition: nothing at the edge has seen it.
145
+ */
146
+ export async function purgePost(input: PurgePostInput): Promise<Response> {
147
+ try {
148
+ return await input.fetch(input.url, {
149
+ method: 'POST',
150
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...input.headers },
151
+ body: JSON.stringify(input.body),
152
+ signal: AbortSignal.timeout(input.timeoutMs),
153
+ });
154
+ } catch (error) {
155
+ const reason = error instanceof Error ? error.message : 'the request failed before a response';
156
+ throw new CachePurgeFailedError({
157
+ driver: input.driver,
158
+ detail: `${reason} — nothing left this host for ${input.url} (egress, DNS or TLS)`,
159
+ retryable: true,
160
+ fix: `curl -sS -m 5 -o /dev/null ${input.url}`,
161
+ });
162
+ }
163
+ }
package/src/redis.ts ADDED
@@ -0,0 +1,131 @@
1
+ // Tier 2: shared cache over `Bun.redis` (no client dependency — the runtime ships one).
2
+ // A tag -> keys SET is maintained alongside every write so `invalidateTags` is ONE round
3
+ // trip via a server-side script, not a KEYS scan. KEYS is O(n) and blocks the server; a
4
+ // framework that ships it as the invalidation path is shipping an outage.
5
+
6
+ import { logger } from '@ultimat3/core';
7
+ import { CacheDriverUnavailableError } from './errors';
8
+ import type { CacheTag } from './tags';
9
+ import { parseTag, serializeTag } from './tags';
10
+ import type { CacheEntry, CacheSetOptions, CacheTier, TierInvalidation } from './tiers';
11
+
12
+ /** The slice of Bun's Redis client this tier uses. Narrow on purpose: easy to fake in tests. */
13
+ export interface RedisLike {
14
+ get(key: string): Promise<string | null>;
15
+ set(key: string, value: string): Promise<unknown>;
16
+ send(command: string, args: string[]): Promise<unknown>;
17
+ }
18
+
19
+ export interface RedisTierOptions {
20
+ /** Key namespace, so two apps can share one Redis without colliding. Default `x`. */
21
+ readonly prefix?: string;
22
+ readonly defaultTtlMs?: number;
23
+ /** Injected in tests; production reads `Bun.redis`. */
24
+ readonly client?: RedisLike;
25
+ }
26
+
27
+ interface StoredEntry {
28
+ readonly v: unknown;
29
+ readonly t: readonly string[];
30
+ }
31
+
32
+ /**
33
+ * Drop the value keys, then drop the tag sets themselves. `SMEMBERS` + `DEL` in one EVAL is
34
+ * atomic and single-trip; doing it client-side would race a concurrent write.
35
+ */
36
+ const INVALIDATE_SCRIPT = `
37
+ local removed = {}
38
+ for i, tagKey in ipairs(KEYS) do
39
+ local members = redis.call('SMEMBERS', tagKey)
40
+ for _, key in ipairs(members) do
41
+ redis.call('DEL', key)
42
+ table.insert(removed, key)
43
+ end
44
+ redis.call('DEL', tagKey)
45
+ end
46
+ return removed
47
+ `.trim();
48
+
49
+ function resolveClient(injected: RedisLike | undefined): RedisLike {
50
+ if (injected !== undefined) return injected;
51
+ const candidate = (Bun as unknown as { redis?: RedisLike }).redis;
52
+ if (candidate === undefined || typeof candidate.send !== 'function') {
53
+ throw new CacheDriverUnavailableError({
54
+ driver: 'redis',
55
+ cause: 'Bun.redis is not available (needs bun >= 1.3 and REDIS_URL)',
56
+ fix: 'set REDIS_URL in .env, or drop the redis tier from cache.tiers in app.config.ts',
57
+ });
58
+ }
59
+ return candidate;
60
+ }
61
+
62
+ const toStrings = (value: unknown): string[] =>
63
+ Array.isArray(value) ? value.map((item) => String(item)) : [];
64
+
65
+ export function createRedisTier(options: RedisTierOptions = {}): CacheTier {
66
+ const prefix = options.prefix ?? 'x';
67
+ const defaultTtlMs = options.defaultTtlMs ?? 300_000;
68
+ let client: RedisLike | undefined;
69
+
70
+ const conn = (): RedisLike => {
71
+ client ??= resolveClient(options.client);
72
+ return client;
73
+ };
74
+
75
+ const valueKey = (key: string): string => `${prefix}:c:${key}`;
76
+ const tagKey = (wire: string): string => `${prefix}:t:${wire}`;
77
+ // A row write must also appear in the collection's tag set, or list caches survive it.
78
+ const tagKeysFor = (owned: CacheTag): string[] =>
79
+ owned.id === undefined
80
+ ? [tagKey(owned.entity)]
81
+ : [tagKey(serializeTag(owned)), tagKey(owned.entity)];
82
+
83
+ return {
84
+ name: 'redis',
85
+
86
+ async get<T>(key: string): Promise<CacheEntry<T> | undefined> {
87
+ const raw = await conn().get(valueKey(key));
88
+ if (raw === null) return undefined;
89
+ try {
90
+ const parsed = JSON.parse(raw) as StoredEntry;
91
+ return { value: parsed.v as T, tags: parsed.t.map(parseTag) };
92
+ } catch {
93
+ // A poisoned value is a miss, never a 500. Redis TTL will reap it.
94
+ logger.warn('cache.redis.corrupt-entry', { key });
95
+ return undefined;
96
+ }
97
+ },
98
+
99
+ async set<T>(key: string, value: T, setOptions?: CacheSetOptions): Promise<void> {
100
+ const tags = setOptions?.tags ?? [];
101
+ const ttlMs = setOptions?.ttlMs ?? defaultTtlMs;
102
+ const payload: StoredEntry = { v: value, t: tags.map(serializeTag) };
103
+ const stored = valueKey(key);
104
+ const ttlSeconds = Math.max(1, Math.ceil(ttlMs / 1000));
105
+ await conn().send('SET', [stored, JSON.stringify(payload), 'EX', String(ttlSeconds)]);
106
+ for (const owned of tags) {
107
+ for (const bucket of tagKeysFor(owned)) {
108
+ await conn().send('SADD', [bucket, stored]);
109
+ }
110
+ }
111
+ },
112
+
113
+ async del(key: string): Promise<void> {
114
+ await conn().send('DEL', [valueKey(key)]);
115
+ },
116
+
117
+ async invalidateTags(tags: readonly CacheTag[]): Promise<TierInvalidation> {
118
+ const buckets = [...new Set(tags.flatMap(tagKeysFor))];
119
+ if (buckets.length === 0) return { tier: 'redis', keys: [] };
120
+ const result = await conn().send('EVAL', [
121
+ INVALIDATE_SCRIPT,
122
+ String(buckets.length),
123
+ ...buckets,
124
+ ]);
125
+ const stripped = toStrings(result).map((key) => key.slice(`${prefix}:c:`.length));
126
+ return { tier: 'redis', keys: stripped };
127
+ },
128
+ };
129
+ }
130
+
131
+ export const REDIS_INVALIDATE_SCRIPT = INVALIDATE_SCRIPT;
@@ -0,0 +1,146 @@
1
+ // The semantic cache for LLM calls: a near-duplicate prompt should not pay for a second
2
+ // completion. Keyed by embedding, matched by cosine similarity above a threshold — exact
3
+ // string keys miss on "list my orders" vs "show me my orders", which is most of the traffic.
4
+ // The in-memory default is correct but O(n); pgvector is the production backing (an ivfflat
5
+ // index over `x_semantic_cache.embedding`), which is why the interface is a driver.
6
+
7
+ import type { Clock } from '@ultimat3/core';
8
+ import { systemClock } from '@ultimat3/core';
9
+ import type { CacheTag } from './tags';
10
+ import { tagsIntersect } from './tags';
11
+ import { nowMs } from './tiers';
12
+
13
+ export type Embedding = readonly number[];
14
+
15
+ export interface SemanticHit<T> {
16
+ readonly value: T;
17
+ readonly similarity: number;
18
+ readonly key: string;
19
+ }
20
+
21
+ export interface SemanticRememberOptions {
22
+ readonly ttlMs?: number;
23
+ readonly tags?: readonly CacheTag[];
24
+ }
25
+
26
+ export interface SemanticCache {
27
+ readonly name: string;
28
+ /** Nearest neighbour above `threshold`, or `undefined`. */
29
+ lookup<T>(embedding: Embedding, threshold?: number): Promise<SemanticHit<T> | undefined>;
30
+ remember<T>(
31
+ key: string,
32
+ embedding: Embedding,
33
+ value: T,
34
+ options?: SemanticRememberOptions,
35
+ ): Promise<void>;
36
+ invalidateTags(tags: readonly CacheTag[]): Promise<readonly string[]>;
37
+ size(): Promise<number>;
38
+ }
39
+
40
+ export interface SemanticCacheOptions {
41
+ /**
42
+ * Default 0.92. Below ~0.9 unrelated prompts start colliding and the cache answers the
43
+ * wrong question — a worse failure than a cache miss, so the default is deliberately tight.
44
+ */
45
+ readonly threshold?: number;
46
+ readonly maxEntries?: number;
47
+ readonly defaultTtlMs?: number;
48
+ readonly clock?: Clock;
49
+ }
50
+
51
+ export function cosineSimilarity(a: Embedding, b: Embedding): number {
52
+ if (a.length !== b.length || a.length === 0) return 0;
53
+ let dot = 0;
54
+ let normA = 0;
55
+ let normB = 0;
56
+ for (let i = 0; i < a.length; i += 1) {
57
+ const x = a[i] ?? 0;
58
+ const y = b[i] ?? 0;
59
+ dot += x * y;
60
+ normA += x * x;
61
+ normB += y * y;
62
+ }
63
+ if (normA === 0 || normB === 0) return 0;
64
+ return dot / (Math.sqrt(normA) * Math.sqrt(normB));
65
+ }
66
+
67
+ interface SemanticRecord {
68
+ readonly key: string;
69
+ readonly embedding: Embedding;
70
+ readonly value: unknown;
71
+ readonly expiresAt: number;
72
+ readonly tags: readonly CacheTag[];
73
+ }
74
+
75
+ export function createMemorySemanticCache(options: SemanticCacheOptions = {}): SemanticCache {
76
+ const threshold = options.threshold ?? 0.92;
77
+ const maxEntries = options.maxEntries ?? 1000;
78
+ const defaultTtlMs = options.defaultTtlMs ?? 3_600_000;
79
+ const clock = options.clock ?? systemClock;
80
+ const records = new Map<string, SemanticRecord>();
81
+
82
+ const live = (): SemanticRecord[] => {
83
+ const at = nowMs(clock);
84
+ const out: SemanticRecord[] = [];
85
+ for (const [key, record] of records) {
86
+ if (record.expiresAt <= at) records.delete(key);
87
+ else out.push(record);
88
+ }
89
+ return out;
90
+ };
91
+
92
+ return {
93
+ name: 'memory',
94
+
95
+ lookup<T>(embedding: Embedding, override?: number): Promise<SemanticHit<T> | undefined> {
96
+ const floor = override ?? threshold;
97
+ let best: SemanticHit<T> | undefined;
98
+ for (const record of live()) {
99
+ const similarity = cosineSimilarity(embedding, record.embedding);
100
+ if (similarity < floor) continue;
101
+ if (best === undefined || similarity > best.similarity) {
102
+ best = { value: record.value as T, similarity, key: record.key };
103
+ }
104
+ }
105
+ return Promise.resolve(best);
106
+ },
107
+
108
+ remember<T>(
109
+ key: string,
110
+ embedding: Embedding,
111
+ value: T,
112
+ rememberOptions?: SemanticRememberOptions,
113
+ ): Promise<void> {
114
+ records.delete(key);
115
+ records.set(key, {
116
+ key,
117
+ embedding,
118
+ value,
119
+ expiresAt: nowMs(clock) + (rememberOptions?.ttlMs ?? defaultTtlMs),
120
+ tags: rememberOptions?.tags ?? [],
121
+ });
122
+ // Insertion-ordered Map: the oldest key is the first one.
123
+ while (records.size > maxEntries) {
124
+ const oldest = records.keys().next();
125
+ if (oldest.done === true) break;
126
+ records.delete(oldest.value);
127
+ }
128
+ return Promise.resolve();
129
+ },
130
+
131
+ invalidateTags(tags: readonly CacheTag[]): Promise<readonly string[]> {
132
+ const removed: string[] = [];
133
+ for (const [key, record] of records) {
134
+ if (tagsIntersect(tags, record.tags)) {
135
+ records.delete(key);
136
+ removed.push(key);
137
+ }
138
+ }
139
+ return Promise.resolve(removed);
140
+ },
141
+
142
+ size(): Promise<number> {
143
+ return Promise.resolve(live().length);
144
+ },
145
+ };
146
+ }
package/src/tags.ts ADDED
@@ -0,0 +1,110 @@
1
+ // Tags are the ONLY invalidation currency in Ultimate: actions declare `invalidates`,
2
+ // routes declare `revalidate.tags`, and every tier speaks the same strings. No tier ever
3
+ // invents its own key convention, so there is nothing for an agent to keep in sync.
4
+
5
+ import { CacheTagUnknownError } from './errors';
6
+
7
+ /** An entity-scoped tag. `{ entity: 'post' }` covers the collection, `+ id` a single row. */
8
+ export interface CacheTag<E extends string = string> {
9
+ readonly entity: E;
10
+ readonly id?: string;
11
+ }
12
+
13
+ /**
14
+ * Apps augment this interface (generated by `x manifest` from their entities) to get
15
+ * `tag.post` typed and `tag.pots` as a build error:
16
+ *
17
+ * declare module '@ultimat3/cache' {
18
+ * interface CacheTagRegistry { post: true; feed: true }
19
+ * }
20
+ */
21
+ // biome-ignore lint/suspicious/noEmptyInterface: augmentation target, filled by generated code
22
+ export interface CacheTagRegistry {}
23
+
24
+ type TagAccessors = { readonly [K in keyof CacheTagRegistry & string]: CacheTag<K> };
25
+
26
+ export type TagFactory = TagAccessors & (<E extends string>(entity: E, id?: string) => CacheTag<E>);
27
+
28
+ const makeTag = <E extends string>(entity: E, id?: string): CacheTag<E> =>
29
+ id === undefined ? { entity } : { entity, id };
30
+
31
+ /**
32
+ * `tag('post', id)` for a row, `tag.post` for the collection. The property form is a Proxy
33
+ * so generated registry augmentations need no runtime codegen.
34
+ */
35
+ export const tag: TagFactory = new Proxy(makeTag, {
36
+ get(target, prop, receiver): unknown {
37
+ if (typeof prop === 'string') return makeTag(prop);
38
+ return Reflect.get(target, prop, receiver);
39
+ },
40
+ }) as unknown as TagFactory;
41
+
42
+ /** Wire form: `post` for a collection, `post:<id>` for a row. Stable across tiers. */
43
+ export function serializeTag(value: CacheTag): string {
44
+ return value.id === undefined ? value.entity : `${value.entity}:${value.id}`;
45
+ }
46
+
47
+ export function parseTag(wire: string): CacheTag {
48
+ const split = wire.indexOf(':');
49
+ if (split === -1) return { entity: wire };
50
+ return { entity: wire.slice(0, split), id: wire.slice(split + 1) };
51
+ }
52
+
53
+ export function serializeTags(tags: readonly CacheTag[]): string[] {
54
+ return tags.map(serializeTag);
55
+ }
56
+
57
+ /**
58
+ * Every tag a row participates in: its collection and its own identity. A row write
59
+ * therefore busts list caches and detail caches with one call.
60
+ */
61
+ export function tagsFor(
62
+ entity: { readonly name: string },
63
+ row: { readonly id: string },
64
+ ): readonly CacheTag[] {
65
+ return [makeTag(entity.name), makeTag(entity.name, row.id)];
66
+ }
67
+
68
+ /**
69
+ * Invalidation is deliberately asymmetric-tolerant: busting a collection (`post`) must kill
70
+ * cached rows, and busting a row (`post:1`) must kill the lists that contained it. Anything
71
+ * narrower leaves an agent hunting a stale list it never thought to tag.
72
+ */
73
+ export function tagMatches(requested: CacheTag, owned: CacheTag): boolean {
74
+ if (requested.entity !== owned.entity) return false;
75
+ if (requested.id === undefined || owned.id === undefined) return true;
76
+ return requested.id === owned.id;
77
+ }
78
+
79
+ export function tagsIntersect(requested: readonly CacheTag[], owned: readonly CacheTag[]): boolean {
80
+ return requested.some((r) => owned.some((o) => tagMatches(r, o)));
81
+ }
82
+
83
+ const declared = new Set<string>();
84
+
85
+ /** Called once at boot with the entity names from `x.manifest.json`. */
86
+ export function declareTags(entities: readonly string[]): void {
87
+ for (const name of entities) declared.add(name);
88
+ }
89
+
90
+ export function knownTags(): readonly string[] {
91
+ return [...declared].sort();
92
+ }
93
+
94
+ export function resetDeclaredTags(): void {
95
+ declared.clear();
96
+ }
97
+
98
+ /**
99
+ * Validation is skipped while nothing is declared — `x dev` boots before the manifest
100
+ * exists, and a hard failure there would be worse than a late one. Once any entity has
101
+ * declared itself, an undeclared tag is a typo and fails loudly.
102
+ */
103
+ export function assertKnownTags(tags: readonly CacheTag[]): void {
104
+ if (declared.size === 0) return;
105
+ for (const value of tags) {
106
+ if (!declared.has(value.entity)) {
107
+ throw new CacheTagUnknownError({ tag: serializeTag(value), known: knownTags() });
108
+ }
109
+ }
110
+ }