@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 developerz.ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,153 @@
1
+ # @ultimat3/cache πŸ—‚οΈ
2
+
3
+ Four tiers. **One invalidation graph, not three.**
4
+
5
+ ```ts
6
+ cache: { invalidates: [tag.post, tag.feed] }
7
+ ```
8
+
9
+ That declaration on an `action` reaches the request memo, the in-process LRU, Redis, every
10
+ ISR route that rendered a post, and the CDN surrogate keys β€” in one hop, through one
11
+ function. Manual cache invalidation is the single worst thing you can ask an agent to do,
12
+ so the framework removes the decision: tag your writes, never your reads.
13
+
14
+ ## Why one graph
15
+
16
+ Every framework that ships "cache tags", "revalidate paths", and "CDN purge" as three
17
+ separate mechanisms produces the same bug β€” two of the three fire, the third serves last
18
+ week. `graph.ts` is a module singleton with **no exported constructor**. Cache keys, ISR
19
+ routes, CDN paths and live queries all register as `CacheDependent`s against tags in that
20
+ one graph, and `invalidateTags()` is the only reader. There is nowhere to put a second one.
21
+
22
+ ## Tiers
23
+
24
+ Reads walk down until a hit, then populate every tier they walked past. Writes populate all.
25
+
26
+ | Order | Tier | Backing | Invalidation | Omit when |
27
+ |---|---|---|---|---|
28
+ | 0 | `request-memo` | ALS context (`WeakMap`) | dies with the request | never |
29
+ | 1 | `lru` | in-process, byte-budgeted | tag index | never |
30
+ | 2 | `redis` | `Bun.redis` | tag→keys set, one `EVAL` | single node |
31
+ | 3 | `cdn` | headers + purge driver | surrogate keys | no CDN |
32
+
33
+ A tier is a `CacheTier` (`get`/`set`/`del`/`invalidateTags`). Swap or omit any of them
34
+ without touching a call site β€” order comes from `TIER_ORDER`, not registration order.
35
+
36
+ ```ts
37
+ import { createCacheStack, createLruTier, createMemoTier, registerTier } from '@ultimat3/cache';
38
+
39
+ const stack = createCacheStack([createMemoTier(), createLruTier({ maxBytes: 64 * 1024 * 1024 })]);
40
+ for (const tier of stack.tiers) registerTier(tier);
41
+
42
+ const feed = await stack.read('feed:org-1', () => db.posts.recent(), {
43
+ ttlMs: 30_000,
44
+ tags: [tag('post')],
45
+ });
46
+ ```
47
+
48
+ ## Tags
49
+
50
+ ```ts
51
+ tag.post // the collection β€” busts lists
52
+ tag('post', id) // one row β€” also busts the lists that contained it
53
+ tagsFor(Post, row) // both, for a repo write
54
+ ```
55
+
56
+ Wire form is `post` / `post:<id>`, identical in Redis keys, CDN surrogate keys and
57
+ `--json` reports. Invalidation is asymmetric-tolerant on purpose: busting a collection
58
+ kills its rows, busting a row kills the collections that held it.
59
+
60
+ `tag.post` is typed via a registry that `x manifest` generates, so `tag.pots` is a build
61
+ error:
62
+
63
+ ```ts
64
+ declare module '@ultimat3/cache' {
65
+ interface CacheTagRegistry { post: true; feed: true }
66
+ }
67
+ ```
68
+
69
+ ## Invalidating
70
+
71
+ ```ts
72
+ const report = await invalidateTags([tag('post', postId)]);
73
+ ```
74
+
75
+ One function. Returns the report the `/_x` cache panel and `x cache bust --json` render:
76
+
77
+ ```json
78
+ {
79
+ "tags": ["post:1"],
80
+ "tiers": [{ "tier": "lru", "keys": ["feed"] }, { "tier": "redis", "keys": ["feed"] }],
81
+ "isr": ["/blog", "/blog/hello"],
82
+ "cdn": ["post:1"],
83
+ "liveQueries": [],
84
+ "durationMs": 1.4,
85
+ "errors": []
86
+ }
87
+ ```
88
+
89
+ A dead tier lands in `errors` and never throws β€” a Redis outage must not fail the write
90
+ that triggered the bust. Entries there expire by TTL instead.
91
+
92
+ Every report is also kept: `recentInvalidations()` hands back the last 100, newest first, each
93
+ one naming the span that triggered it. That is the log the `/_x` cache panel renders β€” "did it
94
+ actually clear?" is answerable without a log dive because the one fan-out path retained the
95
+ answer, not because a second recorder was wired next to it.
96
+
97
+ ## CDN
98
+
99
+ ```ts
100
+ cacheHeaders({ sMaxAge: 300, staleWhileRevalidate: 86_400, tags: [tag('post', id)] });
101
+ // => { 'Cache-Control': 'public, max-age=0, s-maxage=300, stale-while-revalidate=86400',
102
+ // 'Surrogate-Key': 'post:1' }
103
+ ```
104
+
105
+ The surrogate keys **are** the tags, byte for byte, so an edge purge and an app-level
106
+ invalidation can never mean different things. Three `PurgeDriver`s ship:
107
+
108
+ | Driver | Purge | Purge all | Batch |
109
+ |---|---|---|---|
110
+ | `noopPurgeDriver()` | echoes the keys back | resolves | β€” |
111
+ | `fastlyPurgeDriver({ apiToken, serviceId })` | `POST /service/<id>/purge` with `surrogate_keys` | `POST /service/<id>/purge_all` | 256 keys |
112
+ | `cloudflarePurgeDriver({ apiToken, zoneId })` | `POST /zones/<id>/purge_cache` with `tags` | same call, `purge_everything` | 30 tags |
113
+
114
+ Which one a process installs comes from the environment, never from `app.config.ts` β€”
115
+ nothing loads that file's contents at runtime:
116
+
117
+ | Set | Selects |
118
+ |---|---|
119
+ | `FASTLY_API_TOKEN` + `FASTLY_SERVICE_ID` | Fastly |
120
+ | `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ZONE_ID` | Cloudflare |
121
+ | neither | nothing is purged, and `x dev` prints `cdn=none` |
122
+
123
+ Both pairs at once is `X_CONFIG_INVALID`: one process purges exactly one edge. Half a pair
124
+ is refused the same way β€” treating it as "no CDN" is how a deployment ships believing it
125
+ purges. Either refusal names the keys that are actually set, in `cause` and in
126
+ `meta.configured`, so the diagnostic can never point at a variable nobody set. A refused
127
+ purge is `X_CACHE_PURGE_FAILED` carrying `meta.retryable`, and it lands in `report.errors`
128
+ rather than failing the write that triggered it.
129
+
130
+ ## Semantic cache
131
+
132
+ For LLM calls, where "list my orders" and "show me my orders" must hit the same entry.
133
+ `createMemorySemanticCache()` does cosine similarity at a 0.92 threshold (tight on
134
+ purpose β€” a false hit answers the wrong question, which is worse than a miss) and is the
135
+ only backing this package ships β€” it is O(n) and in-process. The interface (`SemanticCache`)
136
+ is a driver seam for that reason; a Postgres/pgvector-backed implementation does not exist
137
+ yet here (`@ultimat3/ai`'s `PgVectorStore` is a separate store, for RAG retrieval, not this
138
+ cache).
139
+
140
+ ## Errors
141
+
142
+ | Code | Cause |
143
+ |---|---|
144
+ | `X_CACHE_DRIVER_UNAVAILABLE` | `Bun.redis` missing, a purge driver built without its token, or a batch size that is not a positive integer |
145
+ | `X_CACHE_PURGE_FAILED` | the CDN refused a purge, or a key it would split on whitespace |
146
+ | `X_CACHE_TAG_UNKNOWN` | a tag no entity declared β€” usually a typo |
147
+ | `X_CACHE_TOO_LARGE` | one entry exceeds a tier's whole byte budget |
148
+
149
+ ## Boundary
150
+
151
+ Tier 1. Imports `@ultimat3/core` and `@ultimat3/schema` only. Knows nothing about
152
+ entities, HTTP or jobs β€” `tagsFor()` takes structural `{ name }` / `{ id }` arguments so
153
+ `@ultimat3/entity` can depend on cache and never the reverse.
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@ultimat3/cache",
3
+ "version": "1.0.0",
4
+ "description": "Tagged caching: request memo, LRU, Redis, CDN β€” one invalidation graph",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/developerz-ai/ultimate.git",
10
+ "directory": "packages/cache"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public",
14
+ "provenance": true
15
+ },
16
+ "exports": {
17
+ ".": "./src/index.ts"
18
+ },
19
+ "files": [
20
+ "src",
21
+ "!src/**/*.test.ts",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "engines": {
26
+ "bun": ">=1.3.0"
27
+ },
28
+ "scripts": {
29
+ "typecheck": "tsc --noEmit -p tsconfig.json",
30
+ "test": "bun test"
31
+ },
32
+ "dependencies": {
33
+ "@ultimat3/core": "1.0.0"
34
+ }
35
+ }
package/src/cdn.ts ADDED
@@ -0,0 +1,106 @@
1
+ // Tier 3: the CDN. Ultimate does not read from the CDN (it sits in front of us), so this
2
+ // tier's job is the other two thirds of caching: emitting the headers that let the CDN hold
3
+ // the response, and purging by surrogate key when a tag changes. Surrogate keys ARE the
4
+ // tags β€” same strings, so a CDN purge cannot drift from an app-level invalidation.
5
+
6
+ import type { CacheTag } from './tags';
7
+ import { serializeTags } from './tags';
8
+ import type { CacheEntry, CacheSetOptions, CacheTier, TierInvalidation } from './tiers';
9
+
10
+ export interface CacheHeaderOptions {
11
+ /** Browser freshness, seconds. */
12
+ readonly maxAge?: number;
13
+ /** Shared (CDN) freshness, seconds. */
14
+ readonly sMaxAge?: number;
15
+ /** Serve stale for N seconds while revalidating behind the request. */
16
+ readonly staleWhileRevalidate?: number;
17
+ readonly staleIfError?: number;
18
+ /** Per-user responses: never shared, no surrogate keys. */
19
+ readonly visibility?: 'public' | 'private';
20
+ readonly immutable?: boolean;
21
+ readonly tags?: readonly CacheTag[];
22
+ }
23
+
24
+ /** `Cache-Control` + `Surrogate-Key`, ready to spread into a `Headers` init. */
25
+ export function cacheHeaders(options: CacheHeaderOptions = {}): Record<string, string> {
26
+ const visibility = options.visibility ?? 'public';
27
+ const parts: string[] = [visibility];
28
+
29
+ if (visibility === 'private') {
30
+ parts.push('no-store');
31
+ return { 'Cache-Control': parts.join(', ') };
32
+ }
33
+
34
+ parts.push(`max-age=${options.maxAge ?? 0}`);
35
+ if (options.sMaxAge !== undefined) parts.push(`s-maxage=${options.sMaxAge}`);
36
+ if (options.staleWhileRevalidate !== undefined) {
37
+ parts.push(`stale-while-revalidate=${options.staleWhileRevalidate}`);
38
+ }
39
+ if (options.staleIfError !== undefined) parts.push(`stale-if-error=${options.staleIfError}`);
40
+ if (options.immutable === true) parts.push('immutable');
41
+
42
+ const headers: Record<string, string> = { 'Cache-Control': parts.join(', ') };
43
+ const keys = serializeTags(options.tags ?? []);
44
+ if (keys.length > 0) headers['Surrogate-Key'] = keys.join(' ');
45
+ return headers;
46
+ }
47
+
48
+ export interface PurgeDriver {
49
+ readonly name: string;
50
+ /** Purge by surrogate key. Returns the keys the provider accepted. */
51
+ purge(keys: readonly string[]): Promise<readonly string[]>;
52
+ purgeAll(): Promise<void>;
53
+ }
54
+
55
+ /** Default: the CDN is optional infrastructure, so its absence must not fail a write. */
56
+ export function noopPurgeDriver(): PurgeDriver {
57
+ return {
58
+ name: 'noop',
59
+ purge(keys) {
60
+ return Promise.resolve(keys);
61
+ },
62
+ purgeAll() {
63
+ return Promise.resolve();
64
+ },
65
+ };
66
+ }
67
+
68
+ export interface CdnTierOptions {
69
+ readonly purge?: PurgeDriver;
70
+ /**
71
+ * Maps a cache key to the CDN path(s) it renders, for key-level purges. Those paths are
72
+ * purged **as surrogate keys** β€” `PurgeDriver.purge` has one currency and this is it β€” so a
73
+ * host using this must tag those responses with their own path.
74
+ */
75
+ readonly pathsForKey?: (key: string) => readonly string[];
76
+ }
77
+
78
+ export function createCdnTier(options: CdnTierOptions = {}): CacheTier {
79
+ const driver = options.purge ?? noopPurgeDriver();
80
+
81
+ return {
82
+ name: 'cdn',
83
+
84
+ get<T>(): Promise<CacheEntry<T> | undefined> {
85
+ // The CDN is upstream of the origin; a read here would be a round trip to ourselves.
86
+ return Promise.resolve(undefined);
87
+ },
88
+
89
+ set<T>(_key: string, _value: T, _options?: CacheSetOptions): Promise<void> {
90
+ // Population happens by responding with `cacheHeaders()`, never by pushing.
91
+ return Promise.resolve();
92
+ },
93
+
94
+ async del(key: string): Promise<void> {
95
+ const paths = options.pathsForKey?.(key) ?? [];
96
+ if (paths.length > 0) await driver.purge(paths);
97
+ },
98
+
99
+ async invalidateTags(tags: readonly CacheTag[]): Promise<TierInvalidation> {
100
+ const keys = serializeTags(tags);
101
+ if (keys.length === 0) return { tier: 'cdn', keys: [] };
102
+ const accepted = await driver.purge(keys);
103
+ return { tier: 'cdn', keys: accepted };
104
+ },
105
+ };
106
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,102 @@
1
+ // The X_* codes owned by @ultimat3/cache. Each one names the exact config change or
2
+ // command that resolves it, so an agent reading the failure can act without a doc lookup.
3
+ import { registerErrorCodes, UltimateError } from '@ultimat3/core';
4
+
5
+ /** Codes this package declares and owns. */
6
+ export const CACHE_OWNED_ERROR_CODES = [
7
+ 'X_CACHE_DRIVER_UNAVAILABLE',
8
+ 'X_CACHE_PURGE_FAILED',
9
+ 'X_CACHE_TAG_UNKNOWN',
10
+ 'X_CACHE_TOO_LARGE',
11
+ ] as const;
12
+
13
+ /** Every code cache can throw. It borrows none: every remote driver here is implemented. */
14
+ export const CACHE_ERROR_CODES = [...CACHE_OWNED_ERROR_CODES] as const;
15
+
16
+ export type CacheOwnedErrorCode = (typeof CACHE_OWNED_ERROR_CODES)[number];
17
+ export type CacheErrorCode = (typeof CACHE_ERROR_CODES)[number];
18
+
19
+ export const CACHE_ERROR_TITLES: Readonly<Record<CacheOwnedErrorCode, string>> = {
20
+ X_CACHE_DRIVER_UNAVAILABLE: "a tier's backing store is missing",
21
+ X_CACHE_PURGE_FAILED: 'the CDN refused a purge',
22
+ X_CACHE_TAG_UNKNOWN: 'a tag no entity declared',
23
+ X_CACHE_TOO_LARGE: "one entry exceeds the tier's byte budget",
24
+ };
25
+
26
+ // One unconditional call, so a second package claiming one of cache's codes throws
27
+ // X_ERROR_CODE_DUPLICATE instead of losing silently to whichever module imported first.
28
+ registerErrorCodes(
29
+ Object.fromEntries(Object.entries(CACHE_ERROR_TITLES).map(([code, title]) => [code, { title }])),
30
+ );
31
+
32
+ const docsFor = (code: CacheErrorCode): string => `https://ultimate.dev/errors/${code}`;
33
+
34
+ /** A tier's backing store is missing at runtime (no Redis binding, no CDN token). */
35
+ export class CacheDriverUnavailableError extends UltimateError {
36
+ constructor(input: { driver: string; cause: string; fix: string }) {
37
+ super({
38
+ code: 'X_CACHE_DRIVER_UNAVAILABLE',
39
+ cause: `cache tier "${input.driver}" is unavailable: ${input.cause}`,
40
+ fix: input.fix,
41
+ docs: docsFor('X_CACHE_DRIVER_UNAVAILABLE'),
42
+ });
43
+ }
44
+ }
45
+
46
+ /**
47
+ * A tag was used that no entity declared. Caught here rather than at read time because a
48
+ * typo in `invalidates: [tag.pots]` is otherwise a silent stale-forever bug.
49
+ */
50
+ export class CacheTagUnknownError extends UltimateError {
51
+ constructor(input: { tag: string; known: readonly string[] }) {
52
+ super({
53
+ code: 'X_CACHE_TAG_UNKNOWN',
54
+ cause: `tag "${input.tag}" is not declared by any entity (declared: ${
55
+ input.known.length > 0 ? input.known.join(', ') : 'none'
56
+ })`,
57
+ fix: 'x manifest',
58
+ docs: docsFor('X_CACHE_TAG_UNKNOWN'),
59
+ });
60
+ }
61
+ }
62
+
63
+ /** A single entry cannot fit the tier's byte budget, so caching it would evict everything. */
64
+ export class CacheTooLargeError extends UltimateError {
65
+ constructor(input: { key: string; bytes: number; maxBytes: number; tier: string }) {
66
+ super({
67
+ code: 'X_CACHE_TOO_LARGE',
68
+ cause: `entry "${input.key}" is ${input.bytes}B, over the ${input.tier} budget of ${input.maxBytes}B`,
69
+ fix: `raise cache.${input.tier}.maxBytes in app.config.ts, or cache a projection instead of the row`,
70
+ docs: docsFor('X_CACHE_TOO_LARGE'),
71
+ });
72
+ }
73
+ }
74
+
75
+ /**
76
+ * A remote purge did not happen. Never fatal on its own β€” `invalidateTags` collects it into
77
+ * `report.errors` so a dead CDN cannot fail the write that triggered the bust β€” which is exactly
78
+ * why `retryable` is carried rather than guessed: the caller decides whether the same purge,
79
+ * unchanged, is worth sending again, and a stale edge until TTL is the cost of getting it wrong.
80
+ */
81
+ export class CachePurgeFailedError extends UltimateError {
82
+ constructor(input: {
83
+ driver: string;
84
+ detail: string;
85
+ status?: number | undefined;
86
+ retryable: boolean;
87
+ fix: string;
88
+ }) {
89
+ const status = input.status === undefined ? '' : ` (HTTP ${input.status})`;
90
+ super({
91
+ code: 'X_CACHE_PURGE_FAILED',
92
+ cause: `${input.driver} refused the purge${status}: ${input.detail}`,
93
+ fix: input.fix,
94
+ docs: docsFor('X_CACHE_PURGE_FAILED'),
95
+ meta: {
96
+ driver: input.driver,
97
+ retryable: input.retryable,
98
+ ...(input.status === undefined ? {} : { status: input.status }),
99
+ },
100
+ });
101
+ }
102
+ }
package/src/graph.ts ADDED
Binary file
package/src/index.ts ADDED
@@ -0,0 +1,82 @@
1
+ // Public API of @ultimat3/cache. Explicit, no `export *`.
2
+
3
+ export type { CacheHeaderOptions, CdnTierOptions, PurgeDriver } from './cdn';
4
+ export { cacheHeaders, createCdnTier, noopPurgeDriver } from './cdn';
5
+ export type { CacheErrorCode } from './errors';
6
+ export {
7
+ CACHE_ERROR_CODES,
8
+ CACHE_ERROR_TITLES,
9
+ CacheDriverUnavailableError,
10
+ CachePurgeFailedError,
11
+ CacheTagUnknownError,
12
+ CacheTooLargeError,
13
+ } from './errors';
14
+ export type { CacheDependent, DependentKind } from './graph';
15
+ export {
16
+ dependentsOf,
17
+ dependentsOfKind,
18
+ graphSize,
19
+ graphSnapshot,
20
+ registerDependent,
21
+ resetGraph,
22
+ unregisterDependent,
23
+ } from './graph';
24
+ export type { InvalidationEvent, InvalidationReport, Revalidator } from './invalidate';
25
+ export {
26
+ invalidateTags,
27
+ invalidateWireTags,
28
+ recentInvalidations,
29
+ registeredTiers,
30
+ registerRevalidator,
31
+ registerTier,
32
+ resetTiers,
33
+ } from './invalidate';
34
+ export type { LruOptions, LruStats } from './lru';
35
+
36
+ export { createLruTier, estimateBytes, LruCache } from './lru';
37
+ export { clearMemo, createMemoTier, memoSize } from './memo';
38
+ export type { CloudflarePurgeOptions } from './purge-cloudflare';
39
+ export {
40
+ CLOUDFLARE_API_URL,
41
+ CLOUDFLARE_MAX_TAGS_PER_REQUEST,
42
+ cloudflarePurgeDriver,
43
+ } from './purge-cloudflare';
44
+ export type { PurgeEnvironment, PurgeSelection } from './purge-env';
45
+ export { CDN_PURGE_ENV_KEYS, isNoopPurgeDriver, selectPurgeDriver } from './purge-env';
46
+ export type { FastlyPurgeOptions } from './purge-fastly';
47
+ export { FASTLY_API_URL, FASTLY_MAX_KEYS_PER_REQUEST, fastlyPurgeDriver } from './purge-fastly';
48
+ export type { PurgeFetch } from './purge-http';
49
+ export { DEFAULT_PURGE_TIMEOUT_MS } from './purge-http';
50
+ export type { RedisLike, RedisTierOptions } from './redis';
51
+ export { createRedisTier, REDIS_INVALIDATE_SCRIPT } from './redis';
52
+ export type {
53
+ Embedding,
54
+ SemanticCache,
55
+ SemanticCacheOptions,
56
+ SemanticHit,
57
+ SemanticRememberOptions,
58
+ } from './semantic';
59
+ export { cosineSimilarity, createMemorySemanticCache } from './semantic';
60
+ export type { CacheTag, CacheTagRegistry, TagFactory } from './tags';
61
+ export {
62
+ assertKnownTags,
63
+ declareTags,
64
+ knownTags,
65
+ parseTag,
66
+ resetDeclaredTags,
67
+ serializeTag,
68
+ serializeTags,
69
+ tag,
70
+ tagMatches,
71
+ tagsFor,
72
+ tagsIntersect,
73
+ } from './tags';
74
+ export type {
75
+ CacheEntry,
76
+ CacheSetOptions,
77
+ CacheStack,
78
+ CacheTier,
79
+ TierInvalidation,
80
+ TierName,
81
+ } from './tiers';
82
+ export { createCacheStack, isExpired, nowMs, sortTiers, TIER_ORDER } from './tiers';
@@ -0,0 +1,170 @@
1
+ // THE single invalidation entry point. `action.cache.invalidates`, `x cache bust`, the MCP
2
+ // tool and the admin panel all call this one function β€” nothing else may talk to a tier's
3
+ // `invalidateTags` directly. One hop reaches memo, LRU, Redis, ISR routes and the CDN, and
4
+ // the returned report is what the `/_x` cache panel renders, so "did it actually clear?" is
5
+ // answerable without a log dive.
6
+
7
+ import { currentSpan, logger, systemClock, withSpan } from '@ultimat3/core';
8
+ import { dependentsOfKind } from './graph';
9
+ import type { CacheTag } from './tags';
10
+ import { assertKnownTags, parseTag, serializeTags } from './tags';
11
+ import type { CacheTier, TierInvalidation } from './tiers';
12
+ import { sortTiers } from './tiers';
13
+
14
+ /** Revalidates one ISR route path. Provided by `@ultimat3/render`; absent on a worker. */
15
+ export type Revalidator = (path: string) => Promise<void> | void;
16
+
17
+ export interface InvalidationReport {
18
+ readonly tags: readonly string[];
19
+ readonly tiers: readonly TierInvalidation[];
20
+ /** ISR route paths queued for regeneration. */
21
+ readonly isr: readonly string[];
22
+ readonly cdn: readonly string[];
23
+ readonly liveQueries: readonly string[];
24
+ readonly durationMs: number;
25
+ readonly errors: readonly { tier: string; message: string }[];
26
+ }
27
+
28
+ /** One completed `invalidateTags` call, kept for the `/_x` cache panel. */
29
+ export interface InvalidationEvent {
30
+ /** ISO-8601, from core's `systemClock` β€” never `new Date()`. */
31
+ readonly at: string;
32
+ /** Wire-form tags, exactly `report.tags`. */
33
+ readonly tags: readonly string[];
34
+ /**
35
+ * Everything the fan-out actually cleared: every tier key, plus the ISR paths, CDN paths
36
+ * and live queries.
37
+ */
38
+ readonly busted: readonly string[];
39
+ /**
40
+ * What triggered it: the name of the span active when `invalidateTags` was called, or
41
+ * `'invalidateTags'`.
42
+ */
43
+ readonly source: string;
44
+ readonly durationMs: number;
45
+ /** Tier failures, verbatim from the report β€” a partial bust must not read as a clean one. */
46
+ readonly errors: readonly { readonly tier: string; readonly message: string }[];
47
+ }
48
+
49
+ // A dev log, not an audit trail β€” capped so a long-lived `x dev` process cannot grow it forever.
50
+ const MAX_INVALIDATION_LOG = 100;
51
+
52
+ /** Newest first. Module-private; read it through `recentInvalidations()`. */
53
+ const invalidationLog: InvalidationEvent[] = [];
54
+
55
+ function recordInvalidation(event: InvalidationEvent): void {
56
+ invalidationLog.unshift(event);
57
+ invalidationLog.length = Math.min(invalidationLog.length, MAX_INVALIDATION_LOG);
58
+ }
59
+
60
+ /** What the `/_x` cache panel renders: newest first, capped, a copy of the live log. */
61
+ export function recentInvalidations(): readonly InvalidationEvent[] {
62
+ return [...invalidationLog];
63
+ }
64
+
65
+ const registry: CacheTier[] = [];
66
+ let revalidator: Revalidator | undefined;
67
+
68
+ /** Tiers register at boot from `app.config.ts`; order is normalised, not trusted. */
69
+ export function registerTier(tier: CacheTier): void {
70
+ const existing = registry.findIndex((known) => known.name === tier.name);
71
+ if (existing === -1) registry.push(tier);
72
+ else registry[existing] = tier;
73
+ }
74
+
75
+ export function registeredTiers(): readonly CacheTier[] {
76
+ return sortTiers(registry);
77
+ }
78
+
79
+ /** Test seam: drops every registered tier, the revalidator, and the invalidation log. */
80
+ export function resetTiers(): void {
81
+ registry.length = 0;
82
+ revalidator = undefined;
83
+ invalidationLog.length = 0;
84
+ }
85
+
86
+ export function registerRevalidator(next: Revalidator): void {
87
+ revalidator = next;
88
+ }
89
+
90
+ /**
91
+ * Fan out `tags` across every registered tier plus the dependency graph. Never throws for a
92
+ * tier failure: a dead Redis must not fail the write that triggered the bust β€” the failure
93
+ * lands in `report.errors` and the entry expires by TTL.
94
+ */
95
+ export function invalidateTags(tags: readonly CacheTag[]): Promise<InvalidationReport> {
96
+ // Captured before `withSpan` opens `cache.invalidate` below: inside that callback the active
97
+ // span is already this call's own, which would make every event's source the same string.
98
+ const source = currentSpan()?.name ?? 'invalidateTags';
99
+
100
+ return withSpan('cache.invalidate', async (): Promise<InvalidationReport> => {
101
+ const startedAt = performance.now();
102
+ assertKnownTags(tags);
103
+
104
+ const tiers: TierInvalidation[] = [];
105
+ const errors: { tier: string; message: string }[] = [];
106
+
107
+ for (const tier of sortTiers(registry)) {
108
+ try {
109
+ tiers.push(await tier.invalidateTags(tags));
110
+ } catch (error) {
111
+ errors.push({
112
+ tier: tier.name,
113
+ message: error instanceof Error ? error.message : String(error),
114
+ });
115
+ }
116
+ }
117
+
118
+ const isr = dependentsOfKind(tags, 'isr-route');
119
+ const cdn = dependentsOfKind(tags, 'cdn-path');
120
+ const liveQueries = dependentsOfKind(tags, 'live-query');
121
+
122
+ for (const path of isr) {
123
+ try {
124
+ await revalidator?.(path);
125
+ } catch (error) {
126
+ errors.push({
127
+ tier: 'isr',
128
+ message: error instanceof Error ? error.message : String(error),
129
+ });
130
+ }
131
+ }
132
+
133
+ const report: InvalidationReport = {
134
+ tags: serializeTags(tags),
135
+ tiers,
136
+ isr,
137
+ cdn,
138
+ liveQueries,
139
+ durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
140
+ errors,
141
+ };
142
+
143
+ recordInvalidation({
144
+ at: systemClock.now().toISOString(),
145
+ tags: report.tags,
146
+ busted: dedupe([
147
+ ...report.tiers.flatMap((entry) => entry.keys),
148
+ ...report.isr,
149
+ ...report.cdn,
150
+ ...report.liveQueries,
151
+ ]),
152
+ source,
153
+ durationMs: report.durationMs,
154
+ errors: report.errors,
155
+ });
156
+
157
+ if (errors.length > 0) logger.warn('cache.invalidate.partial', { ...report });
158
+ return report;
159
+ });
160
+ }
161
+
162
+ /** First-seen order kept β€” a union of what actually changed, not a sorted report. */
163
+ function dedupe(values: readonly string[]): readonly string[] {
164
+ return [...new Set(values)];
165
+ }
166
+
167
+ /** Convenience for `x cache bust post:1` β€” accepts the wire form agents see in reports. */
168
+ export function invalidateWireTags(wire: readonly string[]): Promise<InvalidationReport> {
169
+ return invalidateTags(wire.map(parseTag));
170
+ }