@ultimat3/cache 16.0.0 → 17.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 +14 -0
- package/package.json +2 -2
- package/src/errors.ts +52 -0
- package/src/index.ts +4 -0
- package/src/lru.ts +14 -3
- package/src/purge-cloudflare.ts +6 -1
- package/src/purge-fastly.ts +6 -1
- package/src/purge-http.ts +37 -8
- package/src/redis.ts +6 -2
- package/src/semantic.ts +22 -5
- package/src/tiers.ts +64 -2
package/CLAUDE.md
CHANGED
|
@@ -10,6 +10,20 @@ Tier 1. Tagged caching + THE invalidation graph.
|
|
|
10
10
|
|
|
11
11
|
## Rules
|
|
12
12
|
|
|
13
|
+
- **A tier's own LIMIT is screened where `app.config.ts` names it, `As of 2026-08-26`** —
|
|
14
|
+
`assertFiniteCapacity` for a byte budget or an entry ceiling, `assertFiniteDurationMs` for a
|
|
15
|
+
request budget or a `defaultTtlMs`, `assertFiniteSimilarityFloor` for the semantic tier's
|
|
16
|
+
threshold — all three beside `assertTtl` in `tiers.ts`, and all three raising one code,
|
|
17
|
+
`X_CACHE_LIMIT_INVALID`. Not a nicety: `Number(process.env.CACHE_MAX_BYTES)` on an unset variable
|
|
18
|
+
is `NaN`, `NaN` is not nullish so `??` keeps it, and every comparison against it is FALSE —
|
|
19
|
+
`while (bytes > maxBytes)` never evicted, so the bounded cache this package is built on had no
|
|
20
|
+
bound at all, and `similarity < floor` never skipped, so the semantic tier answered the nearest
|
|
21
|
+
thing it held to a question nobody asked. The failure is never a wrong number; it is the guard
|
|
22
|
+
switching itself off. Never `Math.max(1, x)` in front of one: that is a clamp, not a validator.
|
|
23
|
+
**The `Finite` in all three names is load-bearing** — `bun run finite-bounds` recognises a repair
|
|
24
|
+
by the shape of the CALL, so while they were spelled `assertCapacity` / `assertTimeoutMs` all
|
|
25
|
+
nine of this package's options read as unchecked to the ratchet while every one was screened.
|
|
26
|
+
|
|
13
27
|
- `invalidateTags()` in `invalidate.ts` is the ONLY fan-out path. Never call
|
|
14
28
|
`tier.invalidateTags()` from outside it. It is also the only place the log is written:
|
|
15
29
|
`recentInvalidations()` is a read of what that one path already reported, never a second
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cache",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "17.0.0",
|
|
4
4
|
"description": "Tagged caching: request memo, LRU, Redis, CDN — one invalidation graph",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,6 +31,6 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
34
|
+
"@ultimat3/core": "17.0.0"
|
|
35
35
|
}
|
|
36
36
|
}
|
package/src/errors.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { registerErrorCodes, UltimateError } from '@ultimat3/core';
|
|
|
6
6
|
export const CACHE_OWNED_ERROR_CODES = [
|
|
7
7
|
'X_CACHE_DRIVER_UNAVAILABLE',
|
|
8
8
|
'X_CACHE_JITTER_INVALID',
|
|
9
|
+
'X_CACHE_LIMIT_INVALID',
|
|
9
10
|
'X_CACHE_PURGE_FAILED',
|
|
10
11
|
'X_CACHE_TAG_UNKNOWN',
|
|
11
12
|
'X_CACHE_TOO_LARGE',
|
|
@@ -21,6 +22,7 @@ export type CacheErrorCode = (typeof CACHE_ERROR_CODES)[number];
|
|
|
21
22
|
export const CACHE_ERROR_TITLES: Readonly<Record<CacheOwnedErrorCode, string>> = {
|
|
22
23
|
X_CACHE_DRIVER_UNAVAILABLE: "a tier's backing store is missing",
|
|
23
24
|
X_CACHE_JITTER_INVALID: 'a TTL jitter fraction outside [0, 1)',
|
|
25
|
+
X_CACHE_LIMIT_INVALID: 'a cache ceiling, similarity floor or timeout that is not a usable number',
|
|
24
26
|
X_CACHE_PURGE_FAILED: 'the CDN refused a purge',
|
|
25
27
|
X_CACHE_TAG_UNKNOWN: 'a tag no entity declared',
|
|
26
28
|
X_CACHE_TOO_LARGE: "one entry exceeds the tier's byte budget",
|
|
@@ -71,6 +73,56 @@ export class CacheTooLargeError extends UltimateError {
|
|
|
71
73
|
}
|
|
72
74
|
}
|
|
73
75
|
|
|
76
|
+
/**
|
|
77
|
+
* A LIMIT this package runs on that is not one — a tier's byte budget, its entry ceiling, the
|
|
78
|
+
* similarity floor the semantic tier decides "same question" with, or a purge driver's request
|
|
79
|
+
* timeout. Sibling of the TTL refusal below, over the knobs an `app.config.ts` carries and
|
|
80
|
+
* `CacheTooLargeError`'s `fix:` line already tells an operator to raise.
|
|
81
|
+
*
|
|
82
|
+
* `Number(process.env.CACHE_MAX_BYTES)` on an unset variable is `NaN`, `NaN` is not nullish so
|
|
83
|
+
* `??` passes it through, and every comparison it reaches then answers false. The failure is never
|
|
84
|
+
* a wrong number, it is the guard turning itself off — `while (bytes > NaN)` never evicts, so the
|
|
85
|
+
* cache has no ceiling at all, and `similarity < NaN` never skips, so a semantic lookup answers
|
|
86
|
+
* the nearest thing it holds to a question nobody asked. Refused where the value is still
|
|
87
|
+
* nameable, exactly as `assertTtl` does.
|
|
88
|
+
*/
|
|
89
|
+
/**
|
|
90
|
+
* The mechanism is stated ONLY for `NaN`, and that is deliberate. This cause used to say "a
|
|
91
|
+
* comparison against this one is false for every entry" for every rejected value, which is true of
|
|
92
|
+
* `NaN` and false of everything else it also receives: `Infinity`, `-Infinity`, `0`, a fraction, an
|
|
93
|
+
* unsafe integer. A ceiling of `Infinity` is never *exceeded* and a similarity floor of `Infinity`
|
|
94
|
+
* is never *met* — opposite outcomes, from one sentence claiming both. A cause that names the wrong
|
|
95
|
+
* failure sends the reader to the wrong place, which is the failure mode axiom 4 exists to prevent,
|
|
96
|
+
* so the shared half says only what is true of every value and the `NaN` half is added when it is.
|
|
97
|
+
*/
|
|
98
|
+
const limitEffect = (value: number): string =>
|
|
99
|
+
Number.isNaN(value)
|
|
100
|
+
? ', and every comparison against NaN is false, so the limit stops being enforced rather than being enforced wrongly — NaN is what Number(process.env.…) answers for an unset variable, and it is not nullish, so `??` does not catch it'
|
|
101
|
+
: ', and it is applied as given, so the tier is bounded at a value nobody chose';
|
|
102
|
+
|
|
103
|
+
export class CacheLimitInvalidError extends UltimateError {
|
|
104
|
+
constructor(input: {
|
|
105
|
+
option: string;
|
|
106
|
+
value: number;
|
|
107
|
+
tier: string;
|
|
108
|
+
expected: string;
|
|
109
|
+
/**
|
|
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
|
|
112
|
+
* caller to edit `app.config.ts` names a key that does not exist.
|
|
113
|
+
*/
|
|
114
|
+
source?: string | undefined;
|
|
115
|
+
}) {
|
|
116
|
+
const where = input.source ?? `${input.tier}.${input.option} in app.config.ts`;
|
|
117
|
+
super({
|
|
118
|
+
code: 'X_CACHE_LIMIT_INVALID',
|
|
119
|
+
cause: `the ${input.tier} tier was given ${input.option}=${String(input.value)}; it must be ${input.expected}${limitEffect(input.value)}`,
|
|
120
|
+
fix: `set ${where} to ${input.expected} — and if it comes from the environment, parse it first: Number(process.env.CACHE_LIMIT) is NaN when the variable is unset`,
|
|
121
|
+
meta: { option: input.option, value: String(input.value), tier: input.tier },
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
74
126
|
/**
|
|
75
127
|
* A `ttlMs` that is not a positive, finite number of milliseconds.
|
|
76
128
|
*
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export {
|
|
|
8
8
|
CACHE_ERROR_TITLES,
|
|
9
9
|
CacheDriverUnavailableError,
|
|
10
10
|
CacheJitterInvalidError,
|
|
11
|
+
CacheLimitInvalidError,
|
|
11
12
|
CachePurgeFailedError,
|
|
12
13
|
CacheTagUnknownError,
|
|
13
14
|
CacheTooLargeError,
|
|
@@ -109,6 +110,9 @@ export type {
|
|
|
109
110
|
TtlScope,
|
|
110
111
|
} from './tiers';
|
|
111
112
|
export {
|
|
113
|
+
assertFiniteCapacity,
|
|
114
|
+
assertFiniteDurationMs,
|
|
115
|
+
assertFiniteSimilarityFloor,
|
|
112
116
|
assertTtl,
|
|
113
117
|
createCacheStack,
|
|
114
118
|
DEFAULT_LOAD_DEADLINE_MS,
|
package/src/lru.ts
CHANGED
|
@@ -16,7 +16,7 @@ import type {
|
|
|
16
16
|
TierInvalidation,
|
|
17
17
|
TtlJitter,
|
|
18
18
|
} from './tiers';
|
|
19
|
-
import { assertTtl, nowMs } from './tiers';
|
|
19
|
+
import { assertFiniteCapacity, assertFiniteDurationMs, assertTtl, nowMs } from './tiers';
|
|
20
20
|
|
|
21
21
|
export interface LruOptions {
|
|
22
22
|
/** Byte budget for the whole tier. Default 64 MiB. */
|
|
@@ -81,6 +81,9 @@ export interface LruStats {
|
|
|
81
81
|
readonly evictions: number;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/** 64 MiB. Named because the constructor now screens it, and a screen needs something to pass. */
|
|
85
|
+
const DEFAULT_LRU_MAX_BYTES = 64 * 1024 * 1024;
|
|
86
|
+
|
|
84
87
|
export class LruCache {
|
|
85
88
|
private readonly map = new Map<string, LruNode>();
|
|
86
89
|
private readonly tagIndex = new Map<string, Set<string>>();
|
|
@@ -97,8 +100,16 @@ export class LruCache {
|
|
|
97
100
|
private evictions = 0;
|
|
98
101
|
|
|
99
102
|
constructor(options: LruOptions = {}) {
|
|
100
|
-
this.maxBytes =
|
|
101
|
-
|
|
103
|
+
this.maxBytes = assertFiniteCapacity(
|
|
104
|
+
'lru',
|
|
105
|
+
'maxBytes',
|
|
106
|
+
options.maxBytes ?? DEFAULT_LRU_MAX_BYTES,
|
|
107
|
+
);
|
|
108
|
+
this.defaultTtlMs = assertFiniteDurationMs(
|
|
109
|
+
'lru',
|
|
110
|
+
'defaultTtlMs',
|
|
111
|
+
options.defaultTtlMs ?? 60_000,
|
|
112
|
+
);
|
|
102
113
|
this.clock = options.clock ?? systemClock;
|
|
103
114
|
this.jitter = {
|
|
104
115
|
...(options.jitterFraction === undefined ? {} : { jitterFraction: options.jitterFraction }),
|
package/src/purge-cloudflare.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
purgePost,
|
|
19
19
|
requireCredential,
|
|
20
20
|
} from './purge-http';
|
|
21
|
+
import { assertFiniteDurationMs } from './tiers';
|
|
21
22
|
|
|
22
23
|
export const CLOUDFLARE_API_URL = 'https://api.cloudflare.com/client/v4';
|
|
23
24
|
|
|
@@ -73,7 +74,11 @@ export function cloudflarePurgeDriver(options: CloudflarePurgeOptions): PurgeDri
|
|
|
73
74
|
const apiToken = requireCredential(options.apiToken, 'CLOUDFLARE_API_TOKEN', 'cloudflare');
|
|
74
75
|
const zoneId = requireCredential(options.zoneId, 'CLOUDFLARE_ZONE_ID', 'cloudflare');
|
|
75
76
|
const baseUrl = options.baseUrl ?? CLOUDFLARE_API_URL;
|
|
76
|
-
const timeoutMs =
|
|
77
|
+
const timeoutMs = assertFiniteDurationMs(
|
|
78
|
+
'cloudflare',
|
|
79
|
+
'timeoutMs',
|
|
80
|
+
options.timeoutMs ?? DEFAULT_PURGE_TIMEOUT_MS,
|
|
81
|
+
);
|
|
77
82
|
const doFetch = options.fetch ?? defaultPurgeFetch;
|
|
78
83
|
const headers = { Authorization: `Bearer ${apiToken}` };
|
|
79
84
|
|
package/src/purge-fastly.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
purgePost,
|
|
20
20
|
requireCredential,
|
|
21
21
|
} from './purge-http';
|
|
22
|
+
import { assertFiniteDurationMs } from './tiers';
|
|
22
23
|
|
|
23
24
|
export const FASTLY_API_URL = 'https://api.fastly.com';
|
|
24
25
|
|
|
@@ -72,7 +73,11 @@ export function fastlyPurgeDriver(options: FastlyPurgeOptions): PurgeDriver {
|
|
|
72
73
|
const apiToken = requireCredential(options.apiToken, 'FASTLY_API_TOKEN', 'fastly');
|
|
73
74
|
const serviceId = requireCredential(options.serviceId, 'FASTLY_SERVICE_ID', 'fastly');
|
|
74
75
|
const baseUrl = options.baseUrl ?? FASTLY_API_URL;
|
|
75
|
-
const timeoutMs =
|
|
76
|
+
const timeoutMs = assertFiniteDurationMs(
|
|
77
|
+
'fastly',
|
|
78
|
+
'timeoutMs',
|
|
79
|
+
options.timeoutMs ?? DEFAULT_PURGE_TIMEOUT_MS,
|
|
80
|
+
);
|
|
76
81
|
const doFetch = options.fetch ?? defaultPurgeFetch;
|
|
77
82
|
const headers = { 'Fastly-Key': apiToken };
|
|
78
83
|
|
package/src/purge-http.ts
CHANGED
|
@@ -73,14 +73,43 @@ export function requireCredential(value: string, envKey: string, driver: string)
|
|
|
73
73
|
// Every CDN splits a key list on whitespace or a comma, so a key carrying either purges two
|
|
74
74
|
// things that do not exist instead of the one that does — silently, since the request succeeds.
|
|
75
75
|
const UNSAFE_KEY = /[\s,]/;
|
|
76
|
-
const
|
|
76
|
+
const MAX_KEY_BYTES = 1024;
|
|
77
77
|
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
// The CDN's limit is on the HEADER, so it is bytes — and `String.length` is UTF-16 code units,
|
|
79
|
+
// which for a non-ASCII tag is a third of the answer: 900 CJK characters measured 900 against a
|
|
80
|
+
// 1024 limit and put 2,700 bytes on the wire, where the provider refuses or truncates the key.
|
|
81
|
+
// That is the accepted purge that cleared nothing, arriving through the guard meant to stop it.
|
|
82
|
+
const keyBytes = (key: string): number => new TextEncoder().encode(key).byteLength;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The problem AND the repair, together — because there are three ways a key is unpurgeable and only
|
|
86
|
+
* one of them is fixed by renaming away a separator. A 900-character CJK tag carries no whitespace
|
|
87
|
+
* and no comma, so "rename it so it carries no space or comma" left the next purge failing exactly
|
|
88
|
+
* as the last one did: an instruction that does not repair the stated cause is the shape axiom 4
|
|
89
|
+
* exists to forbid.
|
|
90
|
+
*/
|
|
91
|
+
interface KeyProblem {
|
|
92
|
+
readonly problem: string;
|
|
93
|
+
readonly fix: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const keyProblem = (key: string): KeyProblem | undefined => {
|
|
97
|
+
if (key === '')
|
|
98
|
+
return {
|
|
99
|
+
problem: 'is empty',
|
|
100
|
+
fix: 'give the tag a name in its declareTags(...) call — an empty surrogate key purges nothing',
|
|
101
|
+
};
|
|
80
102
|
if (UNSAFE_KEY.test(key))
|
|
81
|
-
return
|
|
82
|
-
|
|
83
|
-
|
|
103
|
+
return {
|
|
104
|
+
problem: 'contains whitespace or a comma, which a CDN reads as a separator',
|
|
105
|
+
fix: 'rename the tag in its declareTags(...) call so the key carries no space or comma',
|
|
106
|
+
};
|
|
107
|
+
const bytes = keyBytes(key);
|
|
108
|
+
if (bytes > MAX_KEY_BYTES)
|
|
109
|
+
return {
|
|
110
|
+
problem: `is ${bytes} bytes, over the ${MAX_KEY_BYTES}-byte key limit`,
|
|
111
|
+
fix: `shorten the tag in its declareTags(...) call to at most ${MAX_KEY_BYTES} UTF-8 bytes — the limit is on the header, so it is BYTES, and a non-ASCII character costs two or three of them each`,
|
|
112
|
+
};
|
|
84
113
|
return undefined;
|
|
85
114
|
};
|
|
86
115
|
|
|
@@ -94,9 +123,9 @@ export function assertPurgeableKeys(driver: string, keys: readonly string[]): vo
|
|
|
94
123
|
if (problem === undefined) continue;
|
|
95
124
|
throw new CachePurgeFailedError({
|
|
96
125
|
driver,
|
|
97
|
-
detail: `surrogate key ${JSON.stringify(key)} ${problem}`,
|
|
126
|
+
detail: `surrogate key ${JSON.stringify(key)} ${problem.problem}`,
|
|
98
127
|
retryable: false,
|
|
99
|
-
fix:
|
|
128
|
+
fix: problem.fix,
|
|
100
129
|
});
|
|
101
130
|
}
|
|
102
131
|
}
|
package/src/redis.ts
CHANGED
|
@@ -16,7 +16,7 @@ import type {
|
|
|
16
16
|
TierInvalidation,
|
|
17
17
|
TtlJitter,
|
|
18
18
|
} from './tiers';
|
|
19
|
-
import { assertTtl, nowMs } from './tiers';
|
|
19
|
+
import { assertFiniteDurationMs, assertTtl, nowMs } from './tiers';
|
|
20
20
|
|
|
21
21
|
/** The slice of Bun's Redis client this tier uses. Narrow on purpose: easy to fake in tests. */
|
|
22
22
|
export interface RedisLike {
|
|
@@ -191,7 +191,11 @@ function raiseSweepFailure(failures: readonly unknown[], attempted: number): nev
|
|
|
191
191
|
|
|
192
192
|
export function createRedisTier(options: RedisTierOptions = {}): CacheTier {
|
|
193
193
|
const ns = namespaceFor(options.prefix ?? 'x', options.buildId);
|
|
194
|
-
const defaultTtlMs =
|
|
194
|
+
const defaultTtlMs = assertFiniteDurationMs(
|
|
195
|
+
'redis',
|
|
196
|
+
'defaultTtlMs',
|
|
197
|
+
options.defaultTtlMs ?? 300_000,
|
|
198
|
+
);
|
|
195
199
|
const clock = options.clock ?? systemClock;
|
|
196
200
|
const jitter: TtlJitter = {
|
|
197
201
|
...(options.jitterFraction === undefined ? {} : { jitterFraction: options.jitterFraction }),
|
package/src/semantic.ts
CHANGED
|
@@ -8,7 +8,13 @@ import type { Clock } from '@ultimat3/core';
|
|
|
8
8
|
import { systemClock } from '@ultimat3/core';
|
|
9
9
|
import type { CacheTag } from './tags';
|
|
10
10
|
import { tagsIntersect } from './tags';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
assertFiniteCapacity,
|
|
13
|
+
assertFiniteDurationMs,
|
|
14
|
+
assertFiniteSimilarityFloor,
|
|
15
|
+
assertTtl,
|
|
16
|
+
nowMs,
|
|
17
|
+
} from './tiers';
|
|
12
18
|
|
|
13
19
|
export type Embedding = readonly number[];
|
|
14
20
|
|
|
@@ -73,9 +79,15 @@ interface SemanticRecord {
|
|
|
73
79
|
}
|
|
74
80
|
|
|
75
81
|
export function createMemorySemanticCache(options: SemanticCacheOptions = {}): SemanticCache {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
82
|
+
// Screened at construction, both of them: this tier's two knobs are the ones whose failure is
|
|
83
|
+
// silent — a floor of NaN matches everything, a ceiling of NaN evicts nothing.
|
|
84
|
+
const threshold = assertFiniteSimilarityFloor('semantic', 'threshold', options.threshold ?? 0.92);
|
|
85
|
+
const maxEntries = assertFiniteCapacity('semantic', 'maxEntries', options.maxEntries ?? 1000);
|
|
86
|
+
const defaultTtlMs = assertFiniteDurationMs(
|
|
87
|
+
'semantic',
|
|
88
|
+
'defaultTtlMs',
|
|
89
|
+
options.defaultTtlMs ?? 3_600_000,
|
|
90
|
+
);
|
|
79
91
|
const clock = options.clock ?? systemClock;
|
|
80
92
|
const records = new Map<string, SemanticRecord>();
|
|
81
93
|
|
|
@@ -93,7 +105,12 @@ export function createMemorySemanticCache(options: SemanticCacheOptions = {}): S
|
|
|
93
105
|
name: 'memory',
|
|
94
106
|
|
|
95
107
|
lookup<T>(embedding: Embedding, override?: number): Promise<SemanticHit<T> | undefined> {
|
|
96
|
-
|
|
108
|
+
// The override is the same boundary, arriving per call — screened for the same reason, and
|
|
109
|
+
// this is the one that can carry a value straight off a request.
|
|
110
|
+
const floor =
|
|
111
|
+
override === undefined
|
|
112
|
+
? threshold
|
|
113
|
+
: assertFiniteSimilarityFloor('semantic', 'threshold', override);
|
|
97
114
|
let best: SemanticHit<T> | undefined;
|
|
98
115
|
for (const record of live()) {
|
|
99
116
|
const similarity = cosineSimilarity(embedding, record.embedding);
|
package/src/tiers.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type { CacheTierName, Clock, Scheduler } from '@ultimat3/core';
|
|
7
7
|
import { CACHE_TIERS, systemClock } from '@ultimat3/core';
|
|
8
|
-
import { CacheJitterInvalidError, CacheTtlInvalidError } from './errors';
|
|
8
|
+
import { CacheJitterInvalidError, CacheLimitInvalidError, CacheTtlInvalidError } from './errors';
|
|
9
9
|
import type { CacheFence } from './fence';
|
|
10
10
|
import { markInvalidated, sampleFence } from './fence';
|
|
11
11
|
import { mergeSetOptions, tagsAddedSince, ttlOptionsFor } from './set-options';
|
|
@@ -99,6 +99,61 @@ export interface CacheSetOptions {
|
|
|
99
99
|
*/
|
|
100
100
|
export type TtlScope = TierName | 'semantic';
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* A tier's ceiling — bytes or entries — screened where it is still nameable. The same rule as
|
|
104
|
+
* `assertTtl` beside it, over the other knob an `app.config.ts` carries. A SEPARATE refusal from
|
|
105
|
+
* `X_CACHE_TOO_LARGE`, which is one entry against a valid ceiling: this one is the ceiling itself,
|
|
106
|
+
* and it needs a screen because a bad ceiling refuses nothing and evicts nothing rather than
|
|
107
|
+
* refusing everything. `NaN > x` and `x > NaN` are both false.
|
|
108
|
+
*/
|
|
109
|
+
export function assertFiniteCapacity(tier: TtlScope, option: string, value: number): number {
|
|
110
|
+
if (Number.isSafeInteger(value) && value > 0) return value;
|
|
111
|
+
throw new CacheLimitInvalidError({
|
|
112
|
+
tier,
|
|
113
|
+
option,
|
|
114
|
+
value,
|
|
115
|
+
expected: 'a whole number greater than zero',
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A millisecond duration: a driver's request budget (handed to `AbortSignal.timeout`, which THROWS
|
|
121
|
+
* on a non-finite one — inside `purgePost`'s try, so the config typo came back as a retryable
|
|
122
|
+
* transport failure that never happened) and a tier's own `defaultTtlMs`, which is otherwise only
|
|
123
|
+
* screened by `assertTtl` at the first `set`, one write into the process's life.
|
|
124
|
+
*
|
|
125
|
+
* The `Finite` in all three names here is load-bearing: `bun run finite-bounds` recognises a repair
|
|
126
|
+
* by the shape of the CALL, so `assertCapacity` and `assertTimeoutMs` left all nine of this
|
|
127
|
+
* package's options reading as unchecked while every one of them was screened.
|
|
128
|
+
*/
|
|
129
|
+
export function assertFiniteDurationMs(
|
|
130
|
+
tier: string,
|
|
131
|
+
option: string,
|
|
132
|
+
value: number,
|
|
133
|
+
/** Passed only where the value is a CALL argument rather than an `app.config.ts` key. */
|
|
134
|
+
source?: string | undefined,
|
|
135
|
+
): number {
|
|
136
|
+
if (Number.isSafeInteger(value) && value > 0) return value;
|
|
137
|
+
throw new CacheLimitInvalidError({
|
|
138
|
+
tier,
|
|
139
|
+
option,
|
|
140
|
+
value,
|
|
141
|
+
expected: 'a whole number of milliseconds greater than zero',
|
|
142
|
+
source,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The similarity floor the semantic tier decides "same question" with — a correctness boundary,
|
|
148
|
+
* not a tuning knob, so `NaN` here does not loosen it, it removes it: `similarity < NaN` is false
|
|
149
|
+
* for every record. Cosine similarity is in [-1, 1] and a floor of 0 already admits an orthogonal
|
|
150
|
+
* vector, so anything outside [0, 1] is a number nobody can have meant.
|
|
151
|
+
*/
|
|
152
|
+
export function assertFiniteSimilarityFloor(tier: TtlScope, option: string, value: number): number {
|
|
153
|
+
if (Number.isFinite(value) && value >= 0 && value <= 1) return value;
|
|
154
|
+
throw new CacheLimitInvalidError({ tier, option, value, expected: 'a number from 0 to 1' });
|
|
155
|
+
}
|
|
156
|
+
|
|
102
157
|
export function assertTtl(
|
|
103
158
|
key: string,
|
|
104
159
|
ttlMs: number,
|
|
@@ -204,7 +259,14 @@ export function createCacheStack(
|
|
|
204
259
|
// NEXT reader is allowed to try. So the worst case is one duplicate fill, which the ladder's
|
|
205
260
|
// last-write-wins `set` already tolerates, against a key pinned for the life of the process.
|
|
206
261
|
const flight = createSingleFlight({
|
|
207
|
-
deadlineMs:
|
|
262
|
+
deadlineMs: assertFiniteDurationMs(
|
|
263
|
+
'ladder',
|
|
264
|
+
'loadDeadlineMs',
|
|
265
|
+
options.loadDeadlineMs ?? DEFAULT_LOAD_DEADLINE_MS,
|
|
266
|
+
// Caller-owned: it arrives as a `createCacheStack({ loadDeadlineMs })` argument, so there is
|
|
267
|
+
// no `app.config.ts` key to send the reader to.
|
|
268
|
+
'the loadDeadlineMs argument to createCacheStack(...)',
|
|
269
|
+
),
|
|
208
270
|
schedule: options.schedule,
|
|
209
271
|
});
|
|
210
272
|
|