@ultimat3/cache 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tiers.ts ADDED
@@ -0,0 +1,100 @@
1
+ // The tier ladder: request-memo -> lru -> redis -> cdn. Reads walk DOWN until a hit, then
2
+ // the value is written back UP so the next reader stops earlier. One interface for all four
3
+ // so a deployment can omit Redis (single node) or add the CDN tier without touching call
4
+ // sites. Order is data, not control flow.
5
+
6
+ import type { Clock } from '@ultimat3/core';
7
+ import type { CacheTag } from './tags';
8
+
9
+ export type TierName = 'request-memo' | 'lru' | 'redis' | 'cdn';
10
+
11
+ /** Read order. Index in this array is the tier's distance from the request. */
12
+ export const TIER_ORDER: readonly TierName[] = ['request-memo', 'lru', 'redis', 'cdn'];
13
+
14
+ export interface CacheEntry<T> {
15
+ readonly value: T;
16
+ /** Epoch ms; `undefined` means no expiry. */
17
+ readonly expiresAt?: number;
18
+ readonly tags: readonly CacheTag[];
19
+ }
20
+
21
+ export interface CacheSetOptions {
22
+ readonly ttlMs?: number;
23
+ readonly tags?: readonly CacheTag[];
24
+ }
25
+
26
+ /** Per-tier result of an invalidation, surfaced verbatim in the `/_x` cache panel. */
27
+ export interface TierInvalidation {
28
+ readonly tier: TierName;
29
+ readonly keys: readonly string[];
30
+ readonly skipped?: string;
31
+ }
32
+
33
+ export interface CacheTier {
34
+ readonly name: TierName;
35
+ get<T>(key: string): Promise<CacheEntry<T> | undefined>;
36
+ set<T>(key: string, value: T, options?: CacheSetOptions): Promise<void>;
37
+ del(key: string): Promise<void>;
38
+ invalidateTags(tags: readonly CacheTag[]): Promise<TierInvalidation>;
39
+ }
40
+
41
+ export interface CacheStack {
42
+ readonly tiers: readonly CacheTier[];
43
+ /** Read-through: walk down, populate up, return the value. */
44
+ read<T>(key: string, load: () => Promise<T>, options?: CacheSetOptions): Promise<T>;
45
+ write<T>(key: string, value: T, options?: CacheSetOptions): Promise<void>;
46
+ drop(key: string): Promise<void>;
47
+ }
48
+
49
+ /**
50
+ * `Clock.now()` is intentionally read through `unknown` — a clock may return a `Date` or
51
+ * epoch ms and every tier needs one comparable number.
52
+ */
53
+ export function nowMs(clock: Clock): number {
54
+ const reading: unknown = clock.now();
55
+ if (reading instanceof Date) return reading.getTime();
56
+ return Number(reading);
57
+ }
58
+
59
+ export function isExpired<T>(entry: CacheEntry<T>, at: number): boolean {
60
+ return entry.expiresAt !== undefined && entry.expiresAt <= at;
61
+ }
62
+
63
+ /** Sorts tiers into `TIER_ORDER` so registration order cannot change read semantics. */
64
+ export function sortTiers(tiers: readonly CacheTier[]): readonly CacheTier[] {
65
+ return [...tiers].sort((a, b) => TIER_ORDER.indexOf(a.name) - TIER_ORDER.indexOf(b.name));
66
+ }
67
+
68
+ export function createCacheStack(tiers: readonly CacheTier[]): CacheStack {
69
+ const ordered = sortTiers(tiers);
70
+
71
+ return {
72
+ tiers: ordered,
73
+
74
+ async read<T>(key: string, load: () => Promise<T>, options?: CacheSetOptions): Promise<T> {
75
+ for (let i = 0; i < ordered.length; i += 1) {
76
+ const tier = ordered[i];
77
+ if (tier === undefined) continue;
78
+ const hit = await tier.get<T>(key);
79
+ if (hit === undefined) continue;
80
+ // Populate every tier we walked past, closest-first on the next read.
81
+ for (let up = 0; up < i; up += 1) {
82
+ await ordered[up]?.set(key, hit.value, { ...options, tags: hit.tags });
83
+ }
84
+ return hit.value;
85
+ }
86
+
87
+ const value = await load();
88
+ for (const tier of ordered) await tier.set(key, value, options);
89
+ return value;
90
+ },
91
+
92
+ async write<T>(key: string, value: T, options?: CacheSetOptions): Promise<void> {
93
+ for (const tier of ordered) await tier.set(key, value, options);
94
+ },
95
+
96
+ async drop(key: string): Promise<void> {
97
+ for (const tier of ordered) await tier.del(key);
98
+ },
99
+ };
100
+ }