@geekmidas/cache 0.2.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 ADDED
@@ -0,0 +1,17 @@
1
+ # @geekmidas/cache
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
+
13
+ ## 1.0.0
14
+
15
+ ### Major Changes
16
+
17
+ - [`ff7b115`](https://github.com/geekmidas/toolbox/commit/ff7b11599f60f84ac6cdc73714c853ecf786b2e8) Thanks [@geekmidas](https://github.com/geekmidas)! - Version 1 Stable release
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), and Expo Secure Store implementations
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"}
@@ -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"}
@@ -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": "0.2.0",
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
+ }