layercache 1.2.0 → 1.2.1

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/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![npm downloads](https://img.shields.io/npm/dw/layercache)](https://www.npmjs.com/package/layercache)
7
7
  [![license](https://img.shields.io/npm/l/layercache)](LICENSE)
8
8
  [![TypeScript](https://img.shields.io/badge/TypeScript-first-blue)](https://www.typescriptlang.org/)
9
- [![test coverage](https://img.shields.io/badge/tests-132%20passing-brightgreen)](https://github.com/flyingsquirrel0419/layercache)
9
+ [![test coverage](https://img.shields.io/badge/tests-158%20passing-brightgreen)](https://github.com/flyingsquirrel0419/layercache)
10
10
 
11
11
  ```
12
12
  L1 hit ~0.01 ms ← served from memory, zero network
@@ -40,15 +40,22 @@ On a hit, the value is returned from the fastest layer that has it, and automati
40
40
  - **Layered reads & automatic backfill** — hits in slower layers propagate up
41
41
  - **Cache stampede prevention** — mutex-based deduplication per key
42
42
  - **Tag-based invalidation** — `set('user:123:posts', posts, { tags: ['user:123'] })` then `invalidateByTag('user:123')`
43
+ - **Batch tag invalidation** — `invalidateByTags(['tenant:a', 'users'], 'all')` for OR/AND invalidation in one call
43
44
  - **Pattern invalidation** — `invalidateByPattern('user:*')`
45
+ - **Prefix invalidation** — efficient `invalidateByPrefix('user:123:')` for hierarchical keys
46
+ - **Generation-based invalidation** — `generation` prefixes keys with `vN:` and `bumpGeneration()` rotates the namespace instantly
44
47
  - **Per-layer TTL overrides** — different TTLs for memory vs. Redis in one call
48
+ - **TTL policies** — align TTLs to time boundaries (`until-midnight`, `next-hour`, `{ alignTo }`, or a function)
45
49
  - **Negative caching** — cache known misses for a short TTL to protect the database
46
50
  - **Stale strategies** — `staleWhileRevalidate` and `staleIfError` as opt-in read behavior
47
51
  - **TTL jitter** — spread expirations to avoid synchronized stampedes
48
52
  - **Sliding & adaptive TTL** — extend TTL on every read or ramp it up for hot keys
49
53
  - **Refresh-ahead** — trigger background refresh when TTL drops below a threshold
54
+ - **Fetcher rate limiting** — cap concurrent fetchers or requests per interval
50
55
  - **Best-effort writes** — tolerate partial layer write failures when desired
56
+ - **Write-behind mode** — write local layers immediately and flush slower remote layers asynchronously
51
57
  - **Bulk reads** — `mget` uses layer-level `getMany()` when available
58
+ - **Bulk writes** — `mset` uses layer-level `setMany()` when available
52
59
  - **Distributed tag index** — `RedisTagIndex` keeps tag state consistent across multiple servers
53
60
  - **Optional distributed single-flight** — plug in a coordinator to dedupe misses across instances
54
61
  - **Cross-server L1 invalidation** — Redis pub/sub bus flushes stale memory on other instances when you write or delete
@@ -59,17 +66,22 @@ On a hit, the value is returned from the fastest layer that has it, and automati
59
66
  - **Graceful degradation** — skip a failing layer for a configurable retry window
60
67
  - **Circuit breaker** — per-key or global; opens after N failures, recovers after cooldown
61
68
  - **Compression** — transparent async gzip/brotli in `RedisLayer` (non-blocking) with a byte threshold
69
+ - **Serializer fallback chains** — transparently read legacy payloads (for example JSON) and rewrite them with the primary serializer
62
70
  - **Metrics & stats** — per-layer hit/miss counters, **per-layer latency tracking**, circuit-breaker trips, degraded operations; HTTP stats handler
71
+ - **Health checks** — `cache.healthCheck()` returns per-layer health and latency
63
72
  - **Persistence** — `exportState` / `importState` for in-process snapshots; `persistToFile` / `restoreFromFile` for disk
64
- - **Admin CLI** — `layercache stats | keys | invalidate` against any Redis URL
65
- - **Framework integrations** — Express middleware, Fastify plugin, tRPC middleware, GraphQL resolver wrapper
73
+ - **Admin CLI** — `layercache stats | keys | inspect | invalidate` against any Redis URL
74
+ - **Framework integrations** — Express middleware, Fastify plugin, Hono middleware, tRPC middleware, GraphQL resolver wrapper
75
+ - **OpenTelemetry plugin** — instrument `get` / `set` / invalidation flows with spans
66
76
  - **MessagePack serializer** — drop-in replacement for lower Redis memory usage
67
77
  - **NestJS module** — `CacheStackModule.forRoot(...)` and `forRootAsync(...)` with `@InjectCacheStack()`
68
78
  - **`getOrThrow()`** — throws `CacheMissError` instead of returning `null`, for strict use cases
69
79
  - **`inspect()`** — debug a key: see which layers hold it, remaining TTLs, tags, and staleness state
80
+ - **MemoryLayer cleanup hooks** — periodic TTL cleanup and `onEvict` callbacks
70
81
  - **Conditional caching** — `shouldCache` predicate to skip caching specific fetcher results
71
- - **Nested namespaces** — `namespace('a').namespace('b')` for composable key prefixes
82
+ - **Nested namespaces** — `namespace('a').namespace('b')` for composable key prefixes with namespace-scoped metrics
72
83
  - **Custom layers** — implement the 5-method `CacheLayer` interface to plug in Memcached, DynamoDB, or anything else
84
+ - **Edge-safe entry point** — `layercache/edge` exports the non-Node helpers for Worker-style runtimes
73
85
  - **ESM + CJS** — works with both module systems, Node.js ≥ 18
74
86
 
75
87
  ---
@@ -168,6 +180,15 @@ await cache.set('user:123:posts', posts, { tags: ['user:123'] })
168
180
  await cache.invalidateByTag('user:123') // both keys gone
169
181
  ```
170
182
 
183
+ ### `cache.invalidateByTags(tags, mode?): Promise<void>`
184
+
185
+ Delete keys that match any or all of a set of tags.
186
+
187
+ ```ts
188
+ await cache.invalidateByTags(['tenant:a', 'users'], 'all') // keys tagged with both
189
+ await cache.invalidateByTags(['users', 'posts'], 'any') // keys tagged with either
190
+ ```
191
+
171
192
  ### `cache.invalidateByPattern(pattern): Promise<void>`
172
193
 
173
194
  Glob-style deletion against the tracked key set.
@@ -176,6 +197,16 @@ Glob-style deletion against the tracked key set.
176
197
  await cache.invalidateByPattern('user:*') // deletes user:1, user:2, …
177
198
  ```
178
199
 
200
+ ### `cache.invalidateByPrefix(prefix): Promise<void>`
201
+
202
+ Prefer this over glob invalidation when your keys are hierarchical.
203
+
204
+ ```ts
205
+ await cache.invalidateByPrefix('user:123:') // deletes user:123:profile, user:123:posts, ...
206
+ ```
207
+
208
+ The prefix is matched as-is. You do not need to append `*`, and namespace helpers pass their namespace prefix directly.
209
+
179
210
  ### `cache.mget<T>(entries): Promise<Array<T | null>>`
180
211
 
181
212
  Concurrent multi-key fetch, each with its own optional fetcher.
@@ -195,6 +226,13 @@ const [user1, user2] = await cache.mget([
195
226
  const { hits, misses, fetches, staleHits, refreshes, writeFailures } = cache.getMetrics()
196
227
  ```
197
228
 
229
+ ### `cache.healthCheck(): Promise<CacheHealthCheckResult[]>`
230
+
231
+ ```ts
232
+ const health = await cache.healthCheck()
233
+ // [{ layer: 'memory', healthy: true, latencyMs: 0.03 }, ...]
234
+ ```
235
+
198
236
  ### `cache.resetMetrics(): void`
199
237
 
200
238
  Resets all counters to zero — useful for per-interval reporting.
@@ -229,6 +267,21 @@ const getUser = cache.wrap(
229
267
  )
230
268
  ```
231
269
 
270
+ ### Generation-based invalidation
271
+
272
+ Add a generation prefix to every key and rotate it when you want to invalidate the whole cache namespace without scanning:
273
+
274
+ ```ts
275
+ const cache = new CacheStack([...], { generation: 1 })
276
+
277
+ await cache.set('user:123', user)
278
+ cache.bumpGeneration() // now reads use v2:user:123
279
+ ```
280
+
281
+ ### OpenTelemetry note
282
+
283
+ `createOpenTelemetryPlugin()` currently wraps a `CacheStack` instance's methods directly. Use one OpenTelemetry plugin per cache instance; if you need to compose multiple wrappers, install them in a fixed order and uninstall them in reverse order.
284
+
232
285
  ### `cache.warm(entries, options?)`
233
286
 
234
287
  Pre-populate layers at startup from a prioritised list. Higher `priority` values run first.
@@ -246,7 +299,7 @@ await cache.warm(
246
299
 
247
300
  ### `cache.namespace(prefix): CacheNamespace`
248
301
 
249
- Returns a scoped view with the same full API (`get`, `set`, `delete`, `clear`, `mget`, `wrap`, `warm`, `invalidateByTag`, `invalidateByPattern`, `getMetrics`). `clear()` only touches `prefix:*` keys.
302
+ Returns a scoped view with the same full API (`get`, `set`, `delete`, `clear`, `mget`, `wrap`, `warm`, `invalidateByTag`, `invalidateByPattern`, `getMetrics`). `clear()` only touches `prefix:*` keys, and namespace metrics are serialized per `CacheStack` instance so unrelated caches do not block each other while metrics are collected.
250
303
 
251
304
  ```ts
252
305
  const users = cache.namespace('users')
@@ -305,6 +358,19 @@ const data = await cache.get('api:response', fetchFromApi, {
305
358
  // If fetchFromApi returns { status: 500 }, the value is returned but NOT cached
306
359
  ```
307
360
 
361
+ ### TTL policies
362
+
363
+ Align expirations to calendar or boundary-based schedules:
364
+
365
+ ```ts
366
+ await cache.set('daily-report', report, { ttlPolicy: 'until-midnight' })
367
+ await cache.set('hourly-rollup', rollup, { ttlPolicy: 'next-hour' })
368
+ await cache.set('aligned', value, { ttlPolicy: { alignTo: 300 } }) // next 5-minute boundary
369
+ await cache.set('custom', value, {
370
+ ttlPolicy: ({ key, value }) => key.startsWith('hot:') ? 30 : 300
371
+ })
372
+ ```
373
+
308
374
  ---
309
375
 
310
376
  ## Negative + stale caching
@@ -0,0 +1,312 @@
1
+ import {
2
+ PatternMatcher,
3
+ unwrapStoredValue
4
+ } from "./chunk-ZMDB5KOK.js";
5
+
6
+ // src/layers/MemoryLayer.ts
7
+ var MemoryLayer = class {
8
+ name;
9
+ defaultTtl;
10
+ isLocal = true;
11
+ maxSize;
12
+ evictionPolicy;
13
+ onEvict;
14
+ entries = /* @__PURE__ */ new Map();
15
+ cleanupTimer;
16
+ constructor(options = {}) {
17
+ this.name = options.name ?? "memory";
18
+ this.defaultTtl = options.ttl;
19
+ this.maxSize = options.maxSize ?? 1e3;
20
+ this.evictionPolicy = options.evictionPolicy ?? "lru";
21
+ this.onEvict = options.onEvict;
22
+ if (options.cleanupIntervalMs && options.cleanupIntervalMs > 0) {
23
+ this.cleanupTimer = setInterval(() => {
24
+ this.pruneExpired();
25
+ }, options.cleanupIntervalMs);
26
+ this.cleanupTimer.unref?.();
27
+ }
28
+ }
29
+ async get(key) {
30
+ const value = await this.getEntry(key);
31
+ return unwrapStoredValue(value);
32
+ }
33
+ async getEntry(key) {
34
+ const entry = this.entries.get(key);
35
+ if (!entry) {
36
+ return null;
37
+ }
38
+ if (this.isExpired(entry)) {
39
+ this.entries.delete(key);
40
+ return null;
41
+ }
42
+ if (this.evictionPolicy === "lru") {
43
+ this.entries.delete(key);
44
+ entry.accessCount += 1;
45
+ this.entries.set(key, entry);
46
+ } else if (this.evictionPolicy === "lfu") {
47
+ entry.accessCount += 1;
48
+ }
49
+ return entry.value;
50
+ }
51
+ async getMany(keys) {
52
+ const values = [];
53
+ for (const key of keys) {
54
+ values.push(await this.getEntry(key));
55
+ }
56
+ return values;
57
+ }
58
+ async setMany(entries) {
59
+ for (const entry of entries) {
60
+ await this.set(entry.key, entry.value, entry.ttl);
61
+ }
62
+ }
63
+ async set(key, value, ttl = this.defaultTtl) {
64
+ this.entries.delete(key);
65
+ this.entries.set(key, {
66
+ value,
67
+ expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null,
68
+ accessCount: 0,
69
+ insertedAt: Date.now()
70
+ });
71
+ while (this.entries.size > this.maxSize) {
72
+ this.evict();
73
+ }
74
+ }
75
+ async has(key) {
76
+ const entry = this.entries.get(key);
77
+ if (!entry) {
78
+ return false;
79
+ }
80
+ if (this.isExpired(entry)) {
81
+ this.entries.delete(key);
82
+ return false;
83
+ }
84
+ return true;
85
+ }
86
+ async ttl(key) {
87
+ const entry = this.entries.get(key);
88
+ if (!entry) {
89
+ return null;
90
+ }
91
+ if (this.isExpired(entry)) {
92
+ this.entries.delete(key);
93
+ return null;
94
+ }
95
+ if (entry.expiresAt === null) {
96
+ return null;
97
+ }
98
+ return Math.max(0, Math.ceil((entry.expiresAt - Date.now()) / 1e3));
99
+ }
100
+ async size() {
101
+ this.pruneExpired();
102
+ return this.entries.size;
103
+ }
104
+ async delete(key) {
105
+ this.entries.delete(key);
106
+ }
107
+ async deleteMany(keys) {
108
+ for (const key of keys) {
109
+ this.entries.delete(key);
110
+ }
111
+ }
112
+ async clear() {
113
+ this.entries.clear();
114
+ }
115
+ async ping() {
116
+ return true;
117
+ }
118
+ async dispose() {
119
+ if (this.cleanupTimer) {
120
+ clearInterval(this.cleanupTimer);
121
+ this.cleanupTimer = void 0;
122
+ }
123
+ }
124
+ async keys() {
125
+ this.pruneExpired();
126
+ return [...this.entries.keys()];
127
+ }
128
+ exportState() {
129
+ this.pruneExpired();
130
+ return [...this.entries.entries()].map(([key, entry]) => ({
131
+ key,
132
+ value: entry.value,
133
+ expiresAt: entry.expiresAt
134
+ }));
135
+ }
136
+ importState(entries) {
137
+ for (const entry of entries) {
138
+ if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
139
+ continue;
140
+ }
141
+ this.entries.set(entry.key, {
142
+ value: entry.value,
143
+ expiresAt: entry.expiresAt,
144
+ accessCount: 0,
145
+ insertedAt: Date.now()
146
+ });
147
+ }
148
+ while (this.entries.size > this.maxSize) {
149
+ this.evict();
150
+ }
151
+ }
152
+ evict() {
153
+ if (this.evictionPolicy === "lru" || this.evictionPolicy === "fifo") {
154
+ const oldestKey = this.entries.keys().next().value;
155
+ if (oldestKey !== void 0) {
156
+ const entry = this.entries.get(oldestKey);
157
+ this.entries.delete(oldestKey);
158
+ if (entry) {
159
+ this.onEvict?.(oldestKey, unwrapStoredValue(entry.value));
160
+ }
161
+ }
162
+ return;
163
+ }
164
+ let victimKey;
165
+ let minCount = Number.POSITIVE_INFINITY;
166
+ let minInsertedAt = Number.POSITIVE_INFINITY;
167
+ for (const [key, entry] of this.entries.entries()) {
168
+ if (entry.accessCount < minCount || entry.accessCount === minCount && entry.insertedAt < minInsertedAt) {
169
+ minCount = entry.accessCount;
170
+ minInsertedAt = entry.insertedAt;
171
+ victimKey = key;
172
+ }
173
+ }
174
+ if (victimKey !== void 0) {
175
+ const victim = this.entries.get(victimKey);
176
+ this.entries.delete(victimKey);
177
+ if (victim) {
178
+ this.onEvict?.(victimKey, unwrapStoredValue(victim.value));
179
+ }
180
+ }
181
+ }
182
+ pruneExpired() {
183
+ for (const [key, entry] of this.entries.entries()) {
184
+ if (this.isExpired(entry)) {
185
+ this.entries.delete(key);
186
+ }
187
+ }
188
+ }
189
+ isExpired(entry) {
190
+ return entry.expiresAt !== null && entry.expiresAt <= Date.now();
191
+ }
192
+ };
193
+
194
+ // src/invalidation/TagIndex.ts
195
+ var TagIndex = class {
196
+ tagToKeys = /* @__PURE__ */ new Map();
197
+ keyToTags = /* @__PURE__ */ new Map();
198
+ knownKeys = /* @__PURE__ */ new Set();
199
+ maxKnownKeys;
200
+ constructor(options = {}) {
201
+ this.maxKnownKeys = options.maxKnownKeys;
202
+ }
203
+ async touch(key) {
204
+ this.knownKeys.add(key);
205
+ this.pruneKnownKeysIfNeeded();
206
+ }
207
+ async track(key, tags) {
208
+ this.knownKeys.add(key);
209
+ this.pruneKnownKeysIfNeeded();
210
+ if (tags.length === 0) {
211
+ return;
212
+ }
213
+ const existingTags = this.keyToTags.get(key);
214
+ if (existingTags) {
215
+ for (const tag of existingTags) {
216
+ this.tagToKeys.get(tag)?.delete(key);
217
+ }
218
+ }
219
+ const tagSet = new Set(tags);
220
+ this.keyToTags.set(key, tagSet);
221
+ for (const tag of tagSet) {
222
+ const keys = this.tagToKeys.get(tag) ?? /* @__PURE__ */ new Set();
223
+ keys.add(key);
224
+ this.tagToKeys.set(tag, keys);
225
+ }
226
+ }
227
+ async remove(key) {
228
+ this.removeKey(key);
229
+ }
230
+ async keysForTag(tag) {
231
+ return [...this.tagToKeys.get(tag) ?? /* @__PURE__ */ new Set()];
232
+ }
233
+ async keysForPrefix(prefix) {
234
+ return [...this.knownKeys].filter((key) => key.startsWith(prefix));
235
+ }
236
+ async tagsForKey(key) {
237
+ return [...this.keyToTags.get(key) ?? /* @__PURE__ */ new Set()];
238
+ }
239
+ async matchPattern(pattern) {
240
+ return [...this.knownKeys].filter((key) => PatternMatcher.matches(pattern, key));
241
+ }
242
+ async clear() {
243
+ this.tagToKeys.clear();
244
+ this.keyToTags.clear();
245
+ this.knownKeys.clear();
246
+ }
247
+ pruneKnownKeysIfNeeded() {
248
+ if (this.maxKnownKeys === void 0 || this.knownKeys.size <= this.maxKnownKeys) {
249
+ return;
250
+ }
251
+ const toRemove = Math.ceil(this.maxKnownKeys * 0.1);
252
+ let removed = 0;
253
+ for (const key of this.knownKeys) {
254
+ if (removed >= toRemove) {
255
+ break;
256
+ }
257
+ this.removeKey(key);
258
+ removed += 1;
259
+ }
260
+ }
261
+ removeKey(key) {
262
+ this.knownKeys.delete(key);
263
+ const tags = this.keyToTags.get(key);
264
+ if (!tags) {
265
+ return;
266
+ }
267
+ for (const tag of tags) {
268
+ const keys = this.tagToKeys.get(tag);
269
+ if (!keys) {
270
+ continue;
271
+ }
272
+ keys.delete(key);
273
+ if (keys.size === 0) {
274
+ this.tagToKeys.delete(tag);
275
+ }
276
+ }
277
+ this.keyToTags.delete(key);
278
+ }
279
+ };
280
+
281
+ // src/integrations/hono.ts
282
+ function createHonoCacheMiddleware(cache, options = {}) {
283
+ const allowedMethods = new Set((options.methods ?? ["GET"]).map((method) => method.toUpperCase()));
284
+ return async (context, next) => {
285
+ const method = (context.req.method ?? "GET").toUpperCase();
286
+ if (!allowedMethods.has(method)) {
287
+ await next();
288
+ return;
289
+ }
290
+ const key = options.keyResolver ? options.keyResolver(context.req) : `${method}:${context.req.path ?? context.req.url ?? "/"}`;
291
+ const cached = await cache.get(key, void 0, options);
292
+ if (cached !== null) {
293
+ context.header?.("x-cache", "HIT");
294
+ context.header?.("content-type", "application/json; charset=utf-8");
295
+ context.json(cached);
296
+ return;
297
+ }
298
+ const originalJson = context.json.bind(context);
299
+ context.json = (body, status) => {
300
+ context.header?.("x-cache", "MISS");
301
+ void cache.set(key, body, options);
302
+ return originalJson(body, status);
303
+ };
304
+ await next();
305
+ };
306
+ }
307
+
308
+ export {
309
+ MemoryLayer,
310
+ TagIndex,
311
+ createHonoCacheMiddleware
312
+ };
@@ -1,40 +1,6 @@
1
- // src/invalidation/PatternMatcher.ts
2
- var PatternMatcher = class _PatternMatcher {
3
- /**
4
- * Tests whether a glob-style pattern matches a value.
5
- * Supports `*` (any sequence of characters) and `?` (any single character).
6
- * Uses a linear-time algorithm to avoid ReDoS vulnerabilities.
7
- */
8
- static matches(pattern, value) {
9
- return _PatternMatcher.matchLinear(pattern, value);
10
- }
11
- /**
12
- * Linear-time glob matching using dynamic programming.
13
- * Avoids catastrophic backtracking that RegExp-based glob matching can cause.
14
- */
15
- static matchLinear(pattern, value) {
16
- const m = pattern.length;
17
- const n = value.length;
18
- const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(false));
19
- dp[0][0] = true;
20
- for (let i = 1; i <= m; i++) {
21
- if (pattern[i - 1] === "*") {
22
- dp[i][0] = dp[i - 1]?.[0];
23
- }
24
- }
25
- for (let i = 1; i <= m; i++) {
26
- for (let j = 1; j <= n; j++) {
27
- const pc = pattern[i - 1];
28
- if (pc === "*") {
29
- dp[i][j] = dp[i - 1]?.[j] || dp[i]?.[j - 1];
30
- } else if (pc === "?" || pc === value[j - 1]) {
31
- dp[i][j] = dp[i - 1]?.[j - 1];
32
- }
33
- }
34
- }
35
- return dp[m]?.[n];
36
- }
37
- };
1
+ import {
2
+ PatternMatcher
3
+ } from "./chunk-ZMDB5KOK.js";
38
4
 
39
5
  // src/invalidation/RedisTagIndex.ts
40
6
  var RedisTagIndex = class {
@@ -80,6 +46,16 @@ var RedisTagIndex = class {
80
46
  async keysForTag(tag) {
81
47
  return this.client.smembers(this.tagKeysKey(tag));
82
48
  }
49
+ async keysForPrefix(prefix) {
50
+ const matches = [];
51
+ let cursor = "0";
52
+ do {
53
+ const [nextCursor, keys] = await this.client.sscan(this.knownKeysKey(), cursor, "COUNT", this.scanCount);
54
+ cursor = nextCursor;
55
+ matches.push(...keys.filter((key) => key.startsWith(prefix)));
56
+ } while (cursor !== "0");
57
+ return matches;
58
+ }
83
59
  async tagsForKey(key) {
84
60
  return this.client.smembers(this.keyTagsKey(key));
85
61
  }
@@ -130,6 +106,5 @@ var RedisTagIndex = class {
130
106
  };
131
107
 
132
108
  export {
133
- PatternMatcher,
134
109
  RedisTagIndex
135
110
  };
@@ -0,0 +1,159 @@
1
+ // src/internal/StoredValue.ts
2
+ function isStoredValueEnvelope(value) {
3
+ return typeof value === "object" && value !== null && "__layercache" in value && value.__layercache === 1 && "kind" in value;
4
+ }
5
+ function createStoredValueEnvelope(options) {
6
+ const now = options.now ?? Date.now();
7
+ const freshTtlSeconds = normalizePositiveSeconds(options.freshTtlSeconds);
8
+ const staleWhileRevalidateSeconds = normalizePositiveSeconds(options.staleWhileRevalidateSeconds);
9
+ const staleIfErrorSeconds = normalizePositiveSeconds(options.staleIfErrorSeconds);
10
+ const freshUntil = freshTtlSeconds ? now + freshTtlSeconds * 1e3 : null;
11
+ const staleUntil = freshUntil && staleWhileRevalidateSeconds ? freshUntil + staleWhileRevalidateSeconds * 1e3 : null;
12
+ const errorUntil = freshUntil && staleIfErrorSeconds ? freshUntil + staleIfErrorSeconds * 1e3 : null;
13
+ return {
14
+ __layercache: 1,
15
+ kind: options.kind,
16
+ value: options.value,
17
+ freshUntil,
18
+ staleUntil,
19
+ errorUntil,
20
+ freshTtlSeconds: freshTtlSeconds ?? null,
21
+ staleWhileRevalidateSeconds: staleWhileRevalidateSeconds ?? null,
22
+ staleIfErrorSeconds: staleIfErrorSeconds ?? null
23
+ };
24
+ }
25
+ function resolveStoredValue(stored, now = Date.now()) {
26
+ if (!isStoredValueEnvelope(stored)) {
27
+ return { state: "fresh", value: stored, stored };
28
+ }
29
+ if (stored.freshUntil === null || stored.freshUntil > now) {
30
+ return { state: "fresh", value: unwrapStoredValue(stored), stored, envelope: stored };
31
+ }
32
+ if (stored.staleUntil !== null && stored.staleUntil > now) {
33
+ return { state: "stale-while-revalidate", value: unwrapStoredValue(stored), stored, envelope: stored };
34
+ }
35
+ if (stored.errorUntil !== null && stored.errorUntil > now) {
36
+ return { state: "stale-if-error", value: unwrapStoredValue(stored), stored, envelope: stored };
37
+ }
38
+ return { state: "expired", value: null, stored, envelope: stored };
39
+ }
40
+ function unwrapStoredValue(stored) {
41
+ if (!isStoredValueEnvelope(stored)) {
42
+ return stored;
43
+ }
44
+ if (stored.kind === "empty") {
45
+ return null;
46
+ }
47
+ return stored.value ?? null;
48
+ }
49
+ function remainingStoredTtlSeconds(stored, now = Date.now()) {
50
+ if (!isStoredValueEnvelope(stored)) {
51
+ return void 0;
52
+ }
53
+ const expiry = maxExpiry(stored);
54
+ if (expiry === null) {
55
+ return void 0;
56
+ }
57
+ const remainingMs = expiry - now;
58
+ if (remainingMs <= 0) {
59
+ return 1;
60
+ }
61
+ return Math.max(1, Math.ceil(remainingMs / 1e3));
62
+ }
63
+ function remainingFreshTtlSeconds(stored, now = Date.now()) {
64
+ if (!isStoredValueEnvelope(stored) || stored.freshUntil === null) {
65
+ return void 0;
66
+ }
67
+ const remainingMs = stored.freshUntil - now;
68
+ if (remainingMs <= 0) {
69
+ return 0;
70
+ }
71
+ return Math.max(1, Math.ceil(remainingMs / 1e3));
72
+ }
73
+ function refreshStoredEnvelope(stored, now = Date.now()) {
74
+ if (!isStoredValueEnvelope(stored)) {
75
+ return stored;
76
+ }
77
+ return createStoredValueEnvelope({
78
+ kind: stored.kind,
79
+ value: stored.value,
80
+ freshTtlSeconds: stored.freshTtlSeconds ?? void 0,
81
+ staleWhileRevalidateSeconds: stored.staleWhileRevalidateSeconds ?? void 0,
82
+ staleIfErrorSeconds: stored.staleIfErrorSeconds ?? void 0,
83
+ now
84
+ });
85
+ }
86
+ function maxExpiry(stored) {
87
+ const values = [stored.freshUntil, stored.staleUntil, stored.errorUntil].filter(
88
+ (value) => value !== null
89
+ );
90
+ if (values.length === 0) {
91
+ return null;
92
+ }
93
+ return Math.max(...values);
94
+ }
95
+ function normalizePositiveSeconds(value) {
96
+ if (!value || value <= 0) {
97
+ return void 0;
98
+ }
99
+ return value;
100
+ }
101
+
102
+ // src/invalidation/PatternMatcher.ts
103
+ var PatternMatcher = class _PatternMatcher {
104
+ /**
105
+ * Tests whether a glob-style pattern matches a value.
106
+ * Supports `*` (any sequence of characters) and `?` (any single character).
107
+ * Uses a two-pointer algorithm to avoid ReDoS vulnerabilities and
108
+ * quadratic memory usage on long patterns/keys.
109
+ */
110
+ static matches(pattern, value) {
111
+ return _PatternMatcher.matchLinear(pattern, value);
112
+ }
113
+ /**
114
+ * Linear-time glob matching with O(1) extra memory.
115
+ */
116
+ static matchLinear(pattern, value) {
117
+ let patternIndex = 0;
118
+ let valueIndex = 0;
119
+ let starIndex = -1;
120
+ let backtrackValueIndex = 0;
121
+ while (valueIndex < value.length) {
122
+ const patternChar = pattern[patternIndex];
123
+ const valueChar = value[valueIndex];
124
+ if (patternChar === "*" && patternIndex < pattern.length) {
125
+ starIndex = patternIndex;
126
+ patternIndex += 1;
127
+ backtrackValueIndex = valueIndex;
128
+ continue;
129
+ }
130
+ if (patternChar === "?" || patternChar === valueChar) {
131
+ patternIndex += 1;
132
+ valueIndex += 1;
133
+ continue;
134
+ }
135
+ if (starIndex !== -1) {
136
+ patternIndex = starIndex + 1;
137
+ backtrackValueIndex += 1;
138
+ valueIndex = backtrackValueIndex;
139
+ continue;
140
+ }
141
+ return false;
142
+ }
143
+ while (patternIndex < pattern.length && pattern[patternIndex] === "*") {
144
+ patternIndex += 1;
145
+ }
146
+ return patternIndex === pattern.length;
147
+ }
148
+ };
149
+
150
+ export {
151
+ isStoredValueEnvelope,
152
+ createStoredValueEnvelope,
153
+ resolveStoredValue,
154
+ unwrapStoredValue,
155
+ remainingStoredTtlSeconds,
156
+ remainingFreshTtlSeconds,
157
+ refreshStoredEnvelope,
158
+ PatternMatcher
159
+ };