layercache 1.1.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.
@@ -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
+ };
package/dist/cli.cjs CHANGED
@@ -36,41 +36,80 @@ __export(cli_exports, {
36
36
  module.exports = __toCommonJS(cli_exports);
37
37
  var import_ioredis = __toESM(require("ioredis"), 1);
38
38
 
39
+ // src/internal/StoredValue.ts
40
+ function isStoredValueEnvelope(value) {
41
+ return typeof value === "object" && value !== null && "__layercache" in value && value.__layercache === 1 && "kind" in value;
42
+ }
43
+ function resolveStoredValue(stored, now = Date.now()) {
44
+ if (!isStoredValueEnvelope(stored)) {
45
+ return { state: "fresh", value: stored, stored };
46
+ }
47
+ if (stored.freshUntil === null || stored.freshUntil > now) {
48
+ return { state: "fresh", value: unwrapStoredValue(stored), stored, envelope: stored };
49
+ }
50
+ if (stored.staleUntil !== null && stored.staleUntil > now) {
51
+ return { state: "stale-while-revalidate", value: unwrapStoredValue(stored), stored, envelope: stored };
52
+ }
53
+ if (stored.errorUntil !== null && stored.errorUntil > now) {
54
+ return { state: "stale-if-error", value: unwrapStoredValue(stored), stored, envelope: stored };
55
+ }
56
+ return { state: "expired", value: null, stored, envelope: stored };
57
+ }
58
+ function unwrapStoredValue(stored) {
59
+ if (!isStoredValueEnvelope(stored)) {
60
+ return stored;
61
+ }
62
+ if (stored.kind === "empty") {
63
+ return null;
64
+ }
65
+ return stored.value ?? null;
66
+ }
67
+
39
68
  // src/invalidation/PatternMatcher.ts
40
69
  var PatternMatcher = class _PatternMatcher {
41
70
  /**
42
71
  * Tests whether a glob-style pattern matches a value.
43
72
  * Supports `*` (any sequence of characters) and `?` (any single character).
44
- * Uses a linear-time algorithm to avoid ReDoS vulnerabilities.
73
+ * Uses a two-pointer algorithm to avoid ReDoS vulnerabilities and
74
+ * quadratic memory usage on long patterns/keys.
45
75
  */
46
76
  static matches(pattern, value) {
47
77
  return _PatternMatcher.matchLinear(pattern, value);
48
78
  }
49
79
  /**
50
- * Linear-time glob matching using dynamic programming.
51
- * Avoids catastrophic backtracking that RegExp-based glob matching can cause.
80
+ * Linear-time glob matching with O(1) extra memory.
52
81
  */
53
82
  static matchLinear(pattern, value) {
54
- const m = pattern.length;
55
- const n = value.length;
56
- const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(false));
57
- dp[0][0] = true;
58
- for (let i = 1; i <= m; i++) {
59
- if (pattern[i - 1] === "*") {
60
- dp[i][0] = dp[i - 1]?.[0];
83
+ let patternIndex = 0;
84
+ let valueIndex = 0;
85
+ let starIndex = -1;
86
+ let backtrackValueIndex = 0;
87
+ while (valueIndex < value.length) {
88
+ const patternChar = pattern[patternIndex];
89
+ const valueChar = value[valueIndex];
90
+ if (patternChar === "*" && patternIndex < pattern.length) {
91
+ starIndex = patternIndex;
92
+ patternIndex += 1;
93
+ backtrackValueIndex = valueIndex;
94
+ continue;
61
95
  }
62
- }
63
- for (let i = 1; i <= m; i++) {
64
- for (let j = 1; j <= n; j++) {
65
- const pc = pattern[i - 1];
66
- if (pc === "*") {
67
- dp[i][j] = dp[i - 1]?.[j] || dp[i]?.[j - 1];
68
- } else if (pc === "?" || pc === value[j - 1]) {
69
- dp[i][j] = dp[i - 1]?.[j - 1];
70
- }
96
+ if (patternChar === "?" || patternChar === valueChar) {
97
+ patternIndex += 1;
98
+ valueIndex += 1;
99
+ continue;
71
100
  }
101
+ if (starIndex !== -1) {
102
+ patternIndex = starIndex + 1;
103
+ backtrackValueIndex += 1;
104
+ valueIndex = backtrackValueIndex;
105
+ continue;
106
+ }
107
+ return false;
108
+ }
109
+ while (patternIndex < pattern.length && pattern[patternIndex] === "*") {
110
+ patternIndex += 1;
72
111
  }
73
- return dp[m]?.[n];
112
+ return patternIndex === pattern.length;
74
113
  }
75
114
  };
76
115
 
@@ -118,6 +157,19 @@ var RedisTagIndex = class {
118
157
  async keysForTag(tag) {
119
158
  return this.client.smembers(this.tagKeysKey(tag));
120
159
  }
160
+ async keysForPrefix(prefix) {
161
+ const matches = [];
162
+ let cursor = "0";
163
+ do {
164
+ const [nextCursor, keys] = await this.client.sscan(this.knownKeysKey(), cursor, "COUNT", this.scanCount);
165
+ cursor = nextCursor;
166
+ matches.push(...keys.filter((key) => key.startsWith(prefix)));
167
+ } while (cursor !== "0");
168
+ return matches;
169
+ }
170
+ async tagsForKey(key) {
171
+ return this.client.smembers(this.keyTagsKey(key));
172
+ }
121
173
  async matchPattern(pattern) {
122
174
  const matches = [];
123
175
  let cursor = "0";
@@ -211,7 +263,7 @@ async function main(argv = process.argv.slice(2)) {
211
263
  const tagIndex = new RedisTagIndex({ client: redis, prefix: args.tagIndexPrefix ?? "layercache:tag-index" });
212
264
  const keys2 = await tagIndex.keysForTag(args.tag);
213
265
  if (keys2.length > 0) {
214
- await redis.del(...keys2);
266
+ await batchDelete(redis, keys2);
215
267
  }
216
268
  process.stdout.write(`${JSON.stringify({ deletedKeys: keys2.length, tag: args.tag }, null, 2)}
217
269
  `);
@@ -219,12 +271,37 @@ async function main(argv = process.argv.slice(2)) {
219
271
  }
220
272
  const keys = await scanKeys(redis, args.pattern ?? "*");
221
273
  if (keys.length > 0) {
222
- await redis.del(...keys);
274
+ await batchDelete(redis, keys);
223
275
  }
224
276
  process.stdout.write(`${JSON.stringify({ deletedKeys: keys.length, pattern: args.pattern ?? "*" }, null, 2)}
225
277
  `);
226
278
  return;
227
279
  }
280
+ if (args.command === "inspect") {
281
+ if (!args.key) {
282
+ throw new Error("inspect requires --key <key>.");
283
+ }
284
+ const payload = await redis.getBuffer(args.key);
285
+ const ttl = await redis.ttl(args.key);
286
+ const decoded = decodeInspectablePayload(payload);
287
+ process.stdout.write(
288
+ `${JSON.stringify(
289
+ {
290
+ key: args.key,
291
+ exists: payload !== null,
292
+ ttlSeconds: ttl >= 0 ? ttl : null,
293
+ sizeBytes: payload?.byteLength ?? 0,
294
+ isEnvelope: isStoredValueEnvelope(decoded),
295
+ state: payload === null ? null : resolveStoredValue(decoded).state,
296
+ preview: summarizeInspectableValue(decoded)
297
+ },
298
+ null,
299
+ 2
300
+ )}
301
+ `
302
+ );
303
+ return;
304
+ }
228
305
  printUsage();
229
306
  process.exitCode = 1;
230
307
  } catch (error) {
@@ -265,6 +342,9 @@ function parseArgs(argv) {
265
342
  } else if (token === "--tag") {
266
343
  parsed.tag = value;
267
344
  index += 1;
345
+ } else if (token === "--key") {
346
+ parsed.key = value;
347
+ index += 1;
268
348
  } else if (token === "--tag-index-prefix") {
269
349
  parsed.tagIndexPrefix = value;
270
350
  index += 1;
@@ -272,6 +352,13 @@ function parseArgs(argv) {
272
352
  }
273
353
  return parsed;
274
354
  }
355
+ var BATCH_DELETE_SIZE = 500;
356
+ async function batchDelete(redis, keys) {
357
+ for (let i = 0; i < keys.length; i += BATCH_DELETE_SIZE) {
358
+ const batch = keys.slice(i, i + BATCH_DELETE_SIZE);
359
+ await redis.del(...batch);
360
+ }
361
+ }
275
362
  async function scanKeys(redis, pattern) {
276
363
  const keys = [];
277
364
  let cursor = "0";
@@ -284,9 +371,32 @@ async function scanKeys(redis, pattern) {
284
371
  }
285
372
  function printUsage() {
286
373
  process.stdout.write(
287
- "Usage:\n layercache stats --redis <url> [--pattern <glob>]\n layercache keys --redis <url> [--pattern <glob>]\n layercache invalidate --redis <url> [--pattern <glob> | --tag <tag>] [--tag-index-prefix <prefix>]\n\nOptions:\n --redis <url> Redis connection URL (e.g. redis://localhost:6379)\n --pattern <glob> Glob pattern to filter keys (default: *)\n --tag <tag> Invalidate by tag name\n --tag-index-prefix <prefix> Redis key prefix for tag index (default: layercache:tag-index)\n"
374
+ "Usage:\n layercache stats --redis <url> [--pattern <glob>]\n layercache keys --redis <url> [--pattern <glob>]\n layercache inspect --redis <url> --key <key>\n layercache invalidate --redis <url> [--pattern <glob> | --tag <tag>] [--tag-index-prefix <prefix>]\n\nOptions:\n --redis <url> Redis connection URL (e.g. redis://localhost:6379)\n --pattern <glob> Glob pattern to filter keys (default: *)\n --key <key> Exact cache key to inspect\n --tag <tag> Invalidate by tag name\n --tag-index-prefix <prefix> Redis key prefix for tag index (default: layercache:tag-index)\n"
288
375
  );
289
376
  }
377
+ function decodeInspectablePayload(payload) {
378
+ if (payload === null) {
379
+ return null;
380
+ }
381
+ const text = payload.toString("utf8");
382
+ try {
383
+ return JSON.parse(text);
384
+ } catch {
385
+ return text.length > 256 ? `${text.slice(0, 256)}...` : text;
386
+ }
387
+ }
388
+ function summarizeInspectableValue(value) {
389
+ if (isStoredValueEnvelope(value)) {
390
+ return {
391
+ kind: value.kind,
392
+ value: value.value,
393
+ freshUntil: value.freshUntil,
394
+ staleUntil: value.staleUntil,
395
+ errorUntil: value.errorUntil
396
+ };
397
+ }
398
+ return value;
399
+ }
290
400
  if (process.argv[1]?.includes("cli.")) {
291
401
  void main();
292
402
  }
package/dist/cli.js CHANGED
@@ -1,7 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  RedisTagIndex
4
- } from "./chunk-QUB5VZFZ.js";
4
+ } from "./chunk-GF47Y3XR.js";
5
+ import {
6
+ isStoredValueEnvelope,
7
+ resolveStoredValue
8
+ } from "./chunk-ZMDB5KOK.js";
5
9
 
6
10
  // src/cli.ts
7
11
  import Redis from "ioredis";
@@ -51,7 +55,7 @@ async function main(argv = process.argv.slice(2)) {
51
55
  const tagIndex = new RedisTagIndex({ client: redis, prefix: args.tagIndexPrefix ?? "layercache:tag-index" });
52
56
  const keys2 = await tagIndex.keysForTag(args.tag);
53
57
  if (keys2.length > 0) {
54
- await redis.del(...keys2);
58
+ await batchDelete(redis, keys2);
55
59
  }
56
60
  process.stdout.write(`${JSON.stringify({ deletedKeys: keys2.length, tag: args.tag }, null, 2)}
57
61
  `);
@@ -59,12 +63,37 @@ async function main(argv = process.argv.slice(2)) {
59
63
  }
60
64
  const keys = await scanKeys(redis, args.pattern ?? "*");
61
65
  if (keys.length > 0) {
62
- await redis.del(...keys);
66
+ await batchDelete(redis, keys);
63
67
  }
64
68
  process.stdout.write(`${JSON.stringify({ deletedKeys: keys.length, pattern: args.pattern ?? "*" }, null, 2)}
65
69
  `);
66
70
  return;
67
71
  }
72
+ if (args.command === "inspect") {
73
+ if (!args.key) {
74
+ throw new Error("inspect requires --key <key>.");
75
+ }
76
+ const payload = await redis.getBuffer(args.key);
77
+ const ttl = await redis.ttl(args.key);
78
+ const decoded = decodeInspectablePayload(payload);
79
+ process.stdout.write(
80
+ `${JSON.stringify(
81
+ {
82
+ key: args.key,
83
+ exists: payload !== null,
84
+ ttlSeconds: ttl >= 0 ? ttl : null,
85
+ sizeBytes: payload?.byteLength ?? 0,
86
+ isEnvelope: isStoredValueEnvelope(decoded),
87
+ state: payload === null ? null : resolveStoredValue(decoded).state,
88
+ preview: summarizeInspectableValue(decoded)
89
+ },
90
+ null,
91
+ 2
92
+ )}
93
+ `
94
+ );
95
+ return;
96
+ }
68
97
  printUsage();
69
98
  process.exitCode = 1;
70
99
  } catch (error) {
@@ -105,6 +134,9 @@ function parseArgs(argv) {
105
134
  } else if (token === "--tag") {
106
135
  parsed.tag = value;
107
136
  index += 1;
137
+ } else if (token === "--key") {
138
+ parsed.key = value;
139
+ index += 1;
108
140
  } else if (token === "--tag-index-prefix") {
109
141
  parsed.tagIndexPrefix = value;
110
142
  index += 1;
@@ -112,6 +144,13 @@ function parseArgs(argv) {
112
144
  }
113
145
  return parsed;
114
146
  }
147
+ var BATCH_DELETE_SIZE = 500;
148
+ async function batchDelete(redis, keys) {
149
+ for (let i = 0; i < keys.length; i += BATCH_DELETE_SIZE) {
150
+ const batch = keys.slice(i, i + BATCH_DELETE_SIZE);
151
+ await redis.del(...batch);
152
+ }
153
+ }
115
154
  async function scanKeys(redis, pattern) {
116
155
  const keys = [];
117
156
  let cursor = "0";
@@ -124,9 +163,32 @@ async function scanKeys(redis, pattern) {
124
163
  }
125
164
  function printUsage() {
126
165
  process.stdout.write(
127
- "Usage:\n layercache stats --redis <url> [--pattern <glob>]\n layercache keys --redis <url> [--pattern <glob>]\n layercache invalidate --redis <url> [--pattern <glob> | --tag <tag>] [--tag-index-prefix <prefix>]\n\nOptions:\n --redis <url> Redis connection URL (e.g. redis://localhost:6379)\n --pattern <glob> Glob pattern to filter keys (default: *)\n --tag <tag> Invalidate by tag name\n --tag-index-prefix <prefix> Redis key prefix for tag index (default: layercache:tag-index)\n"
166
+ "Usage:\n layercache stats --redis <url> [--pattern <glob>]\n layercache keys --redis <url> [--pattern <glob>]\n layercache inspect --redis <url> --key <key>\n layercache invalidate --redis <url> [--pattern <glob> | --tag <tag>] [--tag-index-prefix <prefix>]\n\nOptions:\n --redis <url> Redis connection URL (e.g. redis://localhost:6379)\n --pattern <glob> Glob pattern to filter keys (default: *)\n --key <key> Exact cache key to inspect\n --tag <tag> Invalidate by tag name\n --tag-index-prefix <prefix> Redis key prefix for tag index (default: layercache:tag-index)\n"
128
167
  );
129
168
  }
169
+ function decodeInspectablePayload(payload) {
170
+ if (payload === null) {
171
+ return null;
172
+ }
173
+ const text = payload.toString("utf8");
174
+ try {
175
+ return JSON.parse(text);
176
+ } catch {
177
+ return text.length > 256 ? `${text.slice(0, 256)}...` : text;
178
+ }
179
+ }
180
+ function summarizeInspectableValue(value) {
181
+ if (isStoredValueEnvelope(value)) {
182
+ return {
183
+ kind: value.kind,
184
+ value: value.value,
185
+ freshUntil: value.freshUntil,
186
+ staleUntil: value.staleUntil,
187
+ errorUntil: value.errorUntil
188
+ };
189
+ }
190
+ return value;
191
+ }
130
192
  if (process.argv[1]?.includes("cli.")) {
131
193
  void main();
132
194
  }