@geekmidas/cache 1.0.0 → 1.1.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/CHANGELOG.md +10 -0
- package/README.md +82 -2
- package/dist/file.cjs +101 -0
- package/dist/file.cjs.map +1 -0
- package/dist/file.d.cts +27 -0
- package/dist/file.d.cts.map +1 -0
- package/dist/file.d.mts +27 -0
- package/dist/file.d.mts.map +1 -0
- package/dist/file.mjs +100 -0
- package/dist/file.mjs.map +1 -0
- package/package.json +11 -3
- package/src/__tests__/file.spec.ts +192 -0
- package/src/file.ts +126 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# @geekmidas/cache
|
|
2
2
|
|
|
3
|
+
## 1.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- ✨ [`be4f7a9`](https://github.com/geekmidas/toolbox/commit/be4f7a9bd5de7f08adbca582916d6902e0c24de2) Thanks [@geekmidas](https://github.com/geekmidas)! - Add partition support for manifest generation. Users can now group constructs (routes, functions, crons, subscribers) into named partitions by providing a `partition` callback per construct type in the config. Manifests output partitioned fields as `Record<string, T[]>` while remaining flat `T[]` arrays when no partitions are configured.
|
|
8
|
+
|
|
9
|
+
Fix mutation type inference in endpoint hooks by using `UseMutationResult` and `UseQueryResult` types directly instead of `ReturnType<typeof useMutation>`, which could resolve to `never` for complex path definitions.
|
|
10
|
+
|
|
11
|
+
Add `FileCache` implementation that persists cache entries to a JSON file on disk. Default location is `process.cwd()/.gkm/cache.json`. Uses an in-process mutex combined with `proper-lockfile` for safe concurrent and cross-process writes.
|
|
12
|
+
|
|
3
13
|
## 1.0.0
|
|
4
14
|
|
|
5
15
|
### Major Changes
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ A type-safe, flexible caching library for TypeScript applications with support f
|
|
|
6
6
|
|
|
7
7
|
- **Type-safe**: Full TypeScript support with generics for strongly-typed cache values
|
|
8
8
|
- **Unified interface**: Common `Cache<T>` interface for all implementations
|
|
9
|
-
- **Multiple backends**: In-memory, Redis (Upstash),
|
|
9
|
+
- **Multiple backends**: In-memory, Redis (Upstash), Expo Secure Store, and file-based implementations
|
|
10
10
|
- **Async API**: Promise-based interface for consistency across implementations
|
|
11
11
|
- **Flexible TTL**: Support for time-to-live across all implementations
|
|
12
12
|
- **Easy testing**: Simple interface makes mocking and testing straightforward
|
|
@@ -31,6 +31,8 @@ For React Native support via Expo:
|
|
|
31
31
|
npm install expo-secure-store
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
+
The file-based cache includes all required dependencies automatically.
|
|
35
|
+
|
|
34
36
|
## Quick Start
|
|
35
37
|
|
|
36
38
|
### In-Memory Cache
|
|
@@ -77,6 +79,35 @@ const user = await cache.get('user:123'); // User | undefined
|
|
|
77
79
|
await cache.delete('user:123');
|
|
78
80
|
```
|
|
79
81
|
|
|
82
|
+
### File Cache
|
|
83
|
+
|
|
84
|
+
Persistent file-based cache for CLI tools and build systems. Data is stored as JSON on disk and survives process restarts:
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
import { FileCache } from '@geekmidas/cache/file';
|
|
88
|
+
|
|
89
|
+
// Default path: process.cwd()/.gkm/cache.json
|
|
90
|
+
const cache = new FileCache();
|
|
91
|
+
|
|
92
|
+
// Custom path and TTL
|
|
93
|
+
const cache = new FileCache({
|
|
94
|
+
path: '/tmp/my-app/cache.json',
|
|
95
|
+
ttl: 3600, // 1 hour default TTL
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Set a value with TTL (in seconds)
|
|
99
|
+
await cache.set('build:hash', 'abc123', 300);
|
|
100
|
+
|
|
101
|
+
// Get a value
|
|
102
|
+
const hash = await cache.get('build:hash'); // string | undefined
|
|
103
|
+
|
|
104
|
+
// Check remaining TTL
|
|
105
|
+
const remaining = await cache.ttl('build:hash'); // seconds
|
|
106
|
+
|
|
107
|
+
// Delete a value
|
|
108
|
+
await cache.delete('build:hash');
|
|
109
|
+
```
|
|
110
|
+
|
|
80
111
|
## API Reference
|
|
81
112
|
|
|
82
113
|
### Cache Interface
|
|
@@ -214,6 +245,44 @@ await cache.set('user:1', {
|
|
|
214
245
|
const user = await cache.get('user:1');
|
|
215
246
|
```
|
|
216
247
|
|
|
248
|
+
### FileCache
|
|
249
|
+
|
|
250
|
+
Persistent file-based cache that stores entries as JSON on disk.
|
|
251
|
+
|
|
252
|
+
```typescript
|
|
253
|
+
import { FileCache } from '@geekmidas/cache/file';
|
|
254
|
+
|
|
255
|
+
class FileCache implements Cache
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
#### Constructor
|
|
259
|
+
|
|
260
|
+
```typescript
|
|
261
|
+
new FileCache(options?: FileCacheOptions)
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
- `path`: Path to the cache JSON file (default: `process.cwd()/.gkm/cache.json`)
|
|
265
|
+
- `ttl`: Default TTL in seconds (default: `600`)
|
|
266
|
+
|
|
267
|
+
#### Features
|
|
268
|
+
|
|
269
|
+
- ✅ Persistent storage across process restarts
|
|
270
|
+
- ✅ TTL support with automatic expiration
|
|
271
|
+
- ✅ Safe concurrent writes (in-process mutex + cross-process file locking)
|
|
272
|
+
- ✅ Auto-creates parent directories
|
|
273
|
+
- ✅ No external services required
|
|
274
|
+
- ❌ Not suitable for high-throughput applications
|
|
275
|
+
- ❌ Not suitable for multi-machine distributed caching
|
|
276
|
+
|
|
277
|
+
#### Example
|
|
278
|
+
|
|
279
|
+
```typescript
|
|
280
|
+
const cache = new FileCache({ path: '/tmp/app-cache.json', ttl: 3600 });
|
|
281
|
+
await cache.set('config', { theme: 'dark' });
|
|
282
|
+
const config = await cache.get('config'); // { theme: 'dark' }
|
|
283
|
+
const remaining = await cache.ttl('config'); // ~3600
|
|
284
|
+
```
|
|
285
|
+
|
|
217
286
|
### ExpoSecureCache
|
|
218
287
|
|
|
219
288
|
Secure storage for React Native apps using Expo SecureStore.
|
|
@@ -347,11 +416,15 @@ Create cache instances based on configuration:
|
|
|
347
416
|
|
|
348
417
|
```typescript
|
|
349
418
|
interface CacheConfig {
|
|
350
|
-
type: 'memory' | 'redis' | 'expo';
|
|
419
|
+
type: 'memory' | 'redis' | 'file' | 'expo';
|
|
351
420
|
redis?: {
|
|
352
421
|
url: string;
|
|
353
422
|
token: string;
|
|
354
423
|
};
|
|
424
|
+
file?: {
|
|
425
|
+
path?: string;
|
|
426
|
+
ttl?: number;
|
|
427
|
+
};
|
|
355
428
|
}
|
|
356
429
|
|
|
357
430
|
function createCache<T>(config: CacheConfig): Cache<T> {
|
|
@@ -361,6 +434,8 @@ function createCache<T>(config: CacheConfig): Cache<T> {
|
|
|
361
434
|
case 'redis':
|
|
362
435
|
if (!config.redis) throw new Error('Redis config required');
|
|
363
436
|
return new UpstashCache<T>(config.redis.url, config.redis.token);
|
|
437
|
+
case 'file':
|
|
438
|
+
return new FileCache(config.file);
|
|
364
439
|
case 'expo':
|
|
365
440
|
return new ExpoSecureCache<T>();
|
|
366
441
|
default:
|
|
@@ -478,6 +553,11 @@ describe('UserService', () => {
|
|
|
478
553
|
- **Cons**: Network latency, external dependency
|
|
479
554
|
- **Best for**: Production, multi-instance apps, shared cache
|
|
480
555
|
|
|
556
|
+
### FileCache
|
|
557
|
+
- **Pros**: Persistent, no external services, cross-process safe
|
|
558
|
+
- **Cons**: Disk I/O latency, not suitable for high throughput
|
|
559
|
+
- **Best for**: CLI tools, build systems, local development caching
|
|
560
|
+
|
|
481
561
|
### ExpoSecureCache
|
|
482
562
|
- **Pros**: Secure, encrypted, persistent on device
|
|
483
563
|
- **Cons**: React Native only, storage limits, slower operations
|
package/dist/file.cjs
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
const require_chunk = require('./chunk-CUT6urMc.cjs');
|
|
2
|
+
const require_src = require('./src-zfYb7mdS.cjs');
|
|
3
|
+
const date_fns = require_chunk.__toESM(require("date-fns"));
|
|
4
|
+
const node_fs = require_chunk.__toESM(require("node:fs"));
|
|
5
|
+
const node_fs_promises = require_chunk.__toESM(require("node:fs/promises"));
|
|
6
|
+
const node_path = require_chunk.__toESM(require("node:path"));
|
|
7
|
+
const proper_lockfile = require_chunk.__toESM(require("proper-lockfile"));
|
|
8
|
+
|
|
9
|
+
//#region src/file.ts
|
|
10
|
+
var FileCache = class {
|
|
11
|
+
path;
|
|
12
|
+
defaultTtl;
|
|
13
|
+
/** In-process mutex to serialize concurrent writes within the same process. */
|
|
14
|
+
mutex = Promise.resolve();
|
|
15
|
+
constructor(options = {}) {
|
|
16
|
+
this.path = options.path ?? (0, node_path.join)(process.cwd(), ".gkm", "cache.json");
|
|
17
|
+
this.defaultTtl = options.ttl ?? 600;
|
|
18
|
+
}
|
|
19
|
+
async get(key) {
|
|
20
|
+
const data = await this.readData();
|
|
21
|
+
const entry = data[key];
|
|
22
|
+
if (!entry) return void 0;
|
|
23
|
+
const remaining = require_src.getExpirationInSeconds(entry.expiresAt);
|
|
24
|
+
if (remaining <= 0) {
|
|
25
|
+
await this.withLock(async (current) => {
|
|
26
|
+
delete current[key];
|
|
27
|
+
return current;
|
|
28
|
+
});
|
|
29
|
+
return void 0;
|
|
30
|
+
}
|
|
31
|
+
return entry.value;
|
|
32
|
+
}
|
|
33
|
+
async set(key, value, ttl) {
|
|
34
|
+
const seconds = ttl ?? this.defaultTtl;
|
|
35
|
+
const expiresAt = (0, date_fns.addSeconds)(/* @__PURE__ */ new Date(), seconds).toISOString();
|
|
36
|
+
await this.withLock(async (data) => {
|
|
37
|
+
data[key] = {
|
|
38
|
+
value,
|
|
39
|
+
expiresAt
|
|
40
|
+
};
|
|
41
|
+
return data;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
async delete(key) {
|
|
45
|
+
await this.withLock(async (data) => {
|
|
46
|
+
delete data[key];
|
|
47
|
+
return data;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
async ttl(key) {
|
|
51
|
+
const data = await this.readData();
|
|
52
|
+
const entry = data[key];
|
|
53
|
+
if (!entry) return 0;
|
|
54
|
+
return require_src.getExpirationInSeconds(entry.expiresAt);
|
|
55
|
+
}
|
|
56
|
+
async readData() {
|
|
57
|
+
try {
|
|
58
|
+
const content = await (0, node_fs_promises.readFile)(this.path, "utf-8");
|
|
59
|
+
return JSON.parse(content);
|
|
60
|
+
} catch {
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async ensureDir() {
|
|
65
|
+
const dir = (0, node_path.dirname)(this.path);
|
|
66
|
+
if (!(0, node_fs.existsSync)(dir)) await (0, node_fs_promises.mkdir)(dir, { recursive: true });
|
|
67
|
+
}
|
|
68
|
+
async withLock(fn) {
|
|
69
|
+
const prev = this.mutex;
|
|
70
|
+
let resolve;
|
|
71
|
+
this.mutex = new Promise((r) => {
|
|
72
|
+
resolve = r;
|
|
73
|
+
});
|
|
74
|
+
try {
|
|
75
|
+
await prev;
|
|
76
|
+
await this.ensureDir();
|
|
77
|
+
if (!(0, node_fs.existsSync)(this.path)) await (0, node_fs_promises.writeFile)(this.path, "{}");
|
|
78
|
+
const release = await proper_lockfile.default.lock(this.path, {
|
|
79
|
+
retries: {
|
|
80
|
+
retries: 3,
|
|
81
|
+
minTimeout: 100,
|
|
82
|
+
maxTimeout: 1e3
|
|
83
|
+
},
|
|
84
|
+
stale: 1e4
|
|
85
|
+
});
|
|
86
|
+
try {
|
|
87
|
+
const data = await this.readData();
|
|
88
|
+
const updated = await fn(data);
|
|
89
|
+
await (0, node_fs_promises.writeFile)(this.path, JSON.stringify(updated, null, 2));
|
|
90
|
+
} finally {
|
|
91
|
+
await release();
|
|
92
|
+
}
|
|
93
|
+
} finally {
|
|
94
|
+
resolve();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
//#endregion
|
|
100
|
+
exports.FileCache = FileCache;
|
|
101
|
+
//# sourceMappingURL=file.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"file.cjs","names":["options: FileCacheOptions","key: string","value: T","ttl?: number","fn: (data: FileCacheData) => Promise<FileCacheData>","resolve: () => void"],"sources":["../src/file.ts"],"sourcesContent":["import { existsSync } from 'node:fs';\nimport { mkdir, readFile, writeFile } from 'node:fs/promises';\nimport { dirname, join } from 'node:path';\nimport { addSeconds } from 'date-fns';\nimport lockfile from 'proper-lockfile';\nimport { type Cache, getExpirationInSeconds } from './';\n\ninterface FileCacheEntry {\n\tvalue: unknown;\n\texpiresAt: string;\n}\n\ntype FileCacheData = Record<string, FileCacheEntry>;\n\nexport interface FileCacheOptions {\n\t/** Path to the cache JSON file. Default: `process.cwd()/.gkm/cache.json` */\n\tpath?: string;\n\t/** Default TTL in seconds. Default: 600 */\n\tttl?: number;\n}\n\nexport class FileCache implements Cache {\n\tprivate readonly path: string;\n\tprivate readonly defaultTtl: number;\n\t/** In-process mutex to serialize concurrent writes within the same process. */\n\tprivate mutex: Promise<void> = Promise.resolve();\n\n\tconstructor(options: FileCacheOptions = {}) {\n\t\tthis.path = options.path ?? join(process.cwd(), '.gkm', 'cache.json');\n\t\tthis.defaultTtl = options.ttl ?? 600;\n\t}\n\n\tasync get<T>(key: string): Promise<T | undefined> {\n\t\tconst data = await this.readData();\n\t\tconst entry = data[key];\n\t\tif (!entry) return undefined;\n\n\t\tconst remaining = getExpirationInSeconds(entry.expiresAt);\n\t\tif (remaining <= 0) {\n\t\t\tawait this.withLock(async (current) => {\n\t\t\t\tdelete current[key];\n\t\t\t\treturn current;\n\t\t\t});\n\t\t\treturn undefined;\n\t\t}\n\n\t\treturn entry.value as T;\n\t}\n\n\tasync set<T>(key: string, value: T, ttl?: number): Promise<void> {\n\t\tconst seconds = ttl ?? this.defaultTtl;\n\t\tconst expiresAt = addSeconds(new Date(), seconds).toISOString();\n\n\t\tawait this.withLock(async (data) => {\n\t\t\tdata[key] = { value, expiresAt };\n\t\t\treturn data;\n\t\t});\n\t}\n\n\tasync delete(key: string): Promise<void> {\n\t\tawait this.withLock(async (data) => {\n\t\t\tdelete data[key];\n\t\t\treturn data;\n\t\t});\n\t}\n\n\tasync ttl(key: string): Promise<number> {\n\t\tconst data = await this.readData();\n\t\tconst entry = data[key];\n\t\tif (!entry) return 0;\n\n\t\treturn getExpirationInSeconds(entry.expiresAt);\n\t}\n\n\tprivate async readData(): Promise<FileCacheData> {\n\t\ttry {\n\t\t\tconst content = await readFile(this.path, 'utf-8');\n\t\t\treturn JSON.parse(content) as FileCacheData;\n\t\t} catch {\n\t\t\treturn {};\n\t\t}\n\t}\n\n\tprivate async ensureDir(): Promise<void> {\n\t\tconst dir = dirname(this.path);\n\t\tif (!existsSync(dir)) {\n\t\t\tawait mkdir(dir, { recursive: true });\n\t\t}\n\t}\n\n\tprivate async withLock(\n\t\tfn: (data: FileCacheData) => Promise<FileCacheData>,\n\t): Promise<void> {\n\t\t// Queue behind any pending in-process operations\n\t\tconst prev = this.mutex;\n\t\tlet resolve: () => void;\n\t\tthis.mutex = new Promise<void>((r) => {\n\t\t\tresolve = r;\n\t\t});\n\n\t\ttry {\n\t\t\tawait prev;\n\t\t\tawait this.ensureDir();\n\n\t\t\t// Ensure file exists before locking (proper-lockfile requires it)\n\t\t\tif (!existsSync(this.path)) {\n\t\t\t\tawait writeFile(this.path, '{}');\n\t\t\t}\n\n\t\t\tconst release = await lockfile.lock(this.path, {\n\t\t\t\tretries: { retries: 3, minTimeout: 100, maxTimeout: 1000 },\n\t\t\t\tstale: 10000,\n\t\t\t});\n\n\t\t\ttry {\n\t\t\t\tconst data = await this.readData();\n\t\t\t\tconst updated = await fn(data);\n\t\t\t\tawait writeFile(this.path, JSON.stringify(updated, null, 2));\n\t\t\t} finally {\n\t\t\t\tawait release();\n\t\t\t}\n\t\t} finally {\n\t\t\tresolve!();\n\t\t}\n\t}\n}\n"],"mappings":";;;;;;;;;AAqBA,IAAa,YAAb,MAAwC;CACvC,AAAiB;CACjB,AAAiB;;CAEjB,AAAQ,QAAuB,QAAQ,SAAS;CAEhD,YAAYA,UAA4B,CAAE,GAAE;AAC3C,OAAK,OAAO,QAAQ,QAAQ,oBAAK,QAAQ,KAAK,EAAE,QAAQ,aAAa;AACrE,OAAK,aAAa,QAAQ,OAAO;CACjC;CAED,MAAM,IAAOC,KAAqC;EACjD,MAAM,OAAO,MAAM,KAAK,UAAU;EAClC,MAAM,QAAQ,KAAK;AACnB,OAAK,MAAO;EAEZ,MAAM,YAAY,mCAAuB,MAAM,UAAU;AACzD,MAAI,aAAa,GAAG;AACnB,SAAM,KAAK,SAAS,OAAO,YAAY;AACtC,WAAO,QAAQ;AACf,WAAO;GACP,EAAC;AACF;EACA;AAED,SAAO,MAAM;CACb;CAED,MAAM,IAAOA,KAAaC,OAAUC,KAA6B;EAChE,MAAM,UAAU,OAAO,KAAK;EAC5B,MAAM,YAAY,yCAAW,IAAI,QAAQ,QAAQ,CAAC,aAAa;AAE/D,QAAM,KAAK,SAAS,OAAO,SAAS;AACnC,QAAK,OAAO;IAAE;IAAO;GAAW;AAChC,UAAO;EACP,EAAC;CACF;CAED,MAAM,OAAOF,KAA4B;AACxC,QAAM,KAAK,SAAS,OAAO,SAAS;AACnC,UAAO,KAAK;AACZ,UAAO;EACP,EAAC;CACF;CAED,MAAM,IAAIA,KAA8B;EACvC,MAAM,OAAO,MAAM,KAAK,UAAU;EAClC,MAAM,QAAQ,KAAK;AACnB,OAAK,MAAO,QAAO;AAEnB,SAAO,mCAAuB,MAAM,UAAU;CAC9C;CAED,MAAc,WAAmC;AAChD,MAAI;GACH,MAAM,UAAU,MAAM,+BAAS,KAAK,MAAM,QAAQ;AAClD,UAAO,KAAK,MAAM,QAAQ;EAC1B,QAAO;AACP,UAAO,CAAE;EACT;CACD;CAED,MAAc,YAA2B;EACxC,MAAM,MAAM,uBAAQ,KAAK,KAAK;AAC9B,OAAK,wBAAW,IAAI,CACnB,OAAM,4BAAM,KAAK,EAAE,WAAW,KAAM,EAAC;CAEtC;CAED,MAAc,SACbG,IACgB;EAEhB,MAAM,OAAO,KAAK;EAClB,IAAIC;AACJ,OAAK,QAAQ,IAAI,QAAc,CAAC,MAAM;AACrC,aAAU;EACV;AAED,MAAI;AACH,SAAM;AACN,SAAM,KAAK,WAAW;AAGtB,QAAK,wBAAW,KAAK,KAAK,CACzB,OAAM,gCAAU,KAAK,MAAM,KAAK;GAGjC,MAAM,UAAU,MAAM,wBAAS,KAAK,KAAK,MAAM;IAC9C,SAAS;KAAE,SAAS;KAAG,YAAY;KAAK,YAAY;IAAM;IAC1D,OAAO;GACP,EAAC;AAEF,OAAI;IACH,MAAM,OAAO,MAAM,KAAK,UAAU;IAClC,MAAM,UAAU,MAAM,GAAG,KAAK;AAC9B,UAAM,gCAAU,KAAK,MAAM,KAAK,UAAU,SAAS,MAAM,EAAE,CAAC;GAC5D,UAAS;AACT,UAAM,SAAS;GACf;EACD,UAAS;AACT,YAAU;EACV;CACD;AACD"}
|
package/dist/file.d.cts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Cache } from "./index-BSYYdwRm.cjs";
|
|
2
|
+
|
|
3
|
+
//#region src/file.d.ts
|
|
4
|
+
interface FileCacheOptions {
|
|
5
|
+
/** Path to the cache JSON file. Default: `process.cwd()/.gkm/cache.json` */
|
|
6
|
+
path?: string;
|
|
7
|
+
/** Default TTL in seconds. Default: 600 */
|
|
8
|
+
ttl?: number;
|
|
9
|
+
}
|
|
10
|
+
declare class FileCache implements Cache {
|
|
11
|
+
private readonly path;
|
|
12
|
+
private readonly defaultTtl;
|
|
13
|
+
/** In-process mutex to serialize concurrent writes within the same process. */
|
|
14
|
+
private mutex;
|
|
15
|
+
constructor(options?: FileCacheOptions);
|
|
16
|
+
get<T>(key: string): Promise<T | undefined>;
|
|
17
|
+
set<T>(key: string, value: T, ttl?: number): Promise<void>;
|
|
18
|
+
delete(key: string): Promise<void>;
|
|
19
|
+
ttl(key: string): Promise<number>;
|
|
20
|
+
private readData;
|
|
21
|
+
private ensureDir;
|
|
22
|
+
private withLock;
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=file.d.ts.map
|
|
25
|
+
//#endregion
|
|
26
|
+
export { FileCache, FileCacheOptions };
|
|
27
|
+
//# sourceMappingURL=file.d.cts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"file.d.cts","names":[],"sources":["../src/file.ts"],"sourcesContent":[],"mappings":";;;UAciB,gBAAA;;EAAA,IAAA,CAAA,EAAA,MAAA;EAOJ;EAAU,GAAA,CAAA,EAAA,MAAA;;AAWa,cAXvB,SAAA,YAAqB,KAWE,CAAA;EAAC,iBAAT,IAAA;EAAO,iBAiBD,UAAA;EAAC;EAAwB,QAU/B,KAAA;EAAO,WAOV,CAAA,OAAA,CAAA,EAvCH,gBAuCG;EAAO,GA7CE,CAAA,CAAA,CAAA,CAAA,GAAA,EAAA,MAAA,CAAA,EAWN,OAXM,CAWE,CAXF,GAAA,SAAA,CAAA;EAAK,GAAA,CAAA,CAAA,CAAA,CAAA,GAAA,EAAA,MAAA,EAAA,KAAA,EA4BL,CA5BK,EAAA,GAAA,CAAA,EAAA,MAAA,CAAA,EA4Ba,OA5Bb,CAAA,IAAA,CAAA;uBAsCX;oBAOH"}
|
package/dist/file.d.mts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Cache } from "./index-Dx96gr-9.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/file.d.ts
|
|
4
|
+
interface FileCacheOptions {
|
|
5
|
+
/** Path to the cache JSON file. Default: `process.cwd()/.gkm/cache.json` */
|
|
6
|
+
path?: string;
|
|
7
|
+
/** Default TTL in seconds. Default: 600 */
|
|
8
|
+
ttl?: number;
|
|
9
|
+
}
|
|
10
|
+
declare class FileCache implements Cache {
|
|
11
|
+
private readonly path;
|
|
12
|
+
private readonly defaultTtl;
|
|
13
|
+
/** In-process mutex to serialize concurrent writes within the same process. */
|
|
14
|
+
private mutex;
|
|
15
|
+
constructor(options?: FileCacheOptions);
|
|
16
|
+
get<T>(key: string): Promise<T | undefined>;
|
|
17
|
+
set<T>(key: string, value: T, ttl?: number): Promise<void>;
|
|
18
|
+
delete(key: string): Promise<void>;
|
|
19
|
+
ttl(key: string): Promise<number>;
|
|
20
|
+
private readData;
|
|
21
|
+
private ensureDir;
|
|
22
|
+
private withLock;
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=file.d.ts.map
|
|
25
|
+
//#endregion
|
|
26
|
+
export { FileCache, FileCacheOptions };
|
|
27
|
+
//# sourceMappingURL=file.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"file.d.mts","names":[],"sources":["../src/file.ts"],"sourcesContent":[],"mappings":";;;UAciB,gBAAA;;EAAA,IAAA,CAAA,EAAA,MAAA;EAOJ;EAAU,GAAA,CAAA,EAAA,MAAA;;AAWa,cAXvB,SAAA,YAAqB,KAWE,CAAA;EAAC,iBAAT,IAAA;EAAO,iBAiBD,UAAA;EAAC;EAAwB,QAU/B,KAAA;EAAO,WAOV,CAAA,OAAA,CAAA,EAvCH,gBAuCG;EAAO,GA7CE,CAAA,CAAA,CAAA,CAAA,GAAA,EAAA,MAAA,CAAA,EAWN,OAXM,CAWE,CAXF,GAAA,SAAA,CAAA;EAAK,GAAA,CAAA,CAAA,CAAA,CAAA,GAAA,EAAA,MAAA,EAAA,KAAA,EA4BL,CA5BK,EAAA,GAAA,CAAA,EAAA,MAAA,CAAA,EA4Ba,OA5Bb,CAAA,IAAA,CAAA;uBAsCX;oBAOH"}
|
package/dist/file.mjs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { getExpirationInSeconds } from "./src-CrE4Nl5T.mjs";
|
|
2
|
+
import { addSeconds } from "date-fns";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import lockfile from "proper-lockfile";
|
|
7
|
+
|
|
8
|
+
//#region src/file.ts
|
|
9
|
+
var FileCache = class {
|
|
10
|
+
path;
|
|
11
|
+
defaultTtl;
|
|
12
|
+
/** In-process mutex to serialize concurrent writes within the same process. */
|
|
13
|
+
mutex = Promise.resolve();
|
|
14
|
+
constructor(options = {}) {
|
|
15
|
+
this.path = options.path ?? join(process.cwd(), ".gkm", "cache.json");
|
|
16
|
+
this.defaultTtl = options.ttl ?? 600;
|
|
17
|
+
}
|
|
18
|
+
async get(key) {
|
|
19
|
+
const data = await this.readData();
|
|
20
|
+
const entry = data[key];
|
|
21
|
+
if (!entry) return void 0;
|
|
22
|
+
const remaining = getExpirationInSeconds(entry.expiresAt);
|
|
23
|
+
if (remaining <= 0) {
|
|
24
|
+
await this.withLock(async (current) => {
|
|
25
|
+
delete current[key];
|
|
26
|
+
return current;
|
|
27
|
+
});
|
|
28
|
+
return void 0;
|
|
29
|
+
}
|
|
30
|
+
return entry.value;
|
|
31
|
+
}
|
|
32
|
+
async set(key, value, ttl) {
|
|
33
|
+
const seconds = ttl ?? this.defaultTtl;
|
|
34
|
+
const expiresAt = addSeconds(/* @__PURE__ */ new Date(), seconds).toISOString();
|
|
35
|
+
await this.withLock(async (data) => {
|
|
36
|
+
data[key] = {
|
|
37
|
+
value,
|
|
38
|
+
expiresAt
|
|
39
|
+
};
|
|
40
|
+
return data;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
async delete(key) {
|
|
44
|
+
await this.withLock(async (data) => {
|
|
45
|
+
delete data[key];
|
|
46
|
+
return data;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
async ttl(key) {
|
|
50
|
+
const data = await this.readData();
|
|
51
|
+
const entry = data[key];
|
|
52
|
+
if (!entry) return 0;
|
|
53
|
+
return getExpirationInSeconds(entry.expiresAt);
|
|
54
|
+
}
|
|
55
|
+
async readData() {
|
|
56
|
+
try {
|
|
57
|
+
const content = await readFile(this.path, "utf-8");
|
|
58
|
+
return JSON.parse(content);
|
|
59
|
+
} catch {
|
|
60
|
+
return {};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async ensureDir() {
|
|
64
|
+
const dir = dirname(this.path);
|
|
65
|
+
if (!existsSync(dir)) await mkdir(dir, { recursive: true });
|
|
66
|
+
}
|
|
67
|
+
async withLock(fn) {
|
|
68
|
+
const prev = this.mutex;
|
|
69
|
+
let resolve;
|
|
70
|
+
this.mutex = new Promise((r) => {
|
|
71
|
+
resolve = r;
|
|
72
|
+
});
|
|
73
|
+
try {
|
|
74
|
+
await prev;
|
|
75
|
+
await this.ensureDir();
|
|
76
|
+
if (!existsSync(this.path)) await writeFile(this.path, "{}");
|
|
77
|
+
const release = await lockfile.lock(this.path, {
|
|
78
|
+
retries: {
|
|
79
|
+
retries: 3,
|
|
80
|
+
minTimeout: 100,
|
|
81
|
+
maxTimeout: 1e3
|
|
82
|
+
},
|
|
83
|
+
stale: 1e4
|
|
84
|
+
});
|
|
85
|
+
try {
|
|
86
|
+
const data = await this.readData();
|
|
87
|
+
const updated = await fn(data);
|
|
88
|
+
await writeFile(this.path, JSON.stringify(updated, null, 2));
|
|
89
|
+
} finally {
|
|
90
|
+
await release();
|
|
91
|
+
}
|
|
92
|
+
} finally {
|
|
93
|
+
resolve();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
//#endregion
|
|
99
|
+
export { FileCache };
|
|
100
|
+
//# sourceMappingURL=file.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"file.mjs","names":["options: FileCacheOptions","key: string","value: T","ttl?: number","fn: (data: FileCacheData) => Promise<FileCacheData>","resolve: () => void"],"sources":["../src/file.ts"],"sourcesContent":["import { existsSync } from 'node:fs';\nimport { mkdir, readFile, writeFile } from 'node:fs/promises';\nimport { dirname, join } from 'node:path';\nimport { addSeconds } from 'date-fns';\nimport lockfile from 'proper-lockfile';\nimport { type Cache, getExpirationInSeconds } from './';\n\ninterface FileCacheEntry {\n\tvalue: unknown;\n\texpiresAt: string;\n}\n\ntype FileCacheData = Record<string, FileCacheEntry>;\n\nexport interface FileCacheOptions {\n\t/** Path to the cache JSON file. Default: `process.cwd()/.gkm/cache.json` */\n\tpath?: string;\n\t/** Default TTL in seconds. Default: 600 */\n\tttl?: number;\n}\n\nexport class FileCache implements Cache {\n\tprivate readonly path: string;\n\tprivate readonly defaultTtl: number;\n\t/** In-process mutex to serialize concurrent writes within the same process. */\n\tprivate mutex: Promise<void> = Promise.resolve();\n\n\tconstructor(options: FileCacheOptions = {}) {\n\t\tthis.path = options.path ?? join(process.cwd(), '.gkm', 'cache.json');\n\t\tthis.defaultTtl = options.ttl ?? 600;\n\t}\n\n\tasync get<T>(key: string): Promise<T | undefined> {\n\t\tconst data = await this.readData();\n\t\tconst entry = data[key];\n\t\tif (!entry) return undefined;\n\n\t\tconst remaining = getExpirationInSeconds(entry.expiresAt);\n\t\tif (remaining <= 0) {\n\t\t\tawait this.withLock(async (current) => {\n\t\t\t\tdelete current[key];\n\t\t\t\treturn current;\n\t\t\t});\n\t\t\treturn undefined;\n\t\t}\n\n\t\treturn entry.value as T;\n\t}\n\n\tasync set<T>(key: string, value: T, ttl?: number): Promise<void> {\n\t\tconst seconds = ttl ?? this.defaultTtl;\n\t\tconst expiresAt = addSeconds(new Date(), seconds).toISOString();\n\n\t\tawait this.withLock(async (data) => {\n\t\t\tdata[key] = { value, expiresAt };\n\t\t\treturn data;\n\t\t});\n\t}\n\n\tasync delete(key: string): Promise<void> {\n\t\tawait this.withLock(async (data) => {\n\t\t\tdelete data[key];\n\t\t\treturn data;\n\t\t});\n\t}\n\n\tasync ttl(key: string): Promise<number> {\n\t\tconst data = await this.readData();\n\t\tconst entry = data[key];\n\t\tif (!entry) return 0;\n\n\t\treturn getExpirationInSeconds(entry.expiresAt);\n\t}\n\n\tprivate async readData(): Promise<FileCacheData> {\n\t\ttry {\n\t\t\tconst content = await readFile(this.path, 'utf-8');\n\t\t\treturn JSON.parse(content) as FileCacheData;\n\t\t} catch {\n\t\t\treturn {};\n\t\t}\n\t}\n\n\tprivate async ensureDir(): Promise<void> {\n\t\tconst dir = dirname(this.path);\n\t\tif (!existsSync(dir)) {\n\t\t\tawait mkdir(dir, { recursive: true });\n\t\t}\n\t}\n\n\tprivate async withLock(\n\t\tfn: (data: FileCacheData) => Promise<FileCacheData>,\n\t): Promise<void> {\n\t\t// Queue behind any pending in-process operations\n\t\tconst prev = this.mutex;\n\t\tlet resolve: () => void;\n\t\tthis.mutex = new Promise<void>((r) => {\n\t\t\tresolve = r;\n\t\t});\n\n\t\ttry {\n\t\t\tawait prev;\n\t\t\tawait this.ensureDir();\n\n\t\t\t// Ensure file exists before locking (proper-lockfile requires it)\n\t\t\tif (!existsSync(this.path)) {\n\t\t\t\tawait writeFile(this.path, '{}');\n\t\t\t}\n\n\t\t\tconst release = await lockfile.lock(this.path, {\n\t\t\t\tretries: { retries: 3, minTimeout: 100, maxTimeout: 1000 },\n\t\t\t\tstale: 10000,\n\t\t\t});\n\n\t\t\ttry {\n\t\t\t\tconst data = await this.readData();\n\t\t\t\tconst updated = await fn(data);\n\t\t\t\tawait writeFile(this.path, JSON.stringify(updated, null, 2));\n\t\t\t} finally {\n\t\t\t\tawait release();\n\t\t\t}\n\t\t} finally {\n\t\t\tresolve!();\n\t\t}\n\t}\n}\n"],"mappings":";;;;;;;;AAqBA,IAAa,YAAb,MAAwC;CACvC,AAAiB;CACjB,AAAiB;;CAEjB,AAAQ,QAAuB,QAAQ,SAAS;CAEhD,YAAYA,UAA4B,CAAE,GAAE;AAC3C,OAAK,OAAO,QAAQ,QAAQ,KAAK,QAAQ,KAAK,EAAE,QAAQ,aAAa;AACrE,OAAK,aAAa,QAAQ,OAAO;CACjC;CAED,MAAM,IAAOC,KAAqC;EACjD,MAAM,OAAO,MAAM,KAAK,UAAU;EAClC,MAAM,QAAQ,KAAK;AACnB,OAAK,MAAO;EAEZ,MAAM,YAAY,uBAAuB,MAAM,UAAU;AACzD,MAAI,aAAa,GAAG;AACnB,SAAM,KAAK,SAAS,OAAO,YAAY;AACtC,WAAO,QAAQ;AACf,WAAO;GACP,EAAC;AACF;EACA;AAED,SAAO,MAAM;CACb;CAED,MAAM,IAAOA,KAAaC,OAAUC,KAA6B;EAChE,MAAM,UAAU,OAAO,KAAK;EAC5B,MAAM,YAAY,2BAAW,IAAI,QAAQ,QAAQ,CAAC,aAAa;AAE/D,QAAM,KAAK,SAAS,OAAO,SAAS;AACnC,QAAK,OAAO;IAAE;IAAO;GAAW;AAChC,UAAO;EACP,EAAC;CACF;CAED,MAAM,OAAOF,KAA4B;AACxC,QAAM,KAAK,SAAS,OAAO,SAAS;AACnC,UAAO,KAAK;AACZ,UAAO;EACP,EAAC;CACF;CAED,MAAM,IAAIA,KAA8B;EACvC,MAAM,OAAO,MAAM,KAAK,UAAU;EAClC,MAAM,QAAQ,KAAK;AACnB,OAAK,MAAO,QAAO;AAEnB,SAAO,uBAAuB,MAAM,UAAU;CAC9C;CAED,MAAc,WAAmC;AAChD,MAAI;GACH,MAAM,UAAU,MAAM,SAAS,KAAK,MAAM,QAAQ;AAClD,UAAO,KAAK,MAAM,QAAQ;EAC1B,QAAO;AACP,UAAO,CAAE;EACT;CACD;CAED,MAAc,YAA2B;EACxC,MAAM,MAAM,QAAQ,KAAK,KAAK;AAC9B,OAAK,WAAW,IAAI,CACnB,OAAM,MAAM,KAAK,EAAE,WAAW,KAAM,EAAC;CAEtC;CAED,MAAc,SACbG,IACgB;EAEhB,MAAM,OAAO,KAAK;EAClB,IAAIC;AACJ,OAAK,QAAQ,IAAI,QAAc,CAAC,MAAM;AACrC,aAAU;EACV;AAED,MAAI;AACH,SAAM;AACN,SAAM,KAAK,WAAW;AAGtB,QAAK,WAAW,KAAK,KAAK,CACzB,OAAM,UAAU,KAAK,MAAM,KAAK;GAGjC,MAAM,UAAU,MAAM,SAAS,KAAK,KAAK,MAAM;IAC9C,SAAS;KAAE,SAAS;KAAG,YAAY;KAAK,YAAY;IAAM;IAC1D,OAAO;GACP,EAAC;AAEF,OAAI;IACH,MAAM,OAAO,MAAM,KAAK,UAAU;IAClC,MAAM,UAAU,MAAM,GAAG,KAAK;AAC9B,UAAM,UAAU,KAAK,MAAM,KAAK,UAAU,SAAS,MAAM,EAAE,CAAC;GAC5D,UAAS;AACT,UAAM,SAAS;GACf;EACD,UAAS;AACT,YAAU;EACV;CACD;AACD"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geekmidas/cache",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Unified cache interface with multiple storage implementations",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -24,6 +24,11 @@
|
|
|
24
24
|
"types": "./dist/expo.d.ts",
|
|
25
25
|
"import": "./dist/expo.mjs",
|
|
26
26
|
"require": "./dist/expo.cjs"
|
|
27
|
+
},
|
|
28
|
+
"./file": {
|
|
29
|
+
"types": "./dist/file.d.ts",
|
|
30
|
+
"import": "./dist/file.mjs",
|
|
31
|
+
"require": "./dist/file.cjs"
|
|
27
32
|
}
|
|
28
33
|
},
|
|
29
34
|
"repository": {
|
|
@@ -35,11 +40,14 @@
|
|
|
35
40
|
"access": "public"
|
|
36
41
|
},
|
|
37
42
|
"dependencies": {
|
|
38
|
-
"date-fns": "~4.1.0"
|
|
43
|
+
"date-fns": "~4.1.0",
|
|
44
|
+
"proper-lockfile": "^4.1.2"
|
|
39
45
|
},
|
|
40
|
-
"devDependencies": {},
|
|
41
46
|
"peerDependencies": {
|
|
42
47
|
"@upstash/redis": "~1.35.1",
|
|
43
48
|
"expo-secure-store": "^14.2.3"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/proper-lockfile": "^4.1.4"
|
|
44
52
|
}
|
|
45
53
|
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { FileCache } from '../file';
|
|
7
|
+
|
|
8
|
+
describe('FileCache', () => {
|
|
9
|
+
let dir: string;
|
|
10
|
+
let cachePath: string;
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
dir = await mkdtemp(join(tmpdir(), 'file-cache-test-'));
|
|
14
|
+
cachePath = join(dir, 'cache.json');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
vi.useRealTimers();
|
|
19
|
+
await rm(dir, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('get', () => {
|
|
23
|
+
it('should return undefined for non-existent key', async () => {
|
|
24
|
+
const cache = new FileCache({ path: cachePath });
|
|
25
|
+
const result = await cache.get('non-existent');
|
|
26
|
+
expect(result).toBeUndefined();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should return undefined when file does not exist', async () => {
|
|
30
|
+
const cache = new FileCache({
|
|
31
|
+
path: join(dir, 'subdir', 'cache.json'),
|
|
32
|
+
});
|
|
33
|
+
const result = await cache.get('any-key');
|
|
34
|
+
expect(result).toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should return stored value', async () => {
|
|
38
|
+
const cache = new FileCache({ path: cachePath });
|
|
39
|
+
await cache.set('key', 'hello');
|
|
40
|
+
const result = await cache.get('key');
|
|
41
|
+
expect(result).toBe('hello');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should handle different data types', async () => {
|
|
45
|
+
const cache = new FileCache({ path: cachePath });
|
|
46
|
+
|
|
47
|
+
await cache.set('string', 'value');
|
|
48
|
+
await cache.set('number', 42);
|
|
49
|
+
await cache.set('boolean', true);
|
|
50
|
+
await cache.set('object', { id: 1, name: 'test' });
|
|
51
|
+
await cache.set('array', [1, 2, 3]);
|
|
52
|
+
await cache.set('null', null);
|
|
53
|
+
|
|
54
|
+
expect(await cache.get('string')).toBe('value');
|
|
55
|
+
expect(await cache.get('number')).toBe(42);
|
|
56
|
+
expect(await cache.get('boolean')).toBe(true);
|
|
57
|
+
expect(await cache.get('object')).toEqual({ id: 1, name: 'test' });
|
|
58
|
+
expect(await cache.get('array')).toEqual([1, 2, 3]);
|
|
59
|
+
expect(await cache.get('null')).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should return undefined for expired entries', async () => {
|
|
63
|
+
const cache = new FileCache({ path: cachePath });
|
|
64
|
+
|
|
65
|
+
vi.useFakeTimers();
|
|
66
|
+
await cache.set('expiring', 'value', 1);
|
|
67
|
+
|
|
68
|
+
// Advance past expiration
|
|
69
|
+
vi.advanceTimersByTime(2000);
|
|
70
|
+
|
|
71
|
+
const result = await cache.get('expiring');
|
|
72
|
+
expect(result).toBeUndefined();
|
|
73
|
+
|
|
74
|
+
vi.useRealTimers();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('set', () => {
|
|
79
|
+
it('should create file and parent directory', async () => {
|
|
80
|
+
const nested = join(dir, 'nested', 'dir', 'cache.json');
|
|
81
|
+
const cache = new FileCache({ path: nested });
|
|
82
|
+
|
|
83
|
+
await cache.set('key', 'value');
|
|
84
|
+
|
|
85
|
+
expect(existsSync(nested)).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should overwrite existing value', async () => {
|
|
89
|
+
const cache = new FileCache({ path: cachePath });
|
|
90
|
+
|
|
91
|
+
await cache.set('key', 'first');
|
|
92
|
+
await cache.set('key', 'second');
|
|
93
|
+
|
|
94
|
+
expect(await cache.get('key')).toBe('second');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should persist data in valid JSON format', async () => {
|
|
98
|
+
const cache = new FileCache({ path: cachePath });
|
|
99
|
+
|
|
100
|
+
await cache.set('test', { hello: 'world' });
|
|
101
|
+
|
|
102
|
+
const raw = await readFile(cachePath, 'utf-8');
|
|
103
|
+
const parsed = JSON.parse(raw);
|
|
104
|
+
|
|
105
|
+
expect(parsed.test).toBeDefined();
|
|
106
|
+
expect(parsed.test.value).toEqual({ hello: 'world' });
|
|
107
|
+
expect(parsed.test.expiresAt).toBeDefined();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should use custom default TTL', async () => {
|
|
111
|
+
const cache = new FileCache({ path: cachePath, ttl: 3600 });
|
|
112
|
+
|
|
113
|
+
await cache.set('key', 'value');
|
|
114
|
+
|
|
115
|
+
const remaining = await cache.ttl('key');
|
|
116
|
+
expect(remaining).toBeGreaterThan(3500);
|
|
117
|
+
expect(remaining).toBeLessThanOrEqual(3600);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('delete', () => {
|
|
122
|
+
it('should remove existing key', async () => {
|
|
123
|
+
const cache = new FileCache({ path: cachePath });
|
|
124
|
+
|
|
125
|
+
await cache.set('key', 'value');
|
|
126
|
+
await cache.delete('key');
|
|
127
|
+
|
|
128
|
+
expect(await cache.get('key')).toBeUndefined();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should not throw for non-existent key', async () => {
|
|
132
|
+
const cache = new FileCache({ path: cachePath });
|
|
133
|
+
await expect(cache.delete('non-existent')).resolves.not.toThrow();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should only delete specified key', async () => {
|
|
137
|
+
const cache = new FileCache({ path: cachePath });
|
|
138
|
+
|
|
139
|
+
await cache.set('key1', 'value1');
|
|
140
|
+
await cache.set('key2', 'value2');
|
|
141
|
+
await cache.delete('key1');
|
|
142
|
+
|
|
143
|
+
expect(await cache.get('key1')).toBeUndefined();
|
|
144
|
+
expect(await cache.get('key2')).toBe('value2');
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('ttl', () => {
|
|
149
|
+
it('should return 0 for non-existent key', async () => {
|
|
150
|
+
const cache = new FileCache({ path: cachePath });
|
|
151
|
+
expect(await cache.ttl('non-existent')).toBe(0);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should return remaining seconds for existing key', async () => {
|
|
155
|
+
const cache = new FileCache({ path: cachePath });
|
|
156
|
+
await cache.set('key', 'value', 300);
|
|
157
|
+
|
|
158
|
+
const remaining = await cache.ttl('key');
|
|
159
|
+
expect(remaining).toBeGreaterThan(298);
|
|
160
|
+
expect(remaining).toBeLessThanOrEqual(300);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('concurrency', () => {
|
|
165
|
+
it('should handle concurrent writes without corruption', async () => {
|
|
166
|
+
const cache = new FileCache({ path: cachePath });
|
|
167
|
+
|
|
168
|
+
// Write 10 keys concurrently
|
|
169
|
+
await Promise.all(
|
|
170
|
+
Array.from({ length: 10 }, (_, i) =>
|
|
171
|
+
cache.set(`key-${i}`, `value-${i}`),
|
|
172
|
+
),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// Verify all keys were written
|
|
176
|
+
for (let i = 0; i < 10; i++) {
|
|
177
|
+
const value = await cache.get(`key-${i}`);
|
|
178
|
+
expect(value).toBe(`value-${i}`);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('persistence', () => {
|
|
184
|
+
it('should persist across cache instances', async () => {
|
|
185
|
+
const cache1 = new FileCache({ path: cachePath });
|
|
186
|
+
await cache1.set('persistent', 'data');
|
|
187
|
+
|
|
188
|
+
const cache2 = new FileCache({ path: cachePath });
|
|
189
|
+
expect(await cache2.get('persistent')).toBe('data');
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
});
|
package/src/file.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { addSeconds } from 'date-fns';
|
|
5
|
+
import lockfile from 'proper-lockfile';
|
|
6
|
+
import { type Cache, getExpirationInSeconds } from './';
|
|
7
|
+
|
|
8
|
+
interface FileCacheEntry {
|
|
9
|
+
value: unknown;
|
|
10
|
+
expiresAt: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type FileCacheData = Record<string, FileCacheEntry>;
|
|
14
|
+
|
|
15
|
+
export interface FileCacheOptions {
|
|
16
|
+
/** Path to the cache JSON file. Default: `process.cwd()/.gkm/cache.json` */
|
|
17
|
+
path?: string;
|
|
18
|
+
/** Default TTL in seconds. Default: 600 */
|
|
19
|
+
ttl?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class FileCache implements Cache {
|
|
23
|
+
private readonly path: string;
|
|
24
|
+
private readonly defaultTtl: number;
|
|
25
|
+
/** In-process mutex to serialize concurrent writes within the same process. */
|
|
26
|
+
private mutex: Promise<void> = Promise.resolve();
|
|
27
|
+
|
|
28
|
+
constructor(options: FileCacheOptions = {}) {
|
|
29
|
+
this.path = options.path ?? join(process.cwd(), '.gkm', 'cache.json');
|
|
30
|
+
this.defaultTtl = options.ttl ?? 600;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async get<T>(key: string): Promise<T | undefined> {
|
|
34
|
+
const data = await this.readData();
|
|
35
|
+
const entry = data[key];
|
|
36
|
+
if (!entry) return undefined;
|
|
37
|
+
|
|
38
|
+
const remaining = getExpirationInSeconds(entry.expiresAt);
|
|
39
|
+
if (remaining <= 0) {
|
|
40
|
+
await this.withLock(async (current) => {
|
|
41
|
+
delete current[key];
|
|
42
|
+
return current;
|
|
43
|
+
});
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return entry.value as T;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
|
51
|
+
const seconds = ttl ?? this.defaultTtl;
|
|
52
|
+
const expiresAt = addSeconds(new Date(), seconds).toISOString();
|
|
53
|
+
|
|
54
|
+
await this.withLock(async (data) => {
|
|
55
|
+
data[key] = { value, expiresAt };
|
|
56
|
+
return data;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async delete(key: string): Promise<void> {
|
|
61
|
+
await this.withLock(async (data) => {
|
|
62
|
+
delete data[key];
|
|
63
|
+
return data;
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async ttl(key: string): Promise<number> {
|
|
68
|
+
const data = await this.readData();
|
|
69
|
+
const entry = data[key];
|
|
70
|
+
if (!entry) return 0;
|
|
71
|
+
|
|
72
|
+
return getExpirationInSeconds(entry.expiresAt);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private async readData(): Promise<FileCacheData> {
|
|
76
|
+
try {
|
|
77
|
+
const content = await readFile(this.path, 'utf-8');
|
|
78
|
+
return JSON.parse(content) as FileCacheData;
|
|
79
|
+
} catch {
|
|
80
|
+
return {};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private async ensureDir(): Promise<void> {
|
|
85
|
+
const dir = dirname(this.path);
|
|
86
|
+
if (!existsSync(dir)) {
|
|
87
|
+
await mkdir(dir, { recursive: true });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private async withLock(
|
|
92
|
+
fn: (data: FileCacheData) => Promise<FileCacheData>,
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
// Queue behind any pending in-process operations
|
|
95
|
+
const prev = this.mutex;
|
|
96
|
+
let resolve: () => void;
|
|
97
|
+
this.mutex = new Promise<void>((r) => {
|
|
98
|
+
resolve = r;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
await prev;
|
|
103
|
+
await this.ensureDir();
|
|
104
|
+
|
|
105
|
+
// Ensure file exists before locking (proper-lockfile requires it)
|
|
106
|
+
if (!existsSync(this.path)) {
|
|
107
|
+
await writeFile(this.path, '{}');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const release = await lockfile.lock(this.path, {
|
|
111
|
+
retries: { retries: 3, minTimeout: 100, maxTimeout: 1000 },
|
|
112
|
+
stale: 10000,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const data = await this.readData();
|
|
117
|
+
const updated = await fn(data);
|
|
118
|
+
await writeFile(this.path, JSON.stringify(updated, null, 2));
|
|
119
|
+
} finally {
|
|
120
|
+
await release();
|
|
121
|
+
}
|
|
122
|
+
} finally {
|
|
123
|
+
resolve!();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|