@ultimat3/cache 24.0.0 → 25.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/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** (`TIER_ORDER`, what `/_x`
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 (`createCacheStack.read` only). A
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 `TIER_ORDER`, never registration order. `sortTiers()` enforces it.
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` and `TIER_ORDER` IS `CACHE_TIERS` (asserted by identity in
42
- `tier-vocabulary.test.ts`). Adding a rung is an edit to `packages/core/src/cache-vocabulary.ts`
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
- `createCacheStack` routes every `get`/`set`/`del` through `bestEffort()` into
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. **Known collision:** `@ultimat3/render` exports its own
58
- `tagKeys` (`render/src/route.ts`) with different behaviour; consolidating is render's call.
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/dev-cache.ts` calls) and
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 `createMemorySemanticCache.remember`
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
- - **`createCacheStack` is the production read path, and `@ultimat3/query`'s `readThrough` is its
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
- - **`createCacheStack` shares one in-flight `load()` per key** through `single-flight.ts`, which is
79
- the door onto `@ultimat3/core`'s `createSingleFlight` (pinned by identity in
80
- `single-flight.test.ts`). A rejected load clears its entry. One `SingleFlight` per stack.
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,7 @@ 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
- - **`CacheTier.set` REJECTS, never throws synchronously** — `createLruTier`/`createMemoTier` are
98
+ - **`CacheTier.set` REJECTS, never throws synchronously** — `lruTier`/`memoTier` are
99
99
  `async` for that alone.
100
100
  - **`redis.ts`'s script deletes NOTHING — it reads.** It returns the members; the tier `DEL`s them
101
101
  client-side one key at a time (slot-local on Cluster/Dragonfly) and `SREM`s only what
@@ -148,7 +148,7 @@ Tier 1. Tagged caching + THE invalidation graph.
148
148
  |---|---|
149
149
  | `tags.ts` | `tag` factory, wire form, match semantics, `tagKeys`, declared-tag registry |
150
150
  | `graph.ts` | tag → dependents (cache keys, ISR routes, CDN paths, live queries) |
151
- | `tiers.ts` | `CacheTier`, `TIER_ORDER`, `TierLabel`, `assertTtl`, read-through stack |
151
+ | `tiers.ts` | `CacheTier`, `TierLabel`, `assertTtl`, read-through stack |
152
152
  | `fence.ts` | the invalidation fence a fill (here or in `query`) checks before it publishes |
153
153
  | `tier-fence.ts` | `TierFence`/`FenceVerdict`, and how a fill samples and asks a shared tier's fence |
154
154
  | `redis-fence.ts` | the leased generation keys a bust writes and a fill watches |
@@ -157,7 +157,6 @@ Tier 1. Tagged caching + THE invalidation graph.
157
157
  | `memo.ts` | request memo over the ALS ctx (WeakMap, no lifecycle) |
158
158
  | `lru.ts` | byte-budgeted LRU (linked list + map + tag index) |
159
159
  | `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
160
  | `cdn.ts` | `Cache-Control`/`Surrogate-Key` emission, `surrogateKeys`, the purge key list, the `PurgeDriver` seam |
162
161
  | `purge-http.ts` | the HTTP half both remote drivers share: one POST, batching, key guard, and core's retryable table re-exported |
163
162
  | `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 `TIER_ORDER`, not registration order.
35
+ without touching a call site — order comes from `@ultimat3/core`'s `CACHE_TIERS`, not registration order.
36
36
 
37
37
  ```ts
38
- import { createCacheStack, createLruTier, createMemoTier, registerTier } from '@ultimat3/cache';
38
+ import { cacheStack, lruTier, memoTier, registerTier } from '@ultimat3/cache';
39
39
 
40
- const stack = createCacheStack([createMemoTier(), createLruTier({ maxBytes: 64 * 1024 * 1024 })]);
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(), {
@@ -58,9 +58,9 @@ called. 40,000 keys warmed by one rolling restart otherwise share one expiry ins
58
58
  inside the same 30-second window. The roll is injected, never `Math.random()` at a call site:
59
59
 
60
60
  ```ts
61
- createLruTier({ rng: () => 0 }); // the full lease — what a test asserting an exact expiry wants
62
- createRedisTier({ jitterFraction: 0 }); // off entirely
63
- createRedisTier({ jitterFraction: 0.2 }); // a wider spread; outside [0, 1) is X_CACHE_JITTER_INVALID
61
+ lruTier({ rng: () => 0 }); // the full lease — what a test asserting an exact expiry wants
62
+ redisTier({ jitterFraction: 0 }); // off entirely
63
+ redisTier({ jitterFraction: 0.2 }); // a wider spread; outside [0, 1) is X_CACHE_JITTER_INVALID
64
64
  ```
65
65
 
66
66
  **N concurrent misses are ONE origin load.** `stack.read` shares an in-flight `load()` per key, so
@@ -68,13 +68,13 @@ a reader arriving while another's load is running joins it instead of issuing it
68
68
  ends as the load settles, rejection included, so one failure is never held as a permanent one. A
69
69
  feed cached for 60s and read 8,000×/s otherwise sends ~1,600 identical queries to Postgres at every
70
70
  TTL boundary, because the write only lands after `load()` resolves. The primitive is
71
- `createSingleFlight()` if you need it elsewhere — `@ultimat3/core`'s, re-exported here unchanged;
72
- the stack holds one per stack.
71
+ `singleFlight()` from `@ultimat3/core` if you need it elsewhere (25.0.0 dropped this
72
+ package's re-export); the stack holds one per stack.
73
73
  A `load()` that never settles does **not** hold its key for ever: past `loadDeadlineMs`
74
74
  (`DEFAULT_LOAD_DEADLINE_MS`, 30s — the point at which `http.requestTimeoutMs` already abandoned the
75
75
  request that was waiting for it) the key is freed and the next reader loads for itself. Eviction
76
76
  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 — `createCacheStack(tiers, { loadDeadlineMs: 5_000 })` for a
77
+ and the cost is one duplicate fill — `cacheStack(tiers, { loadDeadlineMs: 5_000 })` for a
78
78
  tighter ceiling on a fast origin. A joiner shares the
79
79
  leader's **write** as well as its load, so it contributes to it: tags union, TTLs take the shortest.
80
80
  Without that the entry landed carrying only the leader's tags and the joiner's invalidation never
@@ -96,7 +96,7 @@ if (fence.isValid()) await tier.set(key, value, { tags });
96
96
  **That fence is this process's; the Redis tier keeps a second one for the fleet.** A bust another
97
97
  replica ran reaches this process only when the broadcast does, and a lost broadcast means never —
98
98
  so the fill would publish the pre-write rows into the *shared* tier and every replica would promote
99
- them. `createRedisTier` implements `CacheTier.fence`: a bust writes a leased generation
99
+ them. `redisTier` implements `CacheTier.fence`: a bust writes a leased generation
100
100
  (`<ns>:g:{entity}:r:<id>`, `:c`, `:a`) **before** it reads its buckets, `stack.read` samples the
101
101
  generations its tags watch before `load()`, and re-reads them after the tier's `SET`.
102
102
 
@@ -222,7 +222,7 @@ Redis cannot read each other's payloads. Rename `PostView.author` to `PostView.a
222
222
  deploy: `JSON.parse` does not validate, so the old pod reads the new shape back and hands it to a
223
223
  renderer expecting the old one — an undefined author on every cached post, on half the fleet, for
224
224
  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 `createRedisTier({ buildId: null })` if you version
225
+ which is the cheaper of the two. Opt out with `redisTier({ buildId: null })` if you version
226
226
  your own payloads.
227
227
 
228
228
  ## Invalidating
@@ -382,11 +382,11 @@ rather than failing the write that triggered it.
382
382
  ## Semantic cache
383
383
 
384
384
  For LLM calls, where "list my orders" and "show me my orders" must hit the same entry.
385
- `createMemorySemanticCache()` does cosine similarity at a 0.92 threshold (tight on
385
+ `memorySemanticCache()` does cosine similarity at a 0.92 threshold (tight on
386
386
  purpose — a false hit answers the wrong question, which is worse than a miss) and is the
387
387
  only backing this package ships — it is O(n) and in-process. The interface (`SemanticCache`)
388
388
  is a driver seam for that reason; a Postgres/pgvector-backed implementation does not exist
389
- yet here (`@ultimat3/ai`'s `PgVectorStore` is a separate store, for RAG retrieval, not this
389
+ yet here (`@ultimat3/ai`'s `postgresVectorStore()` is a separate store, for RAG retrieval, not this
390
390
  cache).
391
391
 
392
392
  ## Errors
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cache",
3
- "version": "24.0.0",
3
+ "version": "25.0.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.0"
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": "24.0.0"
35
+ "@ultimat3/core": "25.0.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 `createCdnTier` — the caller that
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 createCdnTier(options: CdnTierOptions = {}): CacheTier {
143
+ export function cdnTier(options: CdnTierOptions = {}): CacheTier {
144
144
  const driver = options.purge ?? noopPurgeDriver();
145
145
 
146
146
  return {
package/src/errors.ts CHANGED
@@ -108,7 +108,7 @@ export class CacheLimitInvalidError extends UltimateError {
108
108
  expected: string;
109
109
  /**
110
110
  * Where the value came FROM, when that is not an `app.config.ts` key. `loadDeadlineMs` and the
111
- * similarity override arrive as call arguments (`createCacheStack`, `lookup`), so telling their
111
+ * similarity override arrive as call arguments (`cacheStack`, `lookup`), so telling their
112
112
  * caller to edit `app.config.ts` names a key that does not exist.
113
113
  */
114
114
  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
- createCdnTier,
6
+ cdnTier,
7
7
  isNoopPurgeDriver,
8
8
  noopPurgeDriver,
9
9
  surrogateKeys,
@@ -53,8 +53,8 @@ export {
53
53
  } from './invalidate';
54
54
  export type { LruOptions, LruStats } from './lru';
55
55
 
56
- export { createLruTier, estimateBytes, LruCache } from './lru';
57
- export { clearMemo, createMemoTier, memoSize } from './memo';
56
+ export { estimateBytes, LruCache, lruTier } from './lru';
57
+ export { clearMemo, memoSize, memoTier } from './memo';
58
58
  export type { CloudflarePurgeOptions } from './purge-cloudflare';
59
59
  export {
60
60
  CLOUDFLARE_API_URL,
@@ -69,10 +69,10 @@ export type { PurgeFetch } from './purge-http';
69
69
  export { DEFAULT_PURGE_TIMEOUT_MS } from './purge-http';
70
70
  export type { RedisLike, RedisTierOptions } from './redis';
71
71
  export {
72
- createRedisTier,
73
72
  namespaceFor,
74
73
  REDIS_INVALIDATE_SCRIPT,
75
74
  REDIS_TAG_MEMBER_SCRIPT,
75
+ redisTier,
76
76
  } from './redis';
77
77
  export type {
78
78
  Embedding,
@@ -81,9 +81,7 @@ export type {
81
81
  SemanticHit,
82
82
  SemanticRememberOptions,
83
83
  } from './semantic';
84
- export { cosineSimilarity, createMemorySemanticCache } from './semantic';
85
- export type { FlightJoin, SingleFlight } from './single-flight';
86
- export { createSingleFlight } from './single-flight';
84
+ export { cosineSimilarity, memorySemanticCache } from './semantic';
87
85
  export type { CacheTag, CacheTagRegistry, TagFactory } from './tags';
88
86
  export {
89
87
  assertKnownTags,
@@ -121,11 +119,10 @@ export {
121
119
  assertFiniteDurationMs,
122
120
  assertFiniteSimilarityFloor,
123
121
  assertTtl,
124
- createCacheStack,
122
+ cacheStack,
125
123
  DEFAULT_LOAD_DEADLINE_MS,
126
124
  DEFAULT_TTL_JITTER_FRACTION,
127
125
  isExpired,
128
126
  nowMs,
129
127
  sortTiers,
130
- TIER_ORDER,
131
128
  } 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 { currentSpan, logger, renderThrowable, systemClock, withSpan } from '@ultimat3/core';
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, TIER_ORDER } from './tiers';
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) => TIER_ORDER.indexOf(a.tier) - TIER_ORDER.indexOf(b.tier));
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
@@ -273,7 +273,7 @@ export class LruCache {
273
273
  }
274
274
  }
275
275
 
276
- export function createLruTier(options: LruOptions = {}): CacheTier & { readonly cache: LruCache } {
276
+ export function lruTier(options: LruOptions = {}): CacheTier & { readonly cache: LruCache } {
277
277
  const cache = new LruCache(options);
278
278
  return {
279
279
  name: 'lru',
package/src/memo.ts CHANGED
@@ -46,7 +46,7 @@ export function memoSize(): number {
46
46
  return storeFor(false)?.size ?? 0;
47
47
  }
48
48
 
49
- export function createMemoTier(): CacheTier {
49
+ export function memoTier(): CacheTier {
50
50
  return {
51
51
  name: 'request-memo',
52
52
 
@@ -61,14 +61,14 @@ export function createMemoTier(): CacheTier {
61
61
  *
62
62
  * This tier holds nothing past the request, so it stores no `expiresAt` — but it is still a
63
63
  * 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: `createCacheStack`
64
+ * it made `ttlMs: 0` a value the memo accepted and every other tier refused: `cacheStack`
65
65
  * routes each rung through `bestEffort`, so the miswiring was swallowed as two tier failures
66
66
  * and the read still hit — out of the one tier that never should have taken it. Exactly the
67
67
  * "two tiers, two readings of `0`" the rule exists to close.
68
68
  *
69
69
  * Only a lease the caller SUPPLIED is checked: there is no default to fall back to, because a
70
70
  * memo entry that outlives its request is not a thing that can happen. `jitterFraction: 0` for
71
- * `createMemorySemanticCache`'s reason — spreading a lease is a herd defence for a SHARED
71
+ * `memorySemanticCache`'s reason — spreading a lease is a herd defence for a SHARED
72
72
  * store, and this one dies with the request that made it.
73
73
  */
74
74
  async set<T>(key: string, value: T, options?: CacheSetOptions): Promise<void> {
@@ -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,
@@ -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
- // Re-exported rather than imported twice, so both purge drivers still read "what a failure means"
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
@@ -191,7 +191,7 @@ function raiseSweepFailure(failures: readonly unknown[], attempted: number): nev
191
191
  });
192
192
  }
193
193
 
194
- export function createRedisTier(options: RedisTierOptions = {}): CacheTier {
194
+ export function redisTier(options: RedisTierOptions = {}): CacheTier {
195
195
  const ns = namespaceFor(options.prefix ?? 'x', options.buildId);
196
196
  const defaultTtlMs = assertFiniteDurationMs(
197
197
  'redis',
package/src/semantic.ts CHANGED
@@ -78,7 +78,7 @@ interface SemanticRecord {
78
78
  readonly tags: readonly CacheTag[];
79
79
  }
80
80
 
81
- export function createMemorySemanticCache(options: SemanticCacheOptions = {}): SemanticCache {
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 the same function as `@ultimat3/render`'s same-named `tagKeys`, which is `serializeTags`
69
- * over an optional list and deliberately preserves declaration order for a route descriptor
70
- * (`dsl.test.ts` pins it). Two behaviours under one name, in two packages an app imports together:
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
- /** Read order. Index in this array is the tier's distance from the request. */
28
- export const TIER_ORDER: readonly TierName[] = CACHE_TIERS;
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`: `TIER_ORDER` is the
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 `TIER_ORDER` so registration order cannot change read semantics. */
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) => TIER_ORDER.indexOf(a.name) - TIER_ORDER.indexOf(b.name));
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 createCacheStack(
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 = createSingleFlight({
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 `createCacheStack({ loadDeadlineMs })` argument, so there is
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 createCacheStack(...)',
276
+ 'the loadDeadlineMs argument to cacheStack(...)',
278
277
  ),
279
278
  schedule: options.schedule,
280
279
  });
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
- }
@@ -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';