@happyvertical/cache 0.74.8
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/AGENT.md +33 -0
- package/LICENSE +7 -0
- package/README.md +161 -0
- package/dist/chunks/file-DyC_7WDS.js +450 -0
- package/dist/chunks/file-DyC_7WDS.js.map +1 -0
- package/dist/chunks/memory-C6vfNZYg.js +274 -0
- package/dist/chunks/memory-C6vfNZYg.js.map +1 -0
- package/dist/chunks/redis-D-SNLXE_.js +365 -0
- package/dist/chunks/redis-D-SNLXE_.js.map +1 -0
- package/dist/chunks/s3-ByokNFv_.js +427 -0
- package/dist/chunks/s3-ByokNFv_.js.map +1 -0
- package/dist/cli/claude-context.d.ts +3 -0
- package/dist/cli/claude-context.d.ts.map +1 -0
- package/dist/cli/claude-context.js +21 -0
- package/dist/cli/claude-context.js.map +1 -0
- package/dist/index.d.ts +72 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +170 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/file.d.ts +74 -0
- package/dist/providers/file.d.ts.map +1 -0
- package/dist/providers/memory.d.ts +50 -0
- package/dist/providers/memory.d.ts.map +1 -0
- package/dist/providers/redis.d.ts +37 -0
- package/dist/providers/redis.d.ts.map +1 -0
- package/dist/providers/s3.d.ts +52 -0
- package/dist/providers/s3.d.ts.map +1 -0
- package/dist/shared/types.d.ts +281 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/shared/utils.d.ts +63 -0
- package/dist/shared/utils.d.ts.map +1 -0
- package/metadata.json +35 -0
- package/package.json +72 -0
package/AGENT.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# @happyvertical/cache
|
|
2
|
+
|
|
3
|
+
<!-- BEGIN AGENT:GENERATED -->
|
|
4
|
+
## Purpose
|
|
5
|
+
Standardized caching interface supporting Memory, File, and Redis backends
|
|
6
|
+
|
|
7
|
+
## Package Map
|
|
8
|
+
- Package: `@happyvertical/cache`
|
|
9
|
+
- Hierarchy path: `@happyvertical/sdk > packages > cache`
|
|
10
|
+
- Workspace position: `5 of 30` local packages
|
|
11
|
+
- Internal dependencies: `@happyvertical/utils`
|
|
12
|
+
- Internal dependents: `@happyvertical/geo`, `@happyvertical/translator`
|
|
13
|
+
- Knowledge graph files: `AGENT.md`, `metadata.json`, `ecosystem-manifest.json`
|
|
14
|
+
|
|
15
|
+
## Build & Test
|
|
16
|
+
```bash
|
|
17
|
+
pnpm --filter @happyvertical/cache build
|
|
18
|
+
pnpm --filter @happyvertical/cache test
|
|
19
|
+
pnpm --filter @happyvertical/cache clean
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Agent Correction Loops
|
|
23
|
+
- If module resolution or export errors mention a workspace dependency, build the dependency first (`pnpm --filter @happyvertical/utils build`) and then rerun `pnpm --filter @happyvertical/cache build`.
|
|
24
|
+
- If tests or exports fail after API, type, or bundle changes, run `pnpm --filter @happyvertical/cache clean` followed by `pnpm --filter @happyvertical/cache build` and `pnpm --filter @happyvertical/cache test`.
|
|
25
|
+
- If failures span multiple packages or Turborepo ordering looks wrong, run `pnpm build` and `pnpm typecheck` from the repo root before retrying package-scoped commands.
|
|
26
|
+
|
|
27
|
+
## Ecosystem Relationships
|
|
28
|
+
- Provides: Standardized caching interface supporting Memory, File, and Redis backends
|
|
29
|
+
- Implements: none
|
|
30
|
+
- Requires: @happyvertical/utils, @aws-sdk/client-s3, @aws-sdk/credential-providers, redis
|
|
31
|
+
- Stability: stable (Primary package surface is described as implemented and production-oriented.)
|
|
32
|
+
<!-- END AGENT:GENERATED -->
|
|
33
|
+
|
package/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright <2025> <Happy Vertical Corporation>
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# @happyvertical/cache
|
|
2
|
+
|
|
3
|
+
Unified caching interface supporting Memory, File, Redis, and S3 backends with TTL, eviction policies, batch operations, and compression. All providers implement the same `CacheProvider` interface, so you can swap backends without changing application code.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @happyvertical/cache
|
|
9
|
+
# Requires @happyvertical/utils as a peer
|
|
10
|
+
# redis and @aws-sdk/client-s3 are bundled
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { getCache } from '@happyvertical/cache';
|
|
17
|
+
|
|
18
|
+
const cache = await getCache({
|
|
19
|
+
provider: 'memory',
|
|
20
|
+
maxSize: 100 * 1024 * 1024,
|
|
21
|
+
evictionPolicy: 'lru',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
await cache.set('user:123', { name: 'Alice' }, 3600); // TTL in seconds
|
|
25
|
+
const user = await cache.get('user:123');
|
|
26
|
+
await cache.delete('user:123');
|
|
27
|
+
await cache.close();
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Providers
|
|
31
|
+
|
|
32
|
+
### Memory
|
|
33
|
+
|
|
34
|
+
In-process `Map`-based cache. No external dependencies. Data lost on restart.
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
const cache = await getCache({
|
|
38
|
+
provider: 'memory',
|
|
39
|
+
namespace: 'app',
|
|
40
|
+
defaultTTL: 3600, // seconds
|
|
41
|
+
maxSize: 100 * 1024 * 1024, // bytes (default 100MB)
|
|
42
|
+
maxEntries: 10000, // default 10k
|
|
43
|
+
evictionPolicy: 'lru', // 'lru' | 'lfu' | 'fifo'
|
|
44
|
+
checkPeriod: 60000, // expiration sweep interval in ms
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### File
|
|
49
|
+
|
|
50
|
+
Stores entries as files on disk with optional gzip compression. Persists across restarts.
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
const cache = await getCache({
|
|
54
|
+
provider: 'file',
|
|
55
|
+
cacheDir: './cache', // required
|
|
56
|
+
compression: true, // gzip (default false)
|
|
57
|
+
maxSize: 500 * 1024 * 1024, // bytes (default 500MB)
|
|
58
|
+
fileExtension: '.cache',
|
|
59
|
+
checkPeriod: 300000, // cleanup interval in ms
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Redis
|
|
64
|
+
|
|
65
|
+
Distributed cache using the `redis` npm client. Supports optional gzip compression for large values.
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
const cache = await getCache({
|
|
69
|
+
provider: 'redis',
|
|
70
|
+
host: 'localhost',
|
|
71
|
+
port: 6379,
|
|
72
|
+
password: 'secret',
|
|
73
|
+
db: 0,
|
|
74
|
+
namespace: 'app',
|
|
75
|
+
enableCompression: true,
|
|
76
|
+
compressionThreshold: 1024, // bytes — only compress values larger than this
|
|
77
|
+
connectTimeout: 5000, // ms
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### S3
|
|
82
|
+
|
|
83
|
+
Stores entries as S3 objects. Useful for CI caches that must persist between runs. Compression enabled by default to reduce egress costs.
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
const cache = await getCache({
|
|
87
|
+
provider: 's3',
|
|
88
|
+
bucket: 'my-cache-bucket',
|
|
89
|
+
prefix: 'cache/',
|
|
90
|
+
region: 'us-east-1',
|
|
91
|
+
compression: true, // default true
|
|
92
|
+
compressionThreshold: 1024, // bytes
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## API
|
|
97
|
+
|
|
98
|
+
All providers implement `CacheProvider`:
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
interface CacheProvider {
|
|
102
|
+
get<T>(key: string): Promise<T | undefined>;
|
|
103
|
+
set<T>(key: string, value: T, ttl?: number): Promise<void>;
|
|
104
|
+
has(key: string): Promise<boolean>;
|
|
105
|
+
delete(key: string): Promise<boolean>;
|
|
106
|
+
clear(namespace?: string): Promise<void>;
|
|
107
|
+
keys(pattern?: string): Promise<string[]>;
|
|
108
|
+
getMany<T>(keys: string[]): Promise<Map<string, T>>;
|
|
109
|
+
setMany<T>(entries: Array<{ key: string; value: T; ttl?: number }>): Promise<void>;
|
|
110
|
+
deleteMany(keys: string[]): Promise<number>;
|
|
111
|
+
touch(key: string, ttl: number): Promise<boolean>;
|
|
112
|
+
getStats(): Promise<CacheStats>;
|
|
113
|
+
close(): Promise<void>;
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
- **TTL** is always in seconds. Omit for no expiration.
|
|
118
|
+
- **`keys()`** accepts glob patterns (`user:*`, `report:2024*`).
|
|
119
|
+
- **`touch()`** updates TTL without modifying the value. Returns `false` if the key doesn't exist.
|
|
120
|
+
- **Batch ops** (`getMany`, `setMany`, `deleteMany`) reduce round-trips, especially for Redis.
|
|
121
|
+
|
|
122
|
+
## Environment Variables
|
|
123
|
+
|
|
124
|
+
Configuration can be loaded from `HAVE_CACHE_*` environment variables via `@happyvertical/utils/loadEnvConfig`. Programmatic options always take precedence.
|
|
125
|
+
|
|
126
|
+
| Variable | Maps to | Notes |
|
|
127
|
+
|----------|---------|-------|
|
|
128
|
+
| `HAVE_CACHE_PROVIDER` | `provider` | `memory`, `file`, `redis`, `s3` |
|
|
129
|
+
| `HAVE_CACHE_NAMESPACE` | `namespace` | |
|
|
130
|
+
| `HAVE_CACHE_DEFAULT_TTL` | `defaultTTL` | seconds |
|
|
131
|
+
| `HAVE_CACHE_MAX_SIZE` | `maxSize` | bytes (memory/file) |
|
|
132
|
+
| `HAVE_CACHE_MAX_ENTRIES` | `maxEntries` | memory only |
|
|
133
|
+
| `HAVE_CACHE_EVICTION_POLICY` | `evictionPolicy` | memory only |
|
|
134
|
+
| `HAVE_CACHE_CACHE_DIR` | `cacheDir` | file only |
|
|
135
|
+
| `HAVE_CACHE_COMPRESSION` | `compression` | file/s3 |
|
|
136
|
+
| `HAVE_CACHE_HOST` | `host` | redis only |
|
|
137
|
+
| `HAVE_CACHE_PORT` | `port` | redis only |
|
|
138
|
+
| `HAVE_CACHE_PASSWORD` | `password` | redis only |
|
|
139
|
+
| `HAVE_CACHE_BUCKET` | `bucket` | s3 only |
|
|
140
|
+
| `HAVE_CACHE_PREFIX` | `prefix` | s3 only |
|
|
141
|
+
| `HAVE_CACHE_REGION` | `region` | s3 only |
|
|
142
|
+
|
|
143
|
+
## Error Classes
|
|
144
|
+
|
|
145
|
+
All errors extend `CacheError(message, code, provider)`:
|
|
146
|
+
|
|
147
|
+
- `CacheKeyError` — invalid key (empty or >250 chars)
|
|
148
|
+
- `CacheConnectionError` — backend unreachable (Redis)
|
|
149
|
+
- `CacheSizeError` — entry would exceed `maxSize`
|
|
150
|
+
- `CacheSerializationError` — JSON serialize/deserialize failure
|
|
151
|
+
|
|
152
|
+
## Exported Utilities
|
|
153
|
+
|
|
154
|
+
Low-level helpers re-exported from `shared/utils`:
|
|
155
|
+
|
|
156
|
+
- `isValidKey(key)` — check key length constraints
|
|
157
|
+
- `calculateSize(value)` — approximate JSON byte size
|
|
158
|
+
- `matchesPattern(pattern, str)` — glob matching
|
|
159
|
+
- `formatKey(namespace, key)` / `extractKey(namespace, fullKey)` — namespace prefixing
|
|
160
|
+
- `isExpired(expiresAt)` / `calculateExpiration(ttl)` — TTL math
|
|
161
|
+
- `serialize(value)` / `deserialize(json)` — JSON wrappers
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import { readFile, rm, stat, mkdir, readdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { resolve, join } from "node:path";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { gunzip, gzip } from "node:zlib";
|
|
5
|
+
import { isValidKey, CacheKeyError, formatKey, deserialize, isExpired, CacheError, calculateExpiration, calculateSize, extractKey, matchesPattern, serialize, CacheSerializationError, CacheSizeError } from "../index.js";
|
|
6
|
+
const gzipAsync = promisify(gzip);
|
|
7
|
+
const gunzipAsync = promisify(gunzip);
|
|
8
|
+
class FileProvider {
|
|
9
|
+
cacheDir;
|
|
10
|
+
namespace;
|
|
11
|
+
defaultTTL;
|
|
12
|
+
maxSize;
|
|
13
|
+
compression;
|
|
14
|
+
fileExtension;
|
|
15
|
+
checkPeriod;
|
|
16
|
+
checkInterval;
|
|
17
|
+
stats;
|
|
18
|
+
constructor(options) {
|
|
19
|
+
this.cacheDir = resolve(options.cacheDir);
|
|
20
|
+
this.namespace = options.namespace;
|
|
21
|
+
this.defaultTTL = options.defaultTTL;
|
|
22
|
+
this.maxSize = options.maxSize || 500 * 1024 * 1024;
|
|
23
|
+
this.compression = options.compression ?? false;
|
|
24
|
+
this.fileExtension = options.fileExtension || ".cache";
|
|
25
|
+
this.checkPeriod = options.checkPeriod || 3e5;
|
|
26
|
+
this.stats = {
|
|
27
|
+
hits: 0,
|
|
28
|
+
misses: 0,
|
|
29
|
+
evictions: 0
|
|
30
|
+
};
|
|
31
|
+
this.ensureCacheDir();
|
|
32
|
+
this.startCleanup();
|
|
33
|
+
}
|
|
34
|
+
async get(key) {
|
|
35
|
+
if (!isValidKey(key)) {
|
|
36
|
+
throw new CacheKeyError(key, "file");
|
|
37
|
+
}
|
|
38
|
+
const fullKey = formatKey(this.namespace, key);
|
|
39
|
+
const filePath = this.getFilePath(fullKey);
|
|
40
|
+
try {
|
|
41
|
+
const fileContent = await readFile(filePath);
|
|
42
|
+
let data;
|
|
43
|
+
if (this.compression) {
|
|
44
|
+
data = await gunzipAsync(fileContent);
|
|
45
|
+
} else {
|
|
46
|
+
data = fileContent;
|
|
47
|
+
}
|
|
48
|
+
const entry = deserialize(data.toString("utf-8"));
|
|
49
|
+
if (isExpired(entry.expiresAt)) {
|
|
50
|
+
await rm(filePath, { force: true });
|
|
51
|
+
this.stats.misses++;
|
|
52
|
+
return void 0;
|
|
53
|
+
}
|
|
54
|
+
entry.hits++;
|
|
55
|
+
this.stats.hits++;
|
|
56
|
+
await this.writeEntry(filePath, entry);
|
|
57
|
+
return entry.value;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (error.code === "ENOENT") {
|
|
60
|
+
this.stats.misses++;
|
|
61
|
+
return void 0;
|
|
62
|
+
}
|
|
63
|
+
throw new CacheError(
|
|
64
|
+
`Failed to read cache entry: ${error.message}`,
|
|
65
|
+
"READ_ERROR",
|
|
66
|
+
"file"
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async set(key, value, ttl) {
|
|
71
|
+
if (!isValidKey(key)) {
|
|
72
|
+
throw new CacheKeyError(key, "file");
|
|
73
|
+
}
|
|
74
|
+
const fullKey = formatKey(this.namespace, key);
|
|
75
|
+
const filePath = this.getFilePath(fullKey);
|
|
76
|
+
const expiresAt = calculateExpiration(ttl ?? this.defaultTTL);
|
|
77
|
+
const entry = {
|
|
78
|
+
value,
|
|
79
|
+
createdAt: Date.now(),
|
|
80
|
+
expiresAt,
|
|
81
|
+
size: calculateSize(value),
|
|
82
|
+
hits: 0,
|
|
83
|
+
metadata: {
|
|
84
|
+
compressed: this.compression,
|
|
85
|
+
namespace: this.namespace
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
await this.evictIfNeeded(entry.size);
|
|
89
|
+
await this.writeEntry(filePath, entry);
|
|
90
|
+
}
|
|
91
|
+
async has(key) {
|
|
92
|
+
if (!isValidKey(key)) {
|
|
93
|
+
throw new CacheKeyError(key, "file");
|
|
94
|
+
}
|
|
95
|
+
const fullKey = formatKey(this.namespace, key);
|
|
96
|
+
const filePath = this.getFilePath(fullKey);
|
|
97
|
+
try {
|
|
98
|
+
const fileContent = await readFile(filePath);
|
|
99
|
+
let data;
|
|
100
|
+
if (this.compression) {
|
|
101
|
+
data = await gunzipAsync(fileContent);
|
|
102
|
+
} else {
|
|
103
|
+
data = fileContent;
|
|
104
|
+
}
|
|
105
|
+
const entry = deserialize(data.toString("utf-8"));
|
|
106
|
+
if (isExpired(entry.expiresAt)) {
|
|
107
|
+
await rm(filePath, { force: true });
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
return true;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error.code === "ENOENT") {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
throw new CacheError(
|
|
116
|
+
`Failed to check cache entry: ${error.message}`,
|
|
117
|
+
"CHECK_ERROR",
|
|
118
|
+
"file"
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async delete(key) {
|
|
123
|
+
if (!isValidKey(key)) {
|
|
124
|
+
throw new CacheKeyError(key, "file");
|
|
125
|
+
}
|
|
126
|
+
const fullKey = formatKey(this.namespace, key);
|
|
127
|
+
const filePath = this.getFilePath(fullKey);
|
|
128
|
+
try {
|
|
129
|
+
await rm(filePath);
|
|
130
|
+
return true;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (error.code === "ENOENT") {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
throw new CacheError(
|
|
136
|
+
`Failed to delete cache entry: ${error.message}`,
|
|
137
|
+
"DELETE_ERROR",
|
|
138
|
+
"file"
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async clear(namespace) {
|
|
143
|
+
if (namespace) {
|
|
144
|
+
const prefix = this.sanitizeKey(`${namespace}:`);
|
|
145
|
+
const files = await this.getAllFiles();
|
|
146
|
+
for (const file of files) {
|
|
147
|
+
if (file.startsWith(prefix)) {
|
|
148
|
+
await rm(join(this.cacheDir, file), { force: true });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
try {
|
|
153
|
+
await rm(this.cacheDir, { recursive: true, force: true });
|
|
154
|
+
await this.ensureCacheDir();
|
|
155
|
+
this.stats.hits = 0;
|
|
156
|
+
this.stats.misses = 0;
|
|
157
|
+
this.stats.evictions = 0;
|
|
158
|
+
} catch (error) {
|
|
159
|
+
throw new CacheError(
|
|
160
|
+
`Failed to clear cache: ${error.message}`,
|
|
161
|
+
"CLEAR_ERROR",
|
|
162
|
+
"file"
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async keys(pattern) {
|
|
168
|
+
const files = await this.getAllFiles();
|
|
169
|
+
const keys = [];
|
|
170
|
+
for (const file of files) {
|
|
171
|
+
const key = file.replace(this.fileExtension, "");
|
|
172
|
+
const filePath = join(this.cacheDir, file);
|
|
173
|
+
try {
|
|
174
|
+
const fileContent = await readFile(filePath);
|
|
175
|
+
let data;
|
|
176
|
+
if (this.compression) {
|
|
177
|
+
data = await gunzipAsync(fileContent);
|
|
178
|
+
} else {
|
|
179
|
+
data = fileContent;
|
|
180
|
+
}
|
|
181
|
+
const entry = deserialize(data.toString("utf-8"));
|
|
182
|
+
if (!isExpired(entry.expiresAt)) {
|
|
183
|
+
const desanitized = this.desanitizeKey(key);
|
|
184
|
+
keys.push(extractKey(this.namespace, desanitized));
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (pattern) {
|
|
190
|
+
return keys.filter((key) => matchesPattern(pattern, key));
|
|
191
|
+
}
|
|
192
|
+
return keys;
|
|
193
|
+
}
|
|
194
|
+
async getMany(keys) {
|
|
195
|
+
const result = /* @__PURE__ */ new Map();
|
|
196
|
+
for (const key of keys) {
|
|
197
|
+
const value = await this.get(key);
|
|
198
|
+
if (value !== void 0) {
|
|
199
|
+
result.set(key, value);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
async setMany(entries) {
|
|
205
|
+
for (const entry of entries) {
|
|
206
|
+
await this.set(entry.key, entry.value, entry.ttl);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
async deleteMany(keys) {
|
|
210
|
+
let deleted = 0;
|
|
211
|
+
for (const key of keys) {
|
|
212
|
+
const wasDeleted = await this.delete(key);
|
|
213
|
+
if (wasDeleted) {
|
|
214
|
+
deleted++;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return deleted;
|
|
218
|
+
}
|
|
219
|
+
async getStats() {
|
|
220
|
+
const files = await this.getAllFiles();
|
|
221
|
+
let totalSize = 0;
|
|
222
|
+
let entries = 0;
|
|
223
|
+
for (const file of files) {
|
|
224
|
+
const filePath = join(this.cacheDir, file);
|
|
225
|
+
try {
|
|
226
|
+
const stats = await stat(filePath);
|
|
227
|
+
totalSize += stats.size;
|
|
228
|
+
const fileContent = await readFile(filePath);
|
|
229
|
+
let data;
|
|
230
|
+
if (this.compression) {
|
|
231
|
+
data = await gunzipAsync(fileContent);
|
|
232
|
+
} else {
|
|
233
|
+
data = fileContent;
|
|
234
|
+
}
|
|
235
|
+
const entry = deserialize(data.toString("utf-8"));
|
|
236
|
+
if (!isExpired(entry.expiresAt)) {
|
|
237
|
+
entries++;
|
|
238
|
+
}
|
|
239
|
+
} catch {
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const totalAccesses = this.stats.hits + this.stats.misses;
|
|
243
|
+
const hitRate = totalAccesses > 0 ? this.stats.hits / totalAccesses : 0;
|
|
244
|
+
return {
|
|
245
|
+
entries,
|
|
246
|
+
totalSize,
|
|
247
|
+
hits: this.stats.hits,
|
|
248
|
+
misses: this.stats.misses,
|
|
249
|
+
hitRate,
|
|
250
|
+
evictions: this.stats.evictions,
|
|
251
|
+
backend: {
|
|
252
|
+
type: "file",
|
|
253
|
+
cacheDir: this.cacheDir,
|
|
254
|
+
compression: this.compression,
|
|
255
|
+
maxSize: this.maxSize
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
async touch(key, ttl) {
|
|
260
|
+
if (!isValidKey(key)) {
|
|
261
|
+
throw new CacheKeyError(key, "file");
|
|
262
|
+
}
|
|
263
|
+
const fullKey = formatKey(this.namespace, key);
|
|
264
|
+
const filePath = this.getFilePath(fullKey);
|
|
265
|
+
try {
|
|
266
|
+
const fileContent = await readFile(filePath);
|
|
267
|
+
let data;
|
|
268
|
+
if (this.compression) {
|
|
269
|
+
data = await gunzipAsync(fileContent);
|
|
270
|
+
} else {
|
|
271
|
+
data = fileContent;
|
|
272
|
+
}
|
|
273
|
+
const entry = deserialize(data.toString("utf-8"));
|
|
274
|
+
if (isExpired(entry.expiresAt)) {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
entry.expiresAt = calculateExpiration(ttl);
|
|
278
|
+
await this.writeEntry(filePath, entry);
|
|
279
|
+
return true;
|
|
280
|
+
} catch (error) {
|
|
281
|
+
if (error.code === "ENOENT") {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
throw new CacheError(
|
|
285
|
+
`Failed to touch cache entry: ${error.message}`,
|
|
286
|
+
"TOUCH_ERROR",
|
|
287
|
+
"file"
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
async close() {
|
|
292
|
+
if (this.checkInterval) {
|
|
293
|
+
clearInterval(this.checkInterval);
|
|
294
|
+
this.checkInterval = void 0;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Ensures cache directory exists
|
|
299
|
+
*/
|
|
300
|
+
async ensureCacheDir() {
|
|
301
|
+
try {
|
|
302
|
+
await mkdir(this.cacheDir, { recursive: true });
|
|
303
|
+
} catch (error) {
|
|
304
|
+
throw new CacheError(
|
|
305
|
+
`Failed to create cache directory: ${error.message}`,
|
|
306
|
+
"INIT_ERROR",
|
|
307
|
+
"file"
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Gets the file path for a cache key
|
|
313
|
+
*/
|
|
314
|
+
getFilePath(key) {
|
|
315
|
+
const sanitizedKey = this.sanitizeKey(key);
|
|
316
|
+
return join(this.cacheDir, `${sanitizedKey}${this.fileExtension}`);
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Sanitizes a key for use as a filename
|
|
320
|
+
*/
|
|
321
|
+
sanitizeKey(key) {
|
|
322
|
+
return key.replace(/[^a-zA-Z0-9_:-]/g, "_");
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Desanitizes a filename back to the original key
|
|
326
|
+
*/
|
|
327
|
+
desanitizeKey(sanitized) {
|
|
328
|
+
return sanitized;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Gets all cache file names
|
|
332
|
+
*/
|
|
333
|
+
async getAllFiles() {
|
|
334
|
+
try {
|
|
335
|
+
const files = await readdir(this.cacheDir);
|
|
336
|
+
return files.filter((file) => file.endsWith(this.fileExtension));
|
|
337
|
+
} catch (error) {
|
|
338
|
+
if (error.code === "ENOENT") {
|
|
339
|
+
return [];
|
|
340
|
+
}
|
|
341
|
+
throw new CacheError(
|
|
342
|
+
`Failed to list cache files: ${error.message}`,
|
|
343
|
+
"LIST_ERROR",
|
|
344
|
+
"file"
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Writes an entry to a file
|
|
350
|
+
*/
|
|
351
|
+
async writeEntry(filePath, entry) {
|
|
352
|
+
try {
|
|
353
|
+
let data;
|
|
354
|
+
const json = serialize(entry);
|
|
355
|
+
data = Buffer.from(json, "utf-8");
|
|
356
|
+
if (this.compression) {
|
|
357
|
+
data = await gzipAsync(data);
|
|
358
|
+
}
|
|
359
|
+
await writeFile(filePath, data);
|
|
360
|
+
} catch (error) {
|
|
361
|
+
throw new CacheSerializationError(
|
|
362
|
+
`Failed to write cache entry: ${error.message}`,
|
|
363
|
+
"file"
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Evicts files if size limit is exceeded
|
|
369
|
+
*/
|
|
370
|
+
async evictIfNeeded(newEntrySize) {
|
|
371
|
+
const stats = await this.getStats();
|
|
372
|
+
if (stats.totalSize + newEntrySize > this.maxSize) {
|
|
373
|
+
await this.evict();
|
|
374
|
+
const updatedStats = await this.getStats();
|
|
375
|
+
if (updatedStats.totalSize + newEntrySize > this.maxSize) {
|
|
376
|
+
throw new CacheSizeError(
|
|
377
|
+
`Cannot cache entry: would exceed max size of ${this.maxSize} bytes`,
|
|
378
|
+
"file"
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Evicts oldest files based on creation time
|
|
385
|
+
*/
|
|
386
|
+
async evict() {
|
|
387
|
+
const files = await this.getAllFiles();
|
|
388
|
+
const filesWithStats = [];
|
|
389
|
+
for (const file of files) {
|
|
390
|
+
const filePath = join(this.cacheDir, file);
|
|
391
|
+
try {
|
|
392
|
+
const fileContent = await readFile(filePath);
|
|
393
|
+
let data;
|
|
394
|
+
if (this.compression) {
|
|
395
|
+
data = await gunzipAsync(fileContent);
|
|
396
|
+
} else {
|
|
397
|
+
data = fileContent;
|
|
398
|
+
}
|
|
399
|
+
const entry = deserialize(data.toString("utf-8"));
|
|
400
|
+
filesWithStats.push({ file, createdAt: entry.createdAt });
|
|
401
|
+
} catch {
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
filesWithStats.sort((a, b) => a.createdAt - b.createdAt);
|
|
405
|
+
const toRemove = Math.max(1, Math.floor(filesWithStats.length * 0.1));
|
|
406
|
+
for (let i = 0; i < toRemove; i++) {
|
|
407
|
+
const filePath = join(this.cacheDir, filesWithStats[i].file);
|
|
408
|
+
await rm(filePath, { force: true });
|
|
409
|
+
this.stats.evictions++;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Starts background cleanup of expired files
|
|
414
|
+
*/
|
|
415
|
+
startCleanup() {
|
|
416
|
+
this.checkInterval = setInterval(() => {
|
|
417
|
+
this.removeExpiredFiles();
|
|
418
|
+
}, this.checkPeriod);
|
|
419
|
+
if (this.checkInterval.unref) {
|
|
420
|
+
this.checkInterval.unref();
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Removes expired files
|
|
425
|
+
*/
|
|
426
|
+
async removeExpiredFiles() {
|
|
427
|
+
const files = await this.getAllFiles();
|
|
428
|
+
for (const file of files) {
|
|
429
|
+
const filePath = join(this.cacheDir, file);
|
|
430
|
+
try {
|
|
431
|
+
const fileContent = await readFile(filePath);
|
|
432
|
+
let data;
|
|
433
|
+
if (this.compression) {
|
|
434
|
+
data = await gunzipAsync(fileContent);
|
|
435
|
+
} else {
|
|
436
|
+
data = fileContent;
|
|
437
|
+
}
|
|
438
|
+
const entry = deserialize(data.toString("utf-8"));
|
|
439
|
+
if (isExpired(entry.expiresAt)) {
|
|
440
|
+
await rm(filePath, { force: true });
|
|
441
|
+
}
|
|
442
|
+
} catch {
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
export {
|
|
448
|
+
FileProvider
|
|
449
|
+
};
|
|
450
|
+
//# sourceMappingURL=file-DyC_7WDS.js.map
|