@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 +21 -0
- package/README.md +153 -0
- package/package.json +35 -0
- package/src/cdn.ts +106 -0
- package/src/errors.ts +102 -0
- package/src/graph.ts +0 -0
- package/src/index.ts +82 -0
- package/src/invalidate.ts +170 -0
- package/src/lru.ts +255 -0
- package/src/memo.ts +83 -0
- package/src/purge-cloudflare.ts +138 -0
- package/src/purge-env.ts +109 -0
- package/src/purge-fastly.ts +119 -0
- package/src/purge-http.ts +163 -0
- package/src/redis.ts +131 -0
- package/src/semantic.ts +146 -0
- package/src/tags.ts +110 -0
- package/src/tiers.ts +100 -0
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
|
+
}
|