@ultimat3/cache 24.0.0 → 25.1.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/CLAUDE.md +22 -18
- package/README.md +31 -13
- package/package.json +3 -3
- package/src/cdn.ts +2 -2
- package/src/errors.ts +20 -1
- package/src/index.ts +7 -9
- package/src/invalidate.ts +10 -3
- package/src/lru.ts +15 -5
- package/src/memo.ts +19 -6
- package/src/purge-cloudflare.ts +1 -1
- package/src/purge-fastly.ts +1 -1
- package/src/purge-http.ts +1 -3
- package/src/redis.ts +8 -3
- package/src/semantic.ts +1 -1
- package/src/tags.ts +3 -4
- package/src/tiers.ts +10 -11
- package/src/value-codec.ts +167 -0
- package/src/redis-fake.ts +0 -115
- package/src/single-flight.ts +0 -8
package/CLAUDE.md
CHANGED
|
@@ -18,13 +18,13 @@ Tier 1. Tagged caching + THE invalidation graph.
|
|
|
18
18
|
repair by the call's shape.
|
|
19
19
|
- `invalidateTags()` in `invalidate.ts` is the ONLY fan-out path, and the only writer of the log
|
|
20
20
|
`recentInvalidations()` reads. Never call `tier.invalidateTags()` from outside it.
|
|
21
|
-
- **The fan-out clears FARTHEST tier first, and reports in read order** (`
|
|
21
|
+
- **The fan-out clears FARTHEST tier first, and reports in read order** (core's `CACHE_TIERS`, what `/_x`
|
|
22
22
|
renders). Near-to-far lets a racing read promote the stale far value straight back up.
|
|
23
23
|
`CacheStack.drop` reverses for the same reason. `invalidation-race.test.ts`.
|
|
24
24
|
- **A fill is fenced: sample before `load()`, ask before the write** (`fence.ts`). `sampleFence({ key,
|
|
25
25
|
tags })` → `fence.isValid()`; `markInvalidated` is the write half (`fanOut`, `CacheStack.write`,
|
|
26
26
|
`CacheStack.drop`). Exported so a store outside this package reuses it rather than growing a
|
|
27
|
-
second mechanism. `cover()` widens a fence for joiners' tags (`
|
|
27
|
+
second mechanism. `cover()` widens a fence for joiners' tags (`cacheStack.read` only). A
|
|
28
28
|
fence never fails a read — it declines to publish. The one process-global here with no `isolate*()`
|
|
29
29
|
seam, structurally: a fence samples the current generation.
|
|
30
30
|
- **The Redis tier keeps a second fence, for the FLEET** (`CacheTier.fence`, `tier-fence.ts`,
|
|
@@ -36,11 +36,11 @@ Tier 1. Tagged caching + THE invalidation graph.
|
|
|
36
36
|
- **A refused `set` in `fill` is followed by a `del` in that tier.** `LruCache.set` called
|
|
37
37
|
directly still leaves the entry it refused to replace.
|
|
38
38
|
- One graph. `graph.ts` exports functions over module state and **no constructor**; no second registry.
|
|
39
|
-
- Tag order is `
|
|
39
|
+
- Tag order is core's `CACHE_TIERS`, never registration order. `sortTiers()` enforces it.
|
|
40
40
|
- **The rung NAMES are `@ultimat3/core`'s (`CACHE_TIERS`); the ladder is this package's.** `TierName`
|
|
41
|
-
aliases core's `CacheTierName
|
|
42
|
-
`
|
|
43
|
-
plus a factory here; `scripts/render-modes.ts` refuses a second declaration of the set. **`isr` is
|
|
41
|
+
aliases core's `CacheTierName`; the read order IS `CACHE_TIERS`, imported — 25.0.0 deleted the
|
|
42
|
+
`TIER_ORDER` alias (`X_HELPER_COPY` refuses a value of core's under a second name). Adding a
|
|
43
|
+
rung is an edit to `packages/core/src/cache-vocabulary.ts` plus a factory here; `scripts/render-modes.ts` refuses a second declaration of the set. **`isr` is
|
|
44
44
|
not a tier** — the `'isr'` in `invalidate.ts` is an ISR route (`DependentKind = 'isr-route'`).
|
|
45
45
|
- **`bestEffort()` is the only sanctioned way to swallow a cache refusal.** Its label is `TierLabel`
|
|
46
46
|
(`TierName` plus `'query-read'`), deliberately not a widening of `TierName`.
|
|
@@ -49,15 +49,15 @@ Tier 1. Tagged caching + THE invalidation graph.
|
|
|
49
49
|
values are too. The code field keeps its own total probe (`ultimateCode` in `tier-failures.ts`):
|
|
50
50
|
a driver `code` is a SQLSTATE, never an `X_*`.
|
|
51
51
|
- Tier failures go into `report.errors`; a cache tier may never fail a business read or write.
|
|
52
|
-
`
|
|
52
|
+
`cacheStack` routes every `get`/`set`/`del` through `bestEffort()` into
|
|
53
53
|
`recentTierFailures()`. `load()` is the one unguarded call (it IS the business read).
|
|
54
54
|
`LruCache.set` still throws `X_CACHE_TOO_LARGE` to a direct caller.
|
|
55
55
|
- **`serializeTags` is the wire form; `tagKeys` is the IDENTITY form** (sorted, de-duplicated —
|
|
56
56
|
`@ultimat3/query`'s `cacheKeyFor` builds keys from it). It lives here because `action` and `query`
|
|
57
|
-
are one tier and cannot share a copy.
|
|
58
|
-
`
|
|
57
|
+
are one tier and cannot share a copy. `@ultimat3/render`'s order-preserving route form is its
|
|
58
|
+
internal `routeTagKeys` (`render/src/route.ts`) — one public `tagKeys`, this one.
|
|
59
59
|
- **Two exports have no production caller and both are KEPT**: `invalidateWireTags` (the outbound
|
|
60
|
-
twin of `receiveInvalidationBroadcast`, which `packages/cli/src/
|
|
60
|
+
twin of `receiveInvalidationBroadcast`, which `packages/cli/src/runtime-cache.ts` calls) and
|
|
61
61
|
`recentTierFailures()` (no `/_x` panel reads it yet). Wire the panel or leave them.
|
|
62
62
|
- `tag.x` typing comes from the `CacheTagRegistry` augmentation, generated by `x manifest`.
|
|
63
63
|
- **`declareTags()` is additive and process-wide: a test undoes it with `isolateDeclaredTags()`,
|
|
@@ -68,16 +68,16 @@ Tier 1. Tagged caching + THE invalidation graph.
|
|
|
68
68
|
deletions. `isolateTierFailures`/`resetTierFailures` stay off `index.ts`.
|
|
69
69
|
- Clocks are injected (`LruOptions.clock`, `CacheStackOptions.clock`); read them through `nowMs()`.
|
|
70
70
|
- **`ttlMs` is positive and finite, and `assertTtl` (`tiers.ts`) is the one place that says so** —
|
|
71
|
-
every tier calls it before writing, the request memo and `
|
|
71
|
+
every tier calls it before writing, the request memo and `memorySemanticCache.remember`
|
|
72
72
|
included (scope `'semantic'`, `jitterFraction: 0`). `X_CACHE_TTL_INVALID`, never a resolution.
|
|
73
73
|
- **`assertTtl` also SPREADS the lease it validated.** `rng` is injected (`LruOptions.rng`,
|
|
74
74
|
`RedisTierOptions.rng`) — never `Math.random()` at a call site; `rng: () => 0` is the full lease.
|
|
75
75
|
`jitterFraction` outside `[0, 1)` is `X_CACHE_JITTER_INVALID`, refused, not clamped.
|
|
76
|
-
- **`
|
|
76
|
+
- **`cacheStack` is the production read path, and `@ultimat3/query`'s `readThrough` is its
|
|
77
77
|
caller** — it is what makes an action's `cache.invalidates` reach a `cache:` query.
|
|
78
|
-
- **`
|
|
79
|
-
|
|
80
|
-
`single-flight.
|
|
78
|
+
- **`cacheStack` shares one in-flight `load()` per key** through `@ultimat3/core`'s
|
|
79
|
+
`singleFlight`, imported by `tiers.ts` (`single-flight.test.ts` exercises it through the
|
|
80
|
+
stack); 25.0.0 deleted the `single-flight.ts` door and its re-export. A rejected load clears its entry. One `SingleFlight` per stack.
|
|
81
81
|
- **A wedged `load()` frees its key after `DEFAULT_LOAD_DEADLINE_MS` (30 s)** — http's
|
|
82
82
|
`requestTimeoutMs` default, written as a literal because http is tier 2. `loadDeadlineMs`
|
|
83
83
|
overrides it and `schedule` injects the timer. Eviction frees the KEY only; the load runs on. Not
|
|
@@ -95,7 +95,11 @@ Tier 1. Tagged caching + THE invalidation graph.
|
|
|
95
95
|
collection bust and `t:{entity}:<id>` + `t:{entity}` for a row one. A row bust still `SREM`s what
|
|
96
96
|
it deleted from the index (`sweepBucketsFor`). Pinned by `tier-parity.test.ts` and
|
|
97
97
|
`redis.live.test.ts`.
|
|
98
|
-
-
|
|
98
|
+
- **Every tier stores codec TEXT, never the caller's object** (`value-codec.ts`: JSON + tagged
|
|
99
|
+
`Date`/`bigint`/`Map`/`Set`, app `$x` keys escaped). One shape whichever tier hit, no shared
|
|
100
|
+
mutable reference; a cycle, depth > 512 or a bigint > 4096 chars is `X_CACHE_VALUE_UNENCODABLE`
|
|
101
|
+
at the write. Only `x1:`-marked text is revived; unmarked (pre-codec) is plain `JSON.parse`.
|
|
102
|
+
- **`CacheTier.set` REJECTS, never throws synchronously** — `lruTier`/`memoTier` are
|
|
99
103
|
`async` for that alone.
|
|
100
104
|
- **`redis.ts`'s script deletes NOTHING — it reads.** It returns the members; the tier `DEL`s them
|
|
101
105
|
client-side one key at a time (slot-local on Cluster/Dragonfly) and `SREM`s only what
|
|
@@ -148,7 +152,7 @@ Tier 1. Tagged caching + THE invalidation graph.
|
|
|
148
152
|
|---|---|
|
|
149
153
|
| `tags.ts` | `tag` factory, wire form, match semantics, `tagKeys`, declared-tag registry |
|
|
150
154
|
| `graph.ts` | tag → dependents (cache keys, ISR routes, CDN paths, live queries) |
|
|
151
|
-
| `tiers.ts` | `CacheTier`, `
|
|
155
|
+
| `tiers.ts` | `CacheTier`, `TierLabel`, `assertTtl`, read-through stack |
|
|
152
156
|
| `fence.ts` | the invalidation fence a fill (here or in `query`) checks before it publishes |
|
|
153
157
|
| `tier-fence.ts` | `TierFence`/`FenceVerdict`, and how a fill samples and asks a shared tier's fence |
|
|
154
158
|
| `redis-fence.ts` | the leased generation keys a bust writes and a fill watches |
|
|
@@ -156,8 +160,8 @@ Tier 1. Tagged caching + THE invalidation graph.
|
|
|
156
160
|
| `tier-failures.ts` | `bestEffort()`, and the bounded log of refusals it absorbs |
|
|
157
161
|
| `memo.ts` | request memo over the ALS ctx (WeakMap, no lifecycle) |
|
|
158
162
|
| `lru.ts` | byte-budgeted LRU (linked list + map + tag index) |
|
|
163
|
+
| `value-codec.ts` | the one value encoding every tier stores and decodes |
|
|
159
164
|
| `redis.ts` | `Bun.redis` tier, build-namespaced keys, hash-tagged buckets, one script call per tag |
|
|
160
|
-
| `single-flight.ts` | the door onto `@ultimat3/core`'s `createSingleFlight` — one in-flight `load()` per key, shared by every concurrent miss. No implementation of its own since 2026-08-23 |
|
|
161
165
|
| `cdn.ts` | `Cache-Control`/`Surrogate-Key` emission, `surrogateKeys`, the purge key list, the `PurgeDriver` seam |
|
|
162
166
|
| `purge-http.ts` | the HTTP half both remote drivers share: one POST, batching, key guard, and core's retryable table re-exported |
|
|
163
167
|
| `purge-fastly.ts` | `fastlyPurgeDriver`: surrogate-key batch purge, `purge_all` |
|
package/README.md
CHANGED
|
@@ -32,12 +32,12 @@ Reads walk down until a hit, then populate every tier they walked past. Writes p
|
|
|
32
32
|
|
|
33
33
|
A tier is a `CacheTier` (`get`/`set`/`del`/`invalidateTags`, plus an optional `fence` for a tier
|
|
34
34
|
other processes also write). Swap or omit any of them
|
|
35
|
-
without touching a call site — order comes from `
|
|
35
|
+
without touching a call site — order comes from `@ultimat3/core`'s `CACHE_TIERS`, not registration order.
|
|
36
36
|
|
|
37
37
|
```ts
|
|
38
|
-
import {
|
|
38
|
+
import { cacheStack, lruTier, memoTier, registerTier } from '@ultimat3/cache';
|
|
39
39
|
|
|
40
|
-
const stack =
|
|
40
|
+
const stack = cacheStack([memoTier(), lruTier({ maxBytes: 64 * 1024 * 1024 })]);
|
|
41
41
|
for (const tier of stack.tiers) registerTier(tier);
|
|
42
42
|
|
|
43
43
|
const feed = await stack.read('feed:org-1', () => db.posts.recent(), {
|
|
@@ -46,6 +46,22 @@ const feed = await stack.read('feed:org-1', () => db.posts.recent(), {
|
|
|
46
46
|
});
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
+
**Cached values: JSON + `Date` + `bigint` + `Map` + `Set` — the same shape from every tier.**
|
|
50
|
+
Every tier (the memo and the LRU included) stores the value as text through one codec
|
|
51
|
+
(`value-codec.ts`) and decodes a fresh copy on each hit, so:
|
|
52
|
+
|
|
53
|
+
| You write | Every tier answers |
|
|
54
|
+
|---|---|
|
|
55
|
+
| a `Date`, a `bigint`, a `Map`, a `Set` (at any depth) | the same type back |
|
|
56
|
+
| a plain object, array, string, number, boolean, `null` | JSON semantics: `NaN`/`Infinity` → `null`, `undefined` members dropped |
|
|
57
|
+
| a class instance | a plain object — its prototype is not stored |
|
|
58
|
+
| a cycle, a `toJSON` that throws, nesting past `MAX_CACHE_VALUE_DEPTH` (512), a `bigint` past `MAX_CACHE_BIGINT_DIGITS` (4096 chars) | `X_CACHE_VALUE_UNENCODABLE`, at the write — a value is stored and readable, or refused |
|
|
59
|
+
|
|
60
|
+
A hit is never the object that was written, nor the one another reader got: mutating it changes
|
|
61
|
+
nothing in the cache. An app key spelled `$x` (or `$$x`…) is escaped on the way in. The stored text
|
|
62
|
+
is `x1:` + JSON; only marked text is revived, so a Redis entry written as plain JSON before the
|
|
63
|
+
codec reads back exactly as stored — never revived, never renamed.
|
|
64
|
+
|
|
49
65
|
**`ttlMs` is positive and finite, in every tier.** Omit it for the tier's default; anything else
|
|
50
66
|
is `X_CACHE_TTL_INVALID`. There is no "never expires" and no "do not cache" — `0` used to mean the
|
|
51
67
|
first in the LRU tier and one second in the Redis tier, so a stack holding both answered
|
|
@@ -58,9 +74,9 @@ called. 40,000 keys warmed by one rolling restart otherwise share one expiry ins
|
|
|
58
74
|
inside the same 30-second window. The roll is injected, never `Math.random()` at a call site:
|
|
59
75
|
|
|
60
76
|
```ts
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
77
|
+
lruTier({ rng: () => 0 }); // the full lease — what a test asserting an exact expiry wants
|
|
78
|
+
redisTier({ jitterFraction: 0 }); // off entirely
|
|
79
|
+
redisTier({ jitterFraction: 0.2 }); // a wider spread; outside [0, 1) is X_CACHE_JITTER_INVALID
|
|
64
80
|
```
|
|
65
81
|
|
|
66
82
|
**N concurrent misses are ONE origin load.** `stack.read` shares an in-flight `load()` per key, so
|
|
@@ -68,13 +84,13 @@ a reader arriving while another's load is running joins it instead of issuing it
|
|
|
68
84
|
ends as the load settles, rejection included, so one failure is never held as a permanent one. A
|
|
69
85
|
feed cached for 60s and read 8,000×/s otherwise sends ~1,600 identical queries to Postgres at every
|
|
70
86
|
TTL boundary, because the write only lands after `load()` resolves. The primitive is
|
|
71
|
-
`
|
|
72
|
-
the stack holds one per stack.
|
|
87
|
+
`singleFlight()` from `@ultimat3/core` if you need it elsewhere (25.0.0 dropped this
|
|
88
|
+
package's re-export); the stack holds one per stack.
|
|
73
89
|
A `load()` that never settles does **not** hold its key for ever: past `loadDeadlineMs`
|
|
74
90
|
(`DEFAULT_LOAD_DEADLINE_MS`, 30s — the point at which `http.requestTimeoutMs` already abandoned the
|
|
75
91
|
request that was waiting for it) the key is freed and the next reader loads for itself. Eviction
|
|
76
92
|
frees the key and never the work, so the readers already holding that load still get its answer,
|
|
77
|
-
and the cost is one duplicate fill — `
|
|
93
|
+
and the cost is one duplicate fill — `cacheStack(tiers, { loadDeadlineMs: 5_000 })` for a
|
|
78
94
|
tighter ceiling on a fast origin. A joiner shares the
|
|
79
95
|
leader's **write** as well as its load, so it contributes to it: tags union, TTLs take the shortest.
|
|
80
96
|
Without that the entry landed carrying only the leader's tags and the joiner's invalidation never
|
|
@@ -96,7 +112,7 @@ if (fence.isValid()) await tier.set(key, value, { tags });
|
|
|
96
112
|
**That fence is this process's; the Redis tier keeps a second one for the fleet.** A bust another
|
|
97
113
|
replica ran reaches this process only when the broadcast does, and a lost broadcast means never —
|
|
98
114
|
so the fill would publish the pre-write rows into the *shared* tier and every replica would promote
|
|
99
|
-
them. `
|
|
115
|
+
them. `redisTier` implements `CacheTier.fence`: a bust writes a leased generation
|
|
100
116
|
(`<ns>:g:{entity}:r:<id>`, `:c`, `:a`) **before** it reads its buckets, `stack.read` samples the
|
|
101
117
|
generations its tags watch before `load()`, and re-reads them after the tier's `SET`.
|
|
102
118
|
|
|
@@ -222,7 +238,7 @@ Redis cannot read each other's payloads. Rename `PostView.author` to `PostView.a
|
|
|
222
238
|
deploy: `JSON.parse` does not validate, so the old pod reads the new shape back and hands it to a
|
|
223
239
|
renderer expecting the old one — an undefined author on every cached post, on half the fleet, for
|
|
224
240
|
the length of the rolling deploy. The cost of the default is a **cold shared tier per deploy**,
|
|
225
|
-
which is the cheaper of the two. Opt out with `
|
|
241
|
+
which is the cheaper of the two. Opt out with `redisTier({ buildId: null })` if you version
|
|
226
242
|
your own payloads.
|
|
227
243
|
|
|
228
244
|
## Invalidating
|
|
@@ -382,11 +398,11 @@ rather than failing the write that triggered it.
|
|
|
382
398
|
## Semantic cache
|
|
383
399
|
|
|
384
400
|
For LLM calls, where "list my orders" and "show me my orders" must hit the same entry.
|
|
385
|
-
`
|
|
401
|
+
`memorySemanticCache()` does cosine similarity at a 0.92 threshold (tight on
|
|
386
402
|
purpose — a false hit answers the wrong question, which is worse than a miss) and is the
|
|
387
403
|
only backing this package ships — it is O(n) and in-process. The interface (`SemanticCache`)
|
|
388
404
|
is a driver seam for that reason; a Postgres/pgvector-backed implementation does not exist
|
|
389
|
-
yet here (`@ultimat3/ai`'s `
|
|
405
|
+
yet here (`@ultimat3/ai`'s `postgresVectorStore()` is a separate store, for RAG retrieval, not this
|
|
390
406
|
cache).
|
|
391
407
|
|
|
392
408
|
## Errors
|
|
@@ -399,6 +415,7 @@ cache).
|
|
|
399
415
|
| `X_CACHE_TAG_UNKNOWN` | a tag no entity declared — usually a typo |
|
|
400
416
|
| `X_CACHE_TOO_LARGE` | one entry exceeds a tier's whole byte budget |
|
|
401
417
|
| `X_CACHE_TTL_INVALID` | a `ttlMs` that is not a positive, finite number of milliseconds |
|
|
418
|
+
| `X_CACHE_VALUE_UNENCODABLE` | a value with a cycle, a throwing `toJSON`, nesting past 512 levels or a `bigint` past 4096 digits, written to any tier |
|
|
402
419
|
|
|
403
420
|
### Error classes
|
|
404
421
|
|
|
@@ -414,6 +431,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
414
431
|
| `CacheTagUnknownError` | `X_CACHE_TAG_UNKNOWN` | `src/errors.ts` |
|
|
415
432
|
| `CacheTooLargeError` | `X_CACHE_TOO_LARGE` | `src/errors.ts` |
|
|
416
433
|
| `CacheTtlInvalidError` | `X_CACHE_TTL_INVALID` | `src/errors.ts` |
|
|
434
|
+
| `CacheValueUnencodableError` | `X_CACHE_VALUE_UNENCODABLE` | `src/errors.ts` |
|
|
417
435
|
|
|
418
436
|
## Boundary
|
|
419
437
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cache",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "25.1.0",
|
|
4
4
|
"description": "Tagged caching: request memo, LRU, Redis, CDN — one invalidation graph",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -25,13 +25,13 @@
|
|
|
25
25
|
"LICENSE"
|
|
26
26
|
],
|
|
27
27
|
"engines": {
|
|
28
|
-
"bun": ">=1.4.
|
|
28
|
+
"bun": ">=1.4.2"
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
31
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
32
32
|
"test": "bun test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@ultimat3/core": "
|
|
35
|
+
"@ultimat3/core": "25.1.0"
|
|
36
36
|
}
|
|
37
37
|
}
|
package/src/cdn.ts
CHANGED
|
@@ -125,7 +125,7 @@ export function noopPurgeDriver(): PurgeDriver {
|
|
|
125
125
|
* A driver that reaches no CDN, so a caller can report "purges nothing" without a name match.
|
|
126
126
|
*
|
|
127
127
|
* Lives here rather than beside `selectPurgeDriver`, which is where it was until 2026-08: the
|
|
128
|
-
* `name: 'noop'` it tests for is declared one function up, and `
|
|
128
|
+
* `name: 'noop'` it tests for is declared one function up, and `cdnTier` — the caller that
|
|
129
129
|
* most needs it — cannot import from `purge-env.ts` without making a cycle of the two files.
|
|
130
130
|
*/
|
|
131
131
|
export const isNoopPurgeDriver = (driver: PurgeDriver): boolean => driver.name === 'noop';
|
|
@@ -140,7 +140,7 @@ export interface CdnTierOptions {
|
|
|
140
140
|
readonly pathsForKey?: (key: string) => readonly string[];
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
export function
|
|
143
|
+
export function cdnTier(options: CdnTierOptions = {}): CacheTier {
|
|
144
144
|
const driver = options.purge ?? noopPurgeDriver();
|
|
145
145
|
|
|
146
146
|
return {
|
package/src/errors.ts
CHANGED
|
@@ -11,6 +11,7 @@ export const CACHE_OWNED_ERROR_CODES = [
|
|
|
11
11
|
'X_CACHE_TAG_UNKNOWN',
|
|
12
12
|
'X_CACHE_TOO_LARGE',
|
|
13
13
|
'X_CACHE_TTL_INVALID',
|
|
14
|
+
'X_CACHE_VALUE_UNENCODABLE',
|
|
14
15
|
] as const;
|
|
15
16
|
|
|
16
17
|
/** Every code cache can throw. It borrows none: every remote driver here is implemented. */
|
|
@@ -27,6 +28,7 @@ export const CACHE_ERROR_TITLES: Readonly<Record<CacheOwnedErrorCode, string>> =
|
|
|
27
28
|
X_CACHE_TAG_UNKNOWN: 'a tag no entity declared',
|
|
28
29
|
X_CACHE_TOO_LARGE: "one entry exceeds the tier's byte budget",
|
|
29
30
|
X_CACHE_TTL_INVALID: 'a cache TTL that is not a positive number of milliseconds',
|
|
31
|
+
X_CACHE_VALUE_UNENCODABLE: 'a cached value the cache codec cannot encode',
|
|
30
32
|
};
|
|
31
33
|
|
|
32
34
|
// One unconditional call, so a second package claiming one of cache's codes throws
|
|
@@ -73,6 +75,23 @@ export class CacheTooLargeError extends UltimateError {
|
|
|
73
75
|
}
|
|
74
76
|
}
|
|
75
77
|
|
|
78
|
+
/**
|
|
79
|
+
* A value the cache codec (`value-codec.ts`) cannot write: a cycle, a `toJSON` that throws, nesting
|
|
80
|
+
* past `MAX_CACHE_VALUE_DEPTH`, or a `bigint` past `MAX_CACHE_BIGINT_DIGITS`. Refused at the WRITE, in every tier alike — the LRU used to hold it by reference while Redis
|
|
81
|
+
* threw a bare `TypeError`, so the same `set` worked on one rung and failed on the next. `reason`
|
|
82
|
+
* is `renderThrowable` output, never the value: a cached value is application data.
|
|
83
|
+
*/
|
|
84
|
+
export class CacheValueUnencodableError extends UltimateError {
|
|
85
|
+
constructor(input: { key: string; tier: string; reason: string }) {
|
|
86
|
+
super({
|
|
87
|
+
code: 'X_CACHE_VALUE_UNENCODABLE',
|
|
88
|
+
cause: `entry "${input.key}" cannot be written to the ${input.tier} tier: ${input.reason}. A cache tier holds JSON plus Date, bigint, Map and Set, the same shape on every tier`,
|
|
89
|
+
fix: 'load: () => rows.map(({ parent, ...row }) => row) — cache a projection the codec reads back: plain objects, no back-references, nesting under 512 levels, a bigint past 4096 digits stored as a string',
|
|
90
|
+
meta: { key: input.key, tier: input.tier },
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
76
95
|
/**
|
|
77
96
|
* A LIMIT this package runs on that is not one — a tier's byte budget, its entry ceiling, the
|
|
78
97
|
* similarity floor the semantic tier decides "same question" with, or a purge driver's request
|
|
@@ -108,7 +127,7 @@ export class CacheLimitInvalidError extends UltimateError {
|
|
|
108
127
|
expected: string;
|
|
109
128
|
/**
|
|
110
129
|
* Where the value came FROM, when that is not an `app.config.ts` key. `loadDeadlineMs` and the
|
|
111
|
-
* similarity override arrive as call arguments (`
|
|
130
|
+
* similarity override arrive as call arguments (`cacheStack`, `lookup`), so telling their
|
|
112
131
|
* caller to edit `app.config.ts` names a key that does not exist.
|
|
113
132
|
*/
|
|
114
133
|
source?: string | undefined;
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
export type { CacheHeaderOptions, CdnTierOptions, PurgeDriver } from './cdn';
|
|
4
4
|
export {
|
|
5
5
|
cacheHeaders,
|
|
6
|
-
|
|
6
|
+
cdnTier,
|
|
7
7
|
isNoopPurgeDriver,
|
|
8
8
|
noopPurgeDriver,
|
|
9
9
|
surrogateKeys,
|
|
@@ -19,6 +19,7 @@ export {
|
|
|
19
19
|
CacheTagUnknownError,
|
|
20
20
|
CacheTooLargeError,
|
|
21
21
|
CacheTtlInvalidError,
|
|
22
|
+
CacheValueUnencodableError,
|
|
22
23
|
} from './errors';
|
|
23
24
|
export type { CacheFence, FenceScope } from './fence';
|
|
24
25
|
export { FENCE_MEMORY, markInvalidated, sampleFence } from './fence';
|
|
@@ -53,8 +54,8 @@ export {
|
|
|
53
54
|
} from './invalidate';
|
|
54
55
|
export type { LruOptions, LruStats } from './lru';
|
|
55
56
|
|
|
56
|
-
export {
|
|
57
|
-
export { clearMemo,
|
|
57
|
+
export { estimateBytes, LruCache, lruTier } from './lru';
|
|
58
|
+
export { clearMemo, memoSize, memoTier } from './memo';
|
|
58
59
|
export type { CloudflarePurgeOptions } from './purge-cloudflare';
|
|
59
60
|
export {
|
|
60
61
|
CLOUDFLARE_API_URL,
|
|
@@ -69,10 +70,10 @@ export type { PurgeFetch } from './purge-http';
|
|
|
69
70
|
export { DEFAULT_PURGE_TIMEOUT_MS } from './purge-http';
|
|
70
71
|
export type { RedisLike, RedisTierOptions } from './redis';
|
|
71
72
|
export {
|
|
72
|
-
createRedisTier,
|
|
73
73
|
namespaceFor,
|
|
74
74
|
REDIS_INVALIDATE_SCRIPT,
|
|
75
75
|
REDIS_TAG_MEMBER_SCRIPT,
|
|
76
|
+
redisTier,
|
|
76
77
|
} from './redis';
|
|
77
78
|
export type {
|
|
78
79
|
Embedding,
|
|
@@ -81,9 +82,7 @@ export type {
|
|
|
81
82
|
SemanticHit,
|
|
82
83
|
SemanticRememberOptions,
|
|
83
84
|
} from './semantic';
|
|
84
|
-
export { cosineSimilarity,
|
|
85
|
-
export type { FlightJoin, SingleFlight } from './single-flight';
|
|
86
|
-
export { createSingleFlight } from './single-flight';
|
|
85
|
+
export { cosineSimilarity, memorySemanticCache } from './semantic';
|
|
87
86
|
export type { CacheTag, CacheTagRegistry, TagFactory } from './tags';
|
|
88
87
|
export {
|
|
89
88
|
assertKnownTags,
|
|
@@ -121,11 +120,10 @@ export {
|
|
|
121
120
|
assertFiniteDurationMs,
|
|
122
121
|
assertFiniteSimilarityFloor,
|
|
123
122
|
assertTtl,
|
|
124
|
-
|
|
123
|
+
cacheStack,
|
|
125
124
|
DEFAULT_LOAD_DEADLINE_MS,
|
|
126
125
|
DEFAULT_TTL_JITTER_FRACTION,
|
|
127
126
|
isExpired,
|
|
128
127
|
nowMs,
|
|
129
128
|
sortTiers,
|
|
130
|
-
TIER_ORDER,
|
|
131
129
|
} from './tiers';
|
package/src/invalidate.ts
CHANGED
|
@@ -4,14 +4,21 @@
|
|
|
4
4
|
// the returned report is what the `/_x` cache panel renders, so "did it actually clear?" is
|
|
5
5
|
// answerable without a log dive.
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
CACHE_TIERS,
|
|
9
|
+
currentSpan,
|
|
10
|
+
logger,
|
|
11
|
+
renderThrowable,
|
|
12
|
+
systemClock,
|
|
13
|
+
withSpan,
|
|
14
|
+
} from '@ultimat3/core';
|
|
8
15
|
import { markInvalidated } from './fence';
|
|
9
16
|
import { dependentsOfKind } from './graph';
|
|
10
17
|
import type { CacheTag } from './tags';
|
|
11
18
|
import { assertKnownTags, knownTags, parseTag, serializeTags } from './tags';
|
|
12
19
|
import { isolateTierFailures, resetTierFailures } from './tier-failures';
|
|
13
20
|
import type { CacheTier, TierInvalidation } from './tiers';
|
|
14
|
-
import { sortTiers
|
|
21
|
+
import { sortTiers } from './tiers';
|
|
15
22
|
|
|
16
23
|
/** Revalidates one ISR route path. Provided by `@ultimat3/render`; absent on a worker. */
|
|
17
24
|
export type Revalidator = (path: string) => Promise<void> | void;
|
|
@@ -231,7 +238,7 @@ function fanOut(tags: readonly CacheTag[], options: FanOutOptions): Promise<Inva
|
|
|
231
238
|
}
|
|
232
239
|
// The report is read order, not clear order: it is what the `/_x` panel renders, and a ladder
|
|
233
240
|
// printed upside down is a second thing for a reader to learn.
|
|
234
|
-
tiers.sort((a, b) =>
|
|
241
|
+
tiers.sort((a, b) => CACHE_TIERS.indexOf(a.tier) - CACHE_TIERS.indexOf(b.tier));
|
|
235
242
|
|
|
236
243
|
const isr = dependentsOfKind(tags, 'isr-route');
|
|
237
244
|
const cdn = dependentsOfKind(tags, 'cdn-path');
|
package/src/lru.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
TtlJitter,
|
|
18
18
|
} from './tiers';
|
|
19
19
|
import { assertFiniteCapacity, assertFiniteDurationMs, assertTtl, nowMs } from './tiers';
|
|
20
|
+
import { decodeCacheValue, encodeCacheValue } from './value-codec';
|
|
20
21
|
|
|
21
22
|
export interface LruOptions {
|
|
22
23
|
/** Byte budget for the whole tier. Default 64 MiB. */
|
|
@@ -32,7 +33,12 @@ export interface LruOptions {
|
|
|
32
33
|
|
|
33
34
|
interface LruNode {
|
|
34
35
|
key: string;
|
|
35
|
-
|
|
36
|
+
/**
|
|
37
|
+
* `{ v }` as `value-codec.ts` text, never the caller's object: held by reference, a hit was the
|
|
38
|
+
* writer's live object — mutated by one reader for every later one — and a `Date` here was an
|
|
39
|
+
* ISO string on the pod that read the same entry back out of Redis.
|
|
40
|
+
*/
|
|
41
|
+
text: string;
|
|
36
42
|
bytes: number;
|
|
37
43
|
/** Epoch ms. Always finite: `assertTtl` refuses the `0` that used to mean "never expires". */
|
|
38
44
|
expiresAt: number;
|
|
@@ -141,11 +147,15 @@ export class LruCache {
|
|
|
141
147
|
}
|
|
142
148
|
this.touch(node);
|
|
143
149
|
this.hits += 1;
|
|
144
|
-
|
|
150
|
+
const { v } = decodeCacheValue(node.text) as { readonly v: T };
|
|
151
|
+
return { value: v, tags: node.tags, expiresAt: node.expiresAt };
|
|
145
152
|
}
|
|
146
153
|
|
|
147
154
|
set<T>(key: string, value: T, options: CacheSetOptions = {}): void {
|
|
148
|
-
|
|
155
|
+
// Encoded first: the budget is then the exact bytes held, and an unencodable value is refused
|
|
156
|
+
// before anything is evicted for it.
|
|
157
|
+
const text = encodeCacheValue({ v: value }, { key, tier: 'lru' });
|
|
158
|
+
const bytes = estimateBytes(text) + encoder.encode(key).byteLength;
|
|
149
159
|
if (bytes > this.maxBytes) {
|
|
150
160
|
throw new CacheTooLargeError({ key, bytes, maxBytes: this.maxBytes, tier: 'lru' });
|
|
151
161
|
}
|
|
@@ -158,7 +168,7 @@ export class LruCache {
|
|
|
158
168
|
|
|
159
169
|
const node: LruNode = {
|
|
160
170
|
key,
|
|
161
|
-
|
|
171
|
+
text,
|
|
162
172
|
bytes,
|
|
163
173
|
expiresAt: nowMs(this.clock) + ttl,
|
|
164
174
|
tags: options.tags ?? [],
|
|
@@ -273,7 +283,7 @@ export class LruCache {
|
|
|
273
283
|
}
|
|
274
284
|
}
|
|
275
285
|
|
|
276
|
-
export function
|
|
286
|
+
export function lruTier(options: LruOptions = {}): CacheTier & { readonly cache: LruCache } {
|
|
277
287
|
const cache = new LruCache(options);
|
|
278
288
|
return {
|
|
279
289
|
name: 'lru',
|
package/src/memo.ts
CHANGED
|
@@ -10,8 +10,18 @@ import type { CacheTag } from './tags';
|
|
|
10
10
|
import { tagsIntersect } from './tags';
|
|
11
11
|
import type { CacheEntry, CacheSetOptions, CacheTier, TierInvalidation } from './tiers';
|
|
12
12
|
import { assertTtl } from './tiers';
|
|
13
|
+
import { decodeCacheValue, encodeCacheValue } from './value-codec';
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Codec text, as in every other tier: two callers in one request reading one key must not share
|
|
17
|
+
* one mutable object, and a hit here must be the shape the LRU or Redis would have answered.
|
|
18
|
+
*/
|
|
19
|
+
interface MemoEntry {
|
|
20
|
+
readonly text: string;
|
|
21
|
+
readonly tags: readonly CacheTag[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type MemoStore = Map<string, MemoEntry>;
|
|
15
25
|
|
|
16
26
|
const stores = new WeakMap<object, MemoStore>();
|
|
17
27
|
|
|
@@ -46,14 +56,16 @@ export function memoSize(): number {
|
|
|
46
56
|
return storeFor(false)?.size ?? 0;
|
|
47
57
|
}
|
|
48
58
|
|
|
49
|
-
export function
|
|
59
|
+
export function memoTier(): CacheTier {
|
|
50
60
|
return {
|
|
51
61
|
name: 'request-memo',
|
|
52
62
|
|
|
53
63
|
get<T>(key: string): Promise<CacheEntry<T> | undefined> {
|
|
54
64
|
const entry = storeFor(false)?.get(key);
|
|
55
65
|
// No TTL check: a request is shorter than any meaningful TTL.
|
|
56
|
-
|
|
66
|
+
if (entry === undefined) return Promise.resolve(undefined);
|
|
67
|
+
const { v } = decodeCacheValue(entry.text) as { readonly v: T };
|
|
68
|
+
return Promise.resolve({ value: v, tags: entry.tags });
|
|
57
69
|
},
|
|
58
70
|
|
|
59
71
|
/**
|
|
@@ -61,21 +73,22 @@ export function createMemoTier(): CacheTier {
|
|
|
61
73
|
*
|
|
62
74
|
* This tier holds nothing past the request, so it stores no `expiresAt` — but it is still a
|
|
63
75
|
* rung of one ladder, and `assertTtl` is the one place that says what `ttlMs` may be. Skipping
|
|
64
|
-
* it made `ttlMs: 0` a value the memo accepted and every other tier refused: `
|
|
76
|
+
* it made `ttlMs: 0` a value the memo accepted and every other tier refused: `cacheStack`
|
|
65
77
|
* routes each rung through `bestEffort`, so the miswiring was swallowed as two tier failures
|
|
66
78
|
* and the read still hit — out of the one tier that never should have taken it. Exactly the
|
|
67
79
|
* "two tiers, two readings of `0`" the rule exists to close.
|
|
68
80
|
*
|
|
69
81
|
* Only a lease the caller SUPPLIED is checked: there is no default to fall back to, because a
|
|
70
82
|
* memo entry that outlives its request is not a thing that can happen. `jitterFraction: 0` for
|
|
71
|
-
* `
|
|
83
|
+
* `memorySemanticCache`'s reason — spreading a lease is a herd defence for a SHARED
|
|
72
84
|
* store, and this one dies with the request that made it.
|
|
73
85
|
*/
|
|
74
86
|
async set<T>(key: string, value: T, options?: CacheSetOptions): Promise<void> {
|
|
75
87
|
if (options?.ttlMs !== undefined) {
|
|
76
88
|
assertTtl(key, options.ttlMs, 'request-memo', { jitterFraction: 0 });
|
|
77
89
|
}
|
|
78
|
-
|
|
90
|
+
const text = encodeCacheValue({ v: value }, { key, tier: 'request-memo' });
|
|
91
|
+
storeFor(true)?.set(key, { text, tags: options?.tags ?? [] });
|
|
79
92
|
},
|
|
80
93
|
|
|
81
94
|
del(key: string): Promise<void> {
|
package/src/purge-cloudflare.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// tags ARE Ultimate's wire tags, so a `Cache-Tag` response header and an `invalidates: [tag.post]`
|
|
4
4
|
// name the same string — the alternative, purging by URL, would need a route list nobody keeps.
|
|
5
5
|
|
|
6
|
+
import { isRetryableStatus } from '@ultimat3/core';
|
|
6
7
|
import type { PurgeDriver } from './cdn';
|
|
7
8
|
import { CachePurgeFailedError } from './errors';
|
|
8
9
|
import type { PurgeFetch } from './purge-http';
|
|
@@ -13,7 +14,6 @@ import {
|
|
|
13
14
|
defaultPurgeFetch,
|
|
14
15
|
detailFrom,
|
|
15
16
|
isRecord,
|
|
16
|
-
isRetryableStatus,
|
|
17
17
|
purgeBody,
|
|
18
18
|
purgePost,
|
|
19
19
|
requireCredential,
|
package/src/purge-fastly.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// client. The keys are Ultimate's wire tags unchanged (`post`, `post:1`), which is the property
|
|
4
4
|
// that keeps an edge purge and an app-level invalidation from ever meaning different things.
|
|
5
5
|
|
|
6
|
+
import { isRetryableStatus } from '@ultimat3/core';
|
|
6
7
|
import type { PurgeDriver } from './cdn';
|
|
7
8
|
import { CachePurgeFailedError } from './errors';
|
|
8
9
|
import type { PurgeFetch } from './purge-http';
|
|
@@ -13,7 +14,6 @@ import {
|
|
|
13
14
|
defaultPurgeFetch,
|
|
14
15
|
detailFrom,
|
|
15
16
|
isRecord,
|
|
16
|
-
isRetryableStatus,
|
|
17
17
|
type PurgeBody,
|
|
18
18
|
purgeBody,
|
|
19
19
|
purgePost,
|
package/src/purge-http.ts
CHANGED
|
@@ -18,9 +18,7 @@ const MAX_DETAIL_LENGTH = 200;
|
|
|
18
18
|
// Every other 4xx is a credential or a plan, which no retry fixes. The table itself is
|
|
19
19
|
// `@ultimat3/core`'s — this line and `packages/mail/src/driver-resend.ts`'s were byte-identical in
|
|
20
20
|
// two packages that cannot import each other, so one of them was always going to be edited alone.
|
|
21
|
-
//
|
|
22
|
-
// off the shared HTTP half — the same door `@ultimat3/auth`'s `tokens.ts` gives `timingSafeEqual`.
|
|
23
|
-
export { isRetryableStatus } from '@ultimat3/core';
|
|
21
|
+
// Both purge drivers import it from core; it is not re-exported here (`X_HELPER_COPY`).
|
|
24
22
|
|
|
25
23
|
/**
|
|
26
24
|
* A bare reference to `globalThis.fetch` risks "Illegal invocation" on some hosts; closing over
|
package/src/redis.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
TtlJitter,
|
|
20
20
|
} from './tiers';
|
|
21
21
|
import { assertFiniteDurationMs, assertTtl, nowMs } from './tiers';
|
|
22
|
+
import { decodeCacheValue, encodeCacheValue } from './value-codec';
|
|
22
23
|
|
|
23
24
|
/** The slice of Bun's Redis client this tier uses. Narrow on purpose: easy to fake in tests. */
|
|
24
25
|
export interface RedisLike {
|
|
@@ -191,7 +192,7 @@ function raiseSweepFailure(failures: readonly unknown[], attempted: number): nev
|
|
|
191
192
|
});
|
|
192
193
|
}
|
|
193
194
|
|
|
194
|
-
export function
|
|
195
|
+
export function redisTier(options: RedisTierOptions = {}): CacheTier {
|
|
195
196
|
const ns = namespaceFor(options.prefix ?? 'x', options.buildId);
|
|
196
197
|
const defaultTtlMs = assertFiniteDurationMs(
|
|
197
198
|
'redis',
|
|
@@ -285,7 +286,9 @@ export function createRedisTier(options: RedisTierOptions = {}): CacheTier {
|
|
|
285
286
|
// death handed a fresh five minutes, one tier closer to the request.
|
|
286
287
|
if ('kind' in lease && lease.kind === 'reaped') return undefined;
|
|
287
288
|
try {
|
|
288
|
-
|
|
289
|
+
// `decodeCacheValue`, not `JSON.parse`: the LRU decodes with the same codec, so a `Date`
|
|
290
|
+
// promoted out of here is a `Date` there too. Plain JSON written before the codec reads as-is.
|
|
291
|
+
const parsed = decodeCacheValue(raw) as StoredEntry;
|
|
289
292
|
return {
|
|
290
293
|
value: parsed.v as T,
|
|
291
294
|
tags: parsed.t.map(parseTag),
|
|
@@ -314,6 +317,8 @@ export function createRedisTier(options: RedisTierOptions = {}): CacheTier {
|
|
|
314
317
|
const tags = setOptions?.tags ?? [];
|
|
315
318
|
const ttlMs = assertTtl(key, setOptions?.ttlMs ?? defaultTtlMs, 'redis', jitter);
|
|
316
319
|
const payload: StoredEntry = { v: value, t: tags.map(serializeTag) };
|
|
320
|
+
// Before any bucket is joined: an unencodable value is refused with nothing left behind.
|
|
321
|
+
const text = encodeCacheValue(payload, { key, tier: 'redis' });
|
|
317
322
|
const stored = valueKey(key);
|
|
318
323
|
// `PX`, not `EX`: the value's lease is spent in the milliseconds it was validated and
|
|
319
324
|
// jittered in. `Math.ceil(ttlMs / 1000)` honoured a 1,001ms lease as 2s — rounding toward
|
|
@@ -329,7 +334,7 @@ export function createRedisTier(options: RedisTierOptions = {}): CacheTier {
|
|
|
329
334
|
conn().send('EVAL', [TAG_MEMBER_SCRIPT, '1', bucket, stored, bucketTtlSeconds]),
|
|
330
335
|
),
|
|
331
336
|
);
|
|
332
|
-
await conn().send('SET', [stored,
|
|
337
|
+
await conn().send('SET', [stored, text, 'PX', String(Math.ceil(ttlMs))]);
|
|
333
338
|
if (buckets.length === 0) return;
|
|
334
339
|
const membership = await Promise.all(
|
|
335
340
|
buckets.map((bucket) => conn().send('SISMEMBER', [bucket, stored])),
|
package/src/semantic.ts
CHANGED
|
@@ -78,7 +78,7 @@ interface SemanticRecord {
|
|
|
78
78
|
readonly tags: readonly CacheTag[];
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
export function
|
|
81
|
+
export function memorySemanticCache(options: SemanticCacheOptions = {}): SemanticCache {
|
|
82
82
|
// Screened at construction, both of them: this tier's two knobs are the ones whose failure is
|
|
83
83
|
// silent — a floor of NaN matches everything, a ceiling of NaN evicts nothing.
|
|
84
84
|
const threshold = assertFiniteSimilarityFloor('semantic', 'threshold', options.threshold ?? 0.92);
|
package/src/tags.ts
CHANGED
|
@@ -65,10 +65,9 @@ export function serializeTags(tags: readonly CacheTag[]): string[] {
|
|
|
65
65
|
* both are tier 3, so neither can import the other and a copy in either is a second answer for the
|
|
66
66
|
* other. The same move `toBucket` made into `@ultimat3/http`.
|
|
67
67
|
*
|
|
68
|
-
* NOT
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* naming it here is where a reader can see both.
|
|
68
|
+
* NOT `@ultimat3/render`'s internal `routeTagKeys`, which is `serializeTags` over an optional list
|
|
69
|
+
* and deliberately preserves declaration order for a route descriptor (`dsl.test.ts` pins it). It
|
|
70
|
+
* was a second public `tagKeys` until 25.0.0: two behaviours under one name.
|
|
72
71
|
*/
|
|
73
72
|
export function tagKeys(tags: readonly CacheTag[]): readonly string[] {
|
|
74
73
|
return [...new Set(serializeTags(tags))].sort();
|
package/src/tiers.ts
CHANGED
|
@@ -4,12 +4,11 @@
|
|
|
4
4
|
// sites. Order is data, not control flow.
|
|
5
5
|
|
|
6
6
|
import type { CacheTierName, Clock, Scheduler } from '@ultimat3/core';
|
|
7
|
-
import { CACHE_TIERS, systemClock } from '@ultimat3/core';
|
|
7
|
+
import { CACHE_TIERS, singleFlight, systemClock } from '@ultimat3/core';
|
|
8
8
|
import { CacheJitterInvalidError, CacheLimitInvalidError, CacheTtlInvalidError } from './errors';
|
|
9
9
|
import type { CacheFence, FenceScope } from './fence';
|
|
10
10
|
import { markInvalidated, sampleFence } from './fence';
|
|
11
11
|
import { mergeSetOptions, tagsAddedSince, ttlOptionsFor } from './set-options';
|
|
12
|
-
import { createSingleFlight } from './single-flight';
|
|
13
12
|
import type { CacheTag } from './tags';
|
|
14
13
|
import { bestEffort } from './tier-failures';
|
|
15
14
|
import type { TierFence, TierFences } from './tier-fence';
|
|
@@ -24,14 +23,14 @@ import { sampleTierFences, verdictOf } from './tier-fence';
|
|
|
24
23
|
*/
|
|
25
24
|
export type TierName = CacheTierName;
|
|
26
25
|
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
// Read order is core's `CACHE_TIERS`: index in that array is the tier's distance from the request.
|
|
27
|
+
// Imported, never aliased under a second name here (`X_HELPER_COPY`).
|
|
29
28
|
|
|
30
29
|
/**
|
|
31
30
|
* Who a swallowed refusal is attributed to in `recentTierFailures()` and the `/_x` panel: every
|
|
32
31
|
* rung of the ladder, plus a store that degrades the same way without being on it.
|
|
33
32
|
*
|
|
34
|
-
* Closed rather than a free-form string, and NOT a widening of `TierName`: `
|
|
33
|
+
* Closed rather than a free-form string, and NOT a widening of `TierName`: `CACHE_TIERS` is the
|
|
35
34
|
* ladder and a name missing from it sorts to `-1`, ahead of the request memo. A label is a log
|
|
36
35
|
* facet; a `TierName` is a position. Two spellings of one store is a panel nobody can group.
|
|
37
36
|
*
|
|
@@ -220,9 +219,9 @@ export function isExpired<T>(entry: CacheEntry<T>, at: number): boolean {
|
|
|
220
219
|
return entry.expiresAt !== undefined && entry.expiresAt <= at;
|
|
221
220
|
}
|
|
222
221
|
|
|
223
|
-
/** Sorts tiers into `
|
|
222
|
+
/** Sorts tiers into `CACHE_TIERS` order so registration order cannot change read semantics. */
|
|
224
223
|
export function sortTiers(tiers: readonly CacheTier[]): readonly CacheTier[] {
|
|
225
|
-
return [...tiers].sort((a, b) =>
|
|
224
|
+
return [...tiers].sort((a, b) => CACHE_TIERS.indexOf(a.name) - CACHE_TIERS.indexOf(b.name));
|
|
226
225
|
}
|
|
227
226
|
|
|
228
227
|
/**
|
|
@@ -254,7 +253,7 @@ export interface CacheStackOptions {
|
|
|
254
253
|
readonly schedule?: Scheduler;
|
|
255
254
|
}
|
|
256
255
|
|
|
257
|
-
export function
|
|
256
|
+
export function cacheStack(
|
|
258
257
|
tiers: readonly CacheTier[],
|
|
259
258
|
options: CacheStackOptions = {},
|
|
260
259
|
): CacheStack {
|
|
@@ -267,14 +266,14 @@ export function createCacheStack(
|
|
|
267
266
|
// holding its promise still get whatever it eventually answers. What eviction buys is that the
|
|
268
267
|
// NEXT reader is allowed to try. So the worst case is one duplicate fill, which the ladder's
|
|
269
268
|
// last-write-wins `set` already tolerates, against a key pinned for the life of the process.
|
|
270
|
-
const flight =
|
|
269
|
+
const flight = singleFlight({
|
|
271
270
|
deadlineMs: assertFiniteDurationMs(
|
|
272
271
|
'ladder',
|
|
273
272
|
'loadDeadlineMs',
|
|
274
273
|
options.loadDeadlineMs ?? DEFAULT_LOAD_DEADLINE_MS,
|
|
275
|
-
// Caller-owned: it arrives as a `
|
|
274
|
+
// Caller-owned: it arrives as a `cacheStack({ loadDeadlineMs })` argument, so there is
|
|
276
275
|
// no `app.config.ts` key to send the reader to.
|
|
277
|
-
'the loadDeadlineMs argument to
|
|
276
|
+
'the loadDeadlineMs argument to cacheStack(...)',
|
|
278
277
|
),
|
|
279
278
|
schedule: options.schedule,
|
|
280
279
|
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// The ONE encoding every cache tier stores a value in: JSON, plus tagged `Date`, `bigint`, `Map`
|
|
2
|
+
// and `Set`. One codec is what makes a hit the same shape whichever rung answered it — and since
|
|
3
|
+
// every tier stores TEXT, no two readers ever share one mutable object. Lives here, not in
|
|
4
|
+
// `@ultimat3/query`'s cursor codec: query is tier 3, and this package is tier 1.
|
|
5
|
+
|
|
6
|
+
import { renderThrowable } from '@ultimat3/core';
|
|
7
|
+
import { CacheValueUnencodableError } from './errors';
|
|
8
|
+
|
|
9
|
+
/** The member that marks a tagged value. Only this codec writes it; an app's own is escaped. */
|
|
10
|
+
const TAG = '$x';
|
|
11
|
+
/** `$x`, `$$x`, `$$$x`…: an app key that could be read as a tag, written with one more `$`. */
|
|
12
|
+
const ESCAPABLE = /^\$+x$/;
|
|
13
|
+
/** What an escaped app key looks like on the way back: two or more `$`. */
|
|
14
|
+
const ESCAPED = /^\$\$+x$/;
|
|
15
|
+
const DIGITS = /^-?\d+$/;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The deepest a cached value may nest. `JSON.parse` with a reviver recurses, and overflows the
|
|
19
|
+
* stack somewhere between 5k and 20k levels that `JSON.stringify` wrote without complaint — so a
|
|
20
|
+
* deeper value was stored and then unreadable on every hit until its TTL. Refused at the write.
|
|
21
|
+
*/
|
|
22
|
+
export const MAX_CACHE_VALUE_DEPTH = 512;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The longest `bigint` a cached value may hold, in characters. `BigInt(text)` is super-linear: a
|
|
26
|
+
* million digits throws a `RangeError`, 300k take over a second. 4096 is far past any column type
|
|
27
|
+
* (`numeric` aside) and costs microseconds.
|
|
28
|
+
*/
|
|
29
|
+
export const MAX_CACHE_BIGINT_DIGITS = 4096;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Prefixed to every text this codec writes. No JSON text starts with `x`, so an unmarked entry —
|
|
33
|
+
* a Redis value written by a pod from before the codec — is read with plain `JSON.parse`, and app
|
|
34
|
+
* data that happens to look like a tag (`{"$x":"bigint","v":"12"}`) or an escape (`$$x`) is
|
|
35
|
+
* handed back exactly as it was stored, never revived or renamed.
|
|
36
|
+
*/
|
|
37
|
+
export const CACHE_CODEC_MARK = 'x1:';
|
|
38
|
+
|
|
39
|
+
type Tagged = { readonly [TAG]: 'date' | 'bigint' | 'map' | 'set'; readonly v: unknown };
|
|
40
|
+
|
|
41
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
42
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
43
|
+
|
|
44
|
+
/** A copy with every tag-shaped key given one more `$`, or the object itself when it has none. */
|
|
45
|
+
const escapeKeys = (value: Record<string, unknown>): Record<string, unknown> => {
|
|
46
|
+
const keys = Object.keys(value);
|
|
47
|
+
if (!keys.some((key) => ESCAPABLE.test(key))) return value;
|
|
48
|
+
return Object.fromEntries(keys.map((key) => [ESCAPABLE.test(key) ? `$${key}` : key, value[key]]));
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const unescapeKeys = (value: Record<string, unknown>): Record<string, unknown> => {
|
|
52
|
+
const keys = Object.keys(value);
|
|
53
|
+
if (!keys.some((key) => ESCAPED.test(key))) return value;
|
|
54
|
+
return Object.fromEntries(
|
|
55
|
+
keys.map((key) => [ESCAPED.test(key) ? key.slice(1) : key, value[key]]),
|
|
56
|
+
);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* `this[key]`, not `value`: `JSON.stringify` calls `Date#toJSON` BEFORE the replacer sees it, so
|
|
61
|
+
* the argument is already a string — the holder still has the `Date`.
|
|
62
|
+
*/
|
|
63
|
+
function transform(holder: unknown, key: string, value: unknown): unknown {
|
|
64
|
+
const raw =
|
|
65
|
+
isRecord(holder) || Array.isArray(holder) ? (holder as Record<string, unknown>)[key] : value;
|
|
66
|
+
if (raw instanceof Date) {
|
|
67
|
+
return { [TAG]: 'date', v: Number.isNaN(raw.getTime()) ? null : raw.toISOString() };
|
|
68
|
+
}
|
|
69
|
+
if (typeof raw === 'bigint') {
|
|
70
|
+
const digits = raw.toString();
|
|
71
|
+
if (digits.length > MAX_CACHE_BIGINT_DIGITS) {
|
|
72
|
+
throw new CacheCodecLimit(
|
|
73
|
+
`a bigint of ${digits.length} characters, over the ${MAX_CACHE_BIGINT_DIGITS} a cache holds`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return { [TAG]: 'bigint', v: digits };
|
|
77
|
+
}
|
|
78
|
+
if (raw instanceof Map) return { [TAG]: 'map', v: [...raw.entries()] };
|
|
79
|
+
if (raw instanceof Set) return { [TAG]: 'set', v: [...raw] };
|
|
80
|
+
return isRecord(value) ? escapeKeys(value) : value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A limit this codec refuses at, carried out of `JSON.stringify` to `encodeCacheValue`. */
|
|
84
|
+
class CacheCodecLimit {
|
|
85
|
+
constructor(readonly reason: string) {}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A replacer that knows how deep it is: every container it hands back is filed under its depth,
|
|
90
|
+
* and `JSON.stringify` calls it with that container as `this` for each member — one map lookup
|
|
91
|
+
* per node, no second walk. A cycle never reaches the cap: `JSON.stringify` refuses it first.
|
|
92
|
+
*/
|
|
93
|
+
const replacerWithDepth = (): ((this: unknown, key: string, value: unknown) => unknown) => {
|
|
94
|
+
const depths = new WeakMap<object, number>();
|
|
95
|
+
return function replace(this: unknown, key: string, value: unknown): unknown {
|
|
96
|
+
const out = transform(this, key, value);
|
|
97
|
+
if (typeof out !== 'object' || out === null) return out;
|
|
98
|
+
const depth = (typeof this === 'object' && this !== null ? (depths.get(this) ?? 0) : 0) + 1;
|
|
99
|
+
if (depth > MAX_CACHE_VALUE_DEPTH) {
|
|
100
|
+
throw new CacheCodecLimit(
|
|
101
|
+
`nested deeper than the ${MAX_CACHE_VALUE_DEPTH} levels a cache reads back`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
depths.set(out, depth);
|
|
105
|
+
return out;
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const isEntryList = (value: unknown): value is [unknown, unknown][] =>
|
|
110
|
+
Array.isArray(value) && value.every((entry) => Array.isArray(entry) && entry.length === 2);
|
|
111
|
+
|
|
112
|
+
/** The tagged value back, or `undefined` when `value` is not one this codec wrote. */
|
|
113
|
+
const untag = (value: Record<string, unknown>): { readonly revived: unknown } | undefined => {
|
|
114
|
+
const keys = Object.keys(value);
|
|
115
|
+
if (keys.length !== 2 || !Object.hasOwn(value, TAG) || !Object.hasOwn(value, 'v')) return;
|
|
116
|
+
const { v } = value as Tagged;
|
|
117
|
+
switch (value[TAG]) {
|
|
118
|
+
case 'date':
|
|
119
|
+
return v === null || typeof v === 'string'
|
|
120
|
+
? { revived: new Date(v ?? Number.NaN) }
|
|
121
|
+
: undefined;
|
|
122
|
+
case 'bigint':
|
|
123
|
+
// Length first: text past the cap was not written by `encodeCacheValue`, and `BigInt` on it
|
|
124
|
+
// is the expensive call.
|
|
125
|
+
return typeof v === 'string' && v.length <= MAX_CACHE_BIGINT_DIGITS && DIGITS.test(v)
|
|
126
|
+
? { revived: BigInt(v) }
|
|
127
|
+
: undefined;
|
|
128
|
+
case 'map':
|
|
129
|
+
return isEntryList(v) ? { revived: new Map(v) } : undefined;
|
|
130
|
+
case 'set':
|
|
131
|
+
return Array.isArray(v) ? { revived: new Set(v) } : undefined;
|
|
132
|
+
default:
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/** Bottom-up, so a tagged value's members are already revived when the tag itself is read. */
|
|
138
|
+
const revive = (_key: string, value: unknown): unknown => {
|
|
139
|
+
if (!isRecord(value)) return value;
|
|
140
|
+
const tagged = untag(value);
|
|
141
|
+
return tagged === undefined ? unescapeKeys(value) : tagged.revived;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* `payload` as the text a tier stores, marked with `CACHE_CODEC_MARK`. A cycle, a throwing
|
|
146
|
+
* `toJSON`, nesting past `MAX_CACHE_VALUE_DEPTH` or a bigint past `MAX_CACHE_BIGINT_DIGITS` is
|
|
147
|
+
* `X_CACHE_VALUE_UNENCODABLE`, naming the key and the tier: what is written is always readable.
|
|
148
|
+
*/
|
|
149
|
+
export function encodeCacheValue(payload: unknown, at: { key: string; tier: string }): string {
|
|
150
|
+
try {
|
|
151
|
+
// `?? 'null'`: a top-level `undefined` or function is no JSON text at all.
|
|
152
|
+
return `${CACHE_CODEC_MARK}${JSON.stringify(payload, replacerWithDepth()) ?? 'null'}`;
|
|
153
|
+
} catch (error) {
|
|
154
|
+
const reason = error instanceof CacheCodecLimit ? error.reason : renderThrowable(error);
|
|
155
|
+
throw new CacheValueUnencodableError({ ...at, reason });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The inverse. Marked text is revived — only an object of exactly `{ $x, v }` with a tag this
|
|
161
|
+
* codec knows. Unmarked text predates the codec and is plain `JSON.parse`. Throws what
|
|
162
|
+
* `JSON.parse` throws; a tier reading text it did not write decides what a corrupt entry means.
|
|
163
|
+
*/
|
|
164
|
+
export function decodeCacheValue(text: string): unknown {
|
|
165
|
+
if (!text.startsWith(CACHE_CODEC_MARK)) return JSON.parse(text);
|
|
166
|
+
return JSON.parse(text.slice(CACHE_CODEC_MARK.length), revive);
|
|
167
|
+
}
|
package/src/redis-fake.ts
DELETED
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
// The fake `Bun.redis` both redis test files drive, and the two wire readers they assert with.
|
|
2
|
-
// It is a RECORDER, never an interpreter: it cannot run Lua, so it never pretends to, and every
|
|
3
|
-
// claim about what a script DOES lives in `redis.live.test.ts`. Not exported from `index.ts` —
|
|
4
|
-
// it is a test double shaped around this package's own assertions, not public API.
|
|
5
|
-
|
|
6
|
-
import type { RedisLike, RedisTierOptions } from './redis';
|
|
7
|
-
import { createRedisTier, REDIS_TAG_MEMBER_SCRIPT } from './redis';
|
|
8
|
-
import type { CacheTier } from './tiers';
|
|
9
|
-
|
|
10
|
-
export interface FakeRedis extends RedisLike {
|
|
11
|
-
readonly sent: string[][];
|
|
12
|
-
/**
|
|
13
|
-
* What the server's script answers for one `EVAL`. A test driving a path that READS the reply
|
|
14
|
-
* has to say what came back; there is no default, because `[]` is exactly what a gutted
|
|
15
|
-
* `INVALIDATE_SCRIPT` returns and a silent one would make "the bust cleared nothing" the
|
|
16
|
-
* baseline of both files.
|
|
17
|
-
*/
|
|
18
|
-
answerEval(script: string, reply: unknown): void;
|
|
19
|
-
/** Makes one value key's `DEL` refuse, which is the half of a bust that can fail alone. */
|
|
20
|
-
refuseDel(key: string): void;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function fakeRedis(): FakeRedis {
|
|
24
|
-
const values = new Map<string, string>();
|
|
25
|
-
// The lease `EX` bought, in ms. A fake that answered no `PTTL` could not catch a tier that
|
|
26
|
-
// stopped asking for one — and a hit read back without its remaining life is promoted on the
|
|
27
|
-
// caller's ttl, which is how a value one second from expiry gets a fresh five minutes.
|
|
28
|
-
const expiries = new Map<string, number>();
|
|
29
|
-
const sent: string[][] = [];
|
|
30
|
-
// A fake cannot run Lua. This one used to mirror both script bodies in TypeScript, which is why
|
|
31
|
-
// gutting either to `return 1` / `return {}` left all 517 tests in `cache` + `query` green — the
|
|
32
|
-
// assertions ran against the mirror and the script itself was executed by nothing, ever. What is
|
|
33
|
-
// left is a recorder: the wire traffic is what those files assert, the script body is opaque to
|
|
34
|
-
// them, and every claim about what a script DOES lives behind TEST_REDIS_URL.
|
|
35
|
-
const evalReplies = new Map<string, unknown>([
|
|
36
|
-
// Nothing reads the tag-join's reply, so a constant here asserts nothing about the script.
|
|
37
|
-
[REDIS_TAG_MEMBER_SCRIPT, 1],
|
|
38
|
-
]);
|
|
39
|
-
const refused = new Set<string>();
|
|
40
|
-
return {
|
|
41
|
-
sent,
|
|
42
|
-
answerEval(script, reply) {
|
|
43
|
-
evalReplies.set(script, reply);
|
|
44
|
-
},
|
|
45
|
-
refuseDel(key) {
|
|
46
|
-
refused.add(key);
|
|
47
|
-
},
|
|
48
|
-
get(key) {
|
|
49
|
-
return Promise.resolve(values.get(key) ?? null);
|
|
50
|
-
},
|
|
51
|
-
set(key, value) {
|
|
52
|
-
values.set(key, value);
|
|
53
|
-
return Promise.resolve('OK');
|
|
54
|
-
},
|
|
55
|
-
send(command, args) {
|
|
56
|
-
sent.push([command, ...args]);
|
|
57
|
-
if (command === 'SET') {
|
|
58
|
-
values.set(String(args[0]), String(args[1]));
|
|
59
|
-
if (args[2] === 'EX') expiries.set(String(args[0]), Number(args[3]) * 1_000);
|
|
60
|
-
if (args[2] === 'PX') expiries.set(String(args[0]), Number(args[3]));
|
|
61
|
-
return Promise.resolve('OK');
|
|
62
|
-
}
|
|
63
|
-
if (command === 'PTTL') {
|
|
64
|
-
const key = String(args[0]);
|
|
65
|
-
if (!values.has(key)) return Promise.resolve(-2);
|
|
66
|
-
return Promise.resolve(expiries.get(key) ?? -1);
|
|
67
|
-
}
|
|
68
|
-
if (command === 'DEL') {
|
|
69
|
-
if (refused.has(String(args[0]))) {
|
|
70
|
-
return Promise.reject(new Error(`redis refused DEL ${String(args[0])}`));
|
|
71
|
-
}
|
|
72
|
-
values.delete(String(args[0]));
|
|
73
|
-
expiries.delete(String(args[0]));
|
|
74
|
-
return Promise.resolve(1);
|
|
75
|
-
}
|
|
76
|
-
if (command === 'EVAL') {
|
|
77
|
-
const script = String(args[0]);
|
|
78
|
-
if (!evalReplies.has(script)) {
|
|
79
|
-
// Loud rather than `[]`: an empty member list is what the gutted script answers, so a
|
|
80
|
-
// default would report every bust in these files as clean and every one of them green.
|
|
81
|
-
throw new Error(
|
|
82
|
-
'fake redis cannot execute EVAL — call answerEval(script, reply) to state what the ' +
|
|
83
|
-
'server returned, or move the claim to redis.live.test.ts, which runs the script',
|
|
84
|
-
);
|
|
85
|
-
}
|
|
86
|
-
return Promise.resolve(evalReplies.get(script));
|
|
87
|
-
}
|
|
88
|
-
return Promise.resolve(null);
|
|
89
|
-
},
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* `buildId: null` and `rng: () => 0` are the two things a wire assertion needs pinned: the
|
|
95
|
-
* namespace carries the build id by default, and the lease is spread by default.
|
|
96
|
-
*/
|
|
97
|
-
export function tierFor(client: RedisLike, extra: RedisTierOptions = {}): CacheTier {
|
|
98
|
-
return createRedisTier({ client, buildId: null, rng: () => 0, ...extra });
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** The `{...}` hash tag of a key, which is what Redis Cluster hashes to a slot. */
|
|
102
|
-
export function slotTokenOf(key: string): string {
|
|
103
|
-
return /\{([^}]*)\}/.exec(key)?.[1] ?? key;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Every key argument of one command — `EVAL script numkeys k1 .. kN`, `DEL key` and
|
|
108
|
-
* `SREM key member..`, whose members are values rather than keys and so hash to nothing.
|
|
109
|
-
*/
|
|
110
|
-
export function keysOf(command: readonly string[]): string[] {
|
|
111
|
-
if (command[0] === 'EVAL') return command.slice(3, 3 + Number(command[2]));
|
|
112
|
-
if (command[0] === 'DEL') return command.slice(1);
|
|
113
|
-
if (command[0] === 'SREM' || command[0] === 'SISMEMBER') return command.slice(1, 2);
|
|
114
|
-
return [];
|
|
115
|
-
}
|
package/src/single-flight.ts
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
// N concurrent misses on one key are ONE origin load. The mechanism is `@ultimat3/core`'s — this
|
|
2
|
-
// file's shape verbatim, one tier down, plus identity-checked eviction and an optional injected
|
|
3
|
-
// deadline — because four packages each grew their own deduper and only copies can drift. This
|
|
4
|
-
// file stays as the door `@ultimat3/cache` has always published it through, so no caller moves.
|
|
5
|
-
// `@ultimat3/realtime`'s `entry.reading` is the same shape one tier UP, adopting the same door.
|
|
6
|
-
|
|
7
|
-
export type { FlightJoin, SingleFlight } from '@ultimat3/core';
|
|
8
|
-
export { createSingleFlight } from '@ultimat3/core';
|