@maestro-js/cache 1.0.0-alpha.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/README.md ADDED
@@ -0,0 +1,392 @@
1
+ # @maestro-js/cache
2
+
3
+ Key-value caching with optional TTL, stale-while-revalidate (SWR), distributed locking, and
4
+ cache-aside (`remember`) pattern. This is the canonical example of the Maestro Provider pattern.
5
+
6
+ ## Quick Setup
7
+
8
+ ```ts
9
+ import { Cache } from '@maestro-js/cache'
10
+
11
+ // Create and register a cache instance
12
+ const cacheService = Cache.Provider.create({
13
+ driver: Cache.drivers.redis({ url: 'redis-host', port: 6379 })
14
+ })
15
+ Cache.Provider.register('default', cacheService)
16
+
17
+ // Use directly via the facade
18
+ await Cache.set('user:123', { name: 'Alice' }, 3600)
19
+ const user = await Cache.get<User>('user:123')
20
+ ```
21
+
22
+ ## Provider Pattern
23
+
24
+ Cache follows the standard Maestro Provider pattern built on `@maestro-js/service-registry`:
25
+
26
+ 1. **`Cache.Provider.create(config)`** -- factory that instantiates a cache service with a driver
27
+ 2. **`Cache.Provider.register(name, service)`** -- register the instance in a named registry
28
+ 3. **`Cache.provider(key)`** -- resolve a named instance (deferred via Proxy)
29
+ 4. **Facade** -- `Cache.get()`, `Cache.set()`, etc. spread from `provider('default')`
30
+ 5. **`Cache.drivers`** -- factory functions for pluggable backends (`redis`, `mockRedis`)
31
+
32
+ Extend provider keys via declaration merging:
33
+
34
+ ```ts
35
+ declare module '@maestro-js/cache' {
36
+ namespace Cache.Provider {
37
+ interface Keys {
38
+ default: unknown
39
+ sessions: unknown
40
+ }
41
+ }
42
+ }
43
+ ```
44
+
45
+ Source: `packages/cache/src/index.ts`
46
+
47
+ ## Drivers
48
+
49
+ ### Redis
50
+
51
+ Production driver backed by ioredis. Pass connection details and optional tuning:
52
+
53
+ ```ts
54
+ Cache.drivers.redis({
55
+ url: 'redis-host',
56
+ port: 6379,
57
+ operationTimeout: 5000, // ms, default 5000
58
+ connectTimeout: 10000, // ms, default 10000
59
+ keyPrefix: 'myapp:', // optional prefix for all keys
60
+ logger: customLogger // optional Log.LogFunctions
61
+ })
62
+ ```
63
+
64
+ The returned driver exposes a `connection` property (the raw ioredis `Redis` instance) for
65
+ advanced use cases.
66
+
67
+ Config interface:
68
+
69
+ | Field | Type | Default | Description |
70
+ |--------------------|-------------------|---------|-------------------------------|
71
+ | `url` | `string` | -- | Redis host |
72
+ | `port` | `number` | -- | Redis port |
73
+ | `operationTimeout` | `number` | `5000` | Per-operation timeout (ms) |
74
+ | `connectTimeout` | `number` | `10000` | Connection timeout (ms) |
75
+ | `keyPrefix` | `string` | `''` | Prefix applied to all keys |
76
+ | `logger` | `Log.LogFunctions`| -- | Optional structured logger |
77
+
78
+ Source: `packages/cache/src/redis-cache-driver.ts`
79
+
80
+ ### MockRedis
81
+
82
+ In-memory driver using `ioredis-mock` for testing. Same interface as Redis:
83
+
84
+ ```ts
85
+ Cache.drivers.mockRedis({
86
+ keyPrefix: 'test:', // optional
87
+ logger: customLogger // optional
88
+ })
89
+ ```
90
+
91
+ Source: `packages/cache/src/mock-redis-cache-driver.ts`
92
+
93
+ ## API Reference
94
+
95
+ All values must be JSON-serializable. TTL is always in seconds; pass `null` to persist forever.
96
+
97
+ ### Core Methods
98
+
99
+ ```ts
100
+ Cache.has(key: string): Promise<boolean>
101
+ ```
102
+ Return `true` if the key exists in the cache.
103
+
104
+ ```ts
105
+ Cache.get<T>(key: string): Promise<T | null>
106
+ ```
107
+ Retrieve a value. Return `null` on miss.
108
+
109
+ ```ts
110
+ Cache.set(key: string, value: any, ttlSeconds: number | null): Promise<void>
111
+ ```
112
+ Store a value with TTL in seconds. Pass `null` for no expiration.
113
+
114
+ ```ts
115
+ Cache.del(key: string): Promise<void>
116
+ ```
117
+ Remove a key from the cache.
118
+
119
+ ```ts
120
+ Cache.flush(): Promise<void>
121
+ ```
122
+ Remove all entries. Ignores key prefix -- affects the entire store.
123
+
124
+ ### Atomic Operations
125
+
126
+ ```ts
127
+ Cache.add(key: string, value: any, ttlSeconds: number): Promise<boolean>
128
+ ```
129
+ Store a value only if the key does not already exist. Returns `true` if added, `false` if the key
130
+ was already present. The value is JSON-serialized (compatible with `get`).
131
+
132
+ ```ts
133
+ Cache.increment(key: string, value?: number): Promise<number>
134
+ ```
135
+ Atomically increment a numeric value by `value` (default `1`). Returns the new value.
136
+
137
+ ```ts
138
+ Cache.decrement(key: string, value?: number): Promise<number>
139
+ ```
140
+ Atomically decrement a numeric value by `value` (default `1`). Returns the new value.
141
+
142
+ > **Warning:** `increment` and `decrement` use Redis `INCRBY`/`DECRBY` on raw integers, not
143
+ > JSON-serialized values. Values stored with `set` (which JSON-serializes) cannot be incremented.
144
+ > Conversely, values created by `increment`/`decrement` are raw numbers and will come back as plain
145
+ > numbers from `get`, not wrapped in JSON.
146
+
147
+ ```ts
148
+ // Rate-limit counter
149
+ await Cache.increment('rate:user:42')
150
+ const count = await Cache.increment('rate:user:42')
151
+ if (count > 100) throw new Error('Rate limit exceeded')
152
+
153
+ // Add-if-absent: claim a one-time token
154
+ const claimed = await Cache.add('token:abc123', { userId: 42 }, 300)
155
+ if (!claimed) throw new Error('Token already used')
156
+ ```
157
+
158
+ ### remember (Cache-Aside)
159
+
160
+ ```ts
161
+ Cache.remember<T>(key: string, options: RememberOptions, fn: () => Promise<T>): Promise<T>
162
+ ```
163
+
164
+ Retrieve `key` from cache. On miss, call `fn`, cache the result, and return it.
165
+
166
+ **RememberOptions** accepts:
167
+ - `number` -- TTL in seconds
168
+ - `null` -- persist forever
169
+ - `DurationString` -- e.g. `'5 minutes'`, `'1 hour'`, `'30 seconds'`, `'2 days'`
170
+ - `{ ttl: number | DurationString | null, swr: number | DurationString }` -- TTL with
171
+ stale-while-revalidate window
172
+
173
+ ```ts
174
+ // Simple TTL
175
+ await Cache.remember('user:1', 60, async () => fetchUser(1))
176
+
177
+ // Duration string
178
+ await Cache.remember('config', '5 minutes', async () => loadConfig())
179
+
180
+ // Forever
181
+ await Cache.remember('constants', null, async () => getConstants())
182
+
183
+ // Stale-while-revalidate
184
+ await Cache.remember('feed', { ttl: 60, swr: 30 }, async () => buildFeed())
185
+ await Cache.remember('feed', { ttl: '1 minute', swr: '30 seconds' }, async () => buildFeed())
186
+ ```
187
+
188
+ SWR behavior: when the TTL expires but the entry is within the SWR window, the stale value is
189
+ returned immediately and `fn` runs in the background to refresh the cache. Background refresh
190
+ errors are silently swallowed (the stale value remains). Only one background refresh per key runs
191
+ at a time.
192
+
193
+ Do not mix `Cache.set()` and `Cache.remember()` on the same key -- `remember` wraps values in an
194
+ internal envelope (`__remember` marker) and throws if it encounters a raw value.
195
+
196
+ ### item (Typed Key Handle)
197
+
198
+ ```ts
199
+ Cache.item<T>(key: string)
200
+ ```
201
+
202
+ Return a typed handle scoped to a single key:
203
+
204
+ ```ts
205
+ const user = Cache.item<User>('user:456')
206
+ await user.set({ name: 'Bob' }, 7200)
207
+ await user.exists() // true
208
+ await user.get() // { name: 'Bob' }
209
+ await user.del()
210
+ await user.remember(60, async () => fetchUser(456))
211
+ await user.remember('5 minutes', async () => fetchUser(456))
212
+ await user.remember({ ttl: 1, swr: 10 }, async () => fetchUser(456))
213
+ ```
214
+
215
+ ### lock (Distributed Locking)
216
+
217
+ ```ts
218
+ Cache.lock(name: string, ttlSeconds: number | null): Cache.Lock
219
+ ```
220
+
221
+ Create a distributed lock backed by Redis `SET NX`. Lock methods:
222
+
223
+ ```ts
224
+ const lock = Cache.lock('process-orders', 30)
225
+
226
+ await lock.get() // true if acquired, false if held by another
227
+ await lock.isAvailable() // true if no one holds the lock
228
+ await lock.release() // release if owned by this caller (atomic Lua script)
229
+ await lock.forceRelease() // release regardless of owner
230
+ await lock.block(5) // retry acquiring for up to 5 seconds, throw on timeout
231
+ ```
232
+
233
+ Pass `null` for TTL to create a lock with no automatic expiration.
234
+
235
+ `block()` polls every 250ms and also listens for Redis pub/sub release events for faster
236
+ acquisition. Throws `Error('Could not acquire lock: <name>')` on timeout.
237
+
238
+ ## Common Patterns
239
+
240
+ ### Cache-aside with TTL
241
+
242
+ ```ts
243
+ const user = await Cache.remember('user:123', 3600, async () => {
244
+ return await db.query('SELECT * FROM users WHERE id = 123')
245
+ })
246
+ ```
247
+
248
+ ### Stale-while-revalidate
249
+
250
+ Serve stale data instantly while refreshing in the background:
251
+
252
+ ```ts
253
+ const dashboard = await Cache.remember(
254
+ 'dashboard:stats',
255
+ { ttl: '5 minutes', swr: '1 minute' },
256
+ async () => computeExpensiveStats()
257
+ )
258
+ ```
259
+
260
+ ### Distributed mutex
261
+
262
+ Ensure only one process runs a task at a time:
263
+
264
+ ```ts
265
+ const lock = Cache.lock('import-job', 60)
266
+ const acquired = await lock.get()
267
+ if (acquired) {
268
+ try {
269
+ await runImport()
270
+ } finally {
271
+ await lock.release()
272
+ }
273
+ }
274
+ ```
275
+
276
+ ### Blocking lock with timeout
277
+
278
+ Wait for availability:
279
+
280
+ ```ts
281
+ const lock = Cache.lock('critical-section', 30)
282
+ await lock.block(10) // wait up to 10 seconds
283
+ try {
284
+ await doCriticalWork()
285
+ } finally {
286
+ await lock.release()
287
+ }
288
+ ```
289
+
290
+ ### Named provider instances
291
+
292
+ ```ts
293
+ const sessions = Cache.Provider.create({
294
+ driver: Cache.drivers.redis({ url: 'redis-host', port: 6379, keyPrefix: 'sess:' })
295
+ })
296
+ Cache.Provider.register('sessions', sessions)
297
+
298
+ // Resolve by name
299
+ const sessCache = Cache.provider('sessions')
300
+ await sessCache.set('abc', { userId: 1 }, 1800)
301
+ ```
302
+
303
+ ## Testing
304
+
305
+ Use MockRedis driver in tests. Flush before each test to ensure isolation:
306
+
307
+ ```ts
308
+ import { Cache } from '@maestro-js/cache'
309
+ import { test } from 'beartest-js'
310
+ import { expect } from 'expect'
311
+
312
+ const cache = Cache.Provider.create({ driver: Cache.drivers.mockRedis({}) })
313
+
314
+ test.beforeEach(async () => {
315
+ await cache.flush()
316
+ })
317
+
318
+ test('cache stores and retrieves values', async () => {
319
+ await cache.set('key', { a: 1 }, null)
320
+ expect(await cache.has('key')).toBeTruthy()
321
+ expect(await cache.get('key')).toEqual({ a: 1 })
322
+ })
323
+
324
+ test('remember caches on miss', async () => {
325
+ const result = await cache.remember('user:1', 60, async () => ({ name: 'Alice' }))
326
+ expect(result).toEqual({ name: 'Alice' })
327
+
328
+ let called = false
329
+ const cached = await cache.remember('user:1', 60, async () => {
330
+ called = true
331
+ return { name: 'Bob' }
332
+ })
333
+ expect(cached).toEqual({ name: 'Alice' })
334
+ expect(called).toBe(false)
335
+ })
336
+
337
+ test('locks acquire and release', async () => {
338
+ const lock = cache.lock('test-lock', null)
339
+ expect(await lock.isAvailable()).toBe(true)
340
+ await lock.get()
341
+ expect(await lock.isAvailable()).toBe(false)
342
+ await lock.release()
343
+ expect(await lock.isAvailable()).toBe(true)
344
+ })
345
+ ```
346
+
347
+ Run tests:
348
+
349
+ ```bash
350
+ pnpm --filter @maestro-js/cache test # all cache tests
351
+ cd packages/cache && npx beartest ./tests/cache.test.ts # single file
352
+ ```
353
+
354
+ ## Cross-Package Integration
355
+
356
+ - **Depends on**: `@maestro-js/service-registry` (Provider pattern foundation),
357
+ `@maestro-js/log` (structured logging in drivers)
358
+ - **Used by**: `@maestro-js/cache-control` (HTTP cache-control headers backed by cache)
359
+ - **Complements**: `@maestro-js/context` (per-request via AsyncLocalStorage; cache is
360
+ cross-request shared state)
361
+
362
+ ## Driver Interface
363
+
364
+ Implement `Cache.Driver` to create a custom backend:
365
+
366
+ ```ts
367
+ interface CacheDriver {
368
+ has(key: string): Promise<boolean>
369
+ get(key: string): Promise<any | null>
370
+ set(key: string, value: any, ttlSeconds: number): Promise<unknown>
371
+ add(key: string, value: any, ttlSeconds: number): Promise<boolean>
372
+ setForever(key: string, value: any): Promise<unknown>
373
+ del(key: string): Promise<unknown>
374
+ increment(key: string, value: number): Promise<number>
375
+ decrement(key: string, value: number): Promise<number>
376
+ flush(): Promise<unknown>
377
+ getPrefix(): string
378
+ lock(name: string, ttlSeconds: number | null): Cache.Lock
379
+ }
380
+ ```
381
+
382
+ Source: `packages/cache/src/cache-types.ts`
383
+
384
+ ## Key Source Files
385
+
386
+ - `packages/cache/src/index.ts` -- main export, Provider pattern, `create()`, `remember()` logic
387
+ - `packages/cache/src/cache-types.ts` -- `CacheDriver` and `CacheLock` interfaces
388
+ - `packages/cache/src/redis-cache-driver.ts` -- production Redis driver
389
+ - `packages/cache/src/mock-redis-cache-driver.ts` -- in-memory mock driver for tests
390
+ - `packages/cache/src/duration.ts` -- `DurationString` type and parser
391
+ - `packages/cache/tests/cache.test.ts` -- core cache and remember/SWR tests
392
+ - `packages/cache/tests/cache-locks.test.ts` -- distributed lock tests
@@ -0,0 +1,233 @@
1
+ import { Log } from '@maestro-js/log';
2
+ import Redis$1, { Redis } from 'ioredis';
3
+
4
+ type DurationString = `${number} ${'day' | 'days' | 'hour' | 'hours' | 'minute' | 'minutes' | 'second' | 'seconds'}`;
5
+
6
+ /** Contract that every cache backend (Redis, MockRedis, etc.) must implement */
7
+ interface CacheDriver {
8
+ /** Checks whether `key` exists in the store */
9
+ has(key: string): Promise<boolean>;
10
+ /** Retrieves the value for `key`, or `null` if absent */
11
+ get(key: string): Promise<any | null>;
12
+ /** Stores `value` at `key` with a TTL in seconds */
13
+ set(key: string, value: any, ttlSeconds: number): Promise<unknown>;
14
+ /** only add the item to the cache if it does not already exist in the cache store */
15
+ add(key: string, value: any, ttlSeconds: number): Promise<boolean>;
16
+ /** Stores `value` at `key` with no expiration */
17
+ setForever(key: string, value: any): Promise<unknown>;
18
+ /** Removes `key` from the store */
19
+ del(key: string): Promise<unknown>;
20
+ /** Adjust the value of an integer item in the cache */
21
+ increment(key: string, value: number): Promise<number>;
22
+ /** Adjust the value of an integer item in the cache */
23
+ decrement(key: string, value: number): Promise<number>;
24
+ /** Removes all entries from the store */
25
+ flush(): Promise<unknown>;
26
+ /** Returns the key prefix applied to all operations */
27
+ getPrefix(): string;
28
+ /** Creates a distributed lock with the given name and optional TTL */
29
+ lock(name: string, ttlSeconds: number | null): CacheLock;
30
+ }
31
+ /** Distributed lock for coordinating exclusive access across processes */
32
+ type CacheLock = {
33
+ /** Attempts to acquire the lock — resolves to `true` if acquired, `false` if already held */
34
+ get: () => Promise<boolean>;
35
+ /** Releases the lock if held by this caller */
36
+ release: () => Promise<void>;
37
+ /** Releases the lock regardless of who holds it */
38
+ forceRelease: () => Promise<void>;
39
+ /** Checks whether the lock is currently available (not held) */
40
+ isAvailable: () => Promise<boolean>;
41
+ /** Retries acquiring the lock for up to `seconds`, resolving to `true` if acquired */
42
+ block: (seconds: number) => Promise<void>;
43
+ };
44
+
45
+ interface MockRedisDriverConfig {
46
+ keyPrefix?: string;
47
+ logger?: Log.LogFunctions;
48
+ }
49
+ declare function mockRedisCacheDriver({ logger: loggerInput, keyPrefix }: MockRedisDriverConfig): CacheDriver & {
50
+ connection: Redis;
51
+ };
52
+
53
+ interface RedisDriverConfig {
54
+ url: string;
55
+ port: number;
56
+ operationTimeout?: number;
57
+ connectTimeout?: number;
58
+ keyPrefix?: string;
59
+ logger?: Log.LogFunctions;
60
+ }
61
+ declare function redisCacheDriver({ url, port, operationTimeout, connectTimeout, keyPrefix, logger: loggerInput }: RedisDriverConfig): CacheDriver & {
62
+ connection: Redis$1;
63
+ };
64
+
65
+ /**
66
+ * Creates a new Cache service instance with the provided driver configuration.
67
+ */
68
+ declare function create(config: Cache.Provider.CacheServiceConfig): Cache.CacheService;
69
+ declare function provider(key: Cache.Provider.Key): {
70
+ has: (key: string) => Promise<boolean>;
71
+ get: <T = any>(key: string) => Promise<T | null>;
72
+ set: (key: string, value: any, ttlSeconds: number | null) => Promise<void>;
73
+ add: (key: string, value: any, ttlSeconds: number) => Promise<boolean>;
74
+ increment: (key: string, value?: number) => Promise<number>;
75
+ decrement: (key: string, value?: number) => Promise<number>;
76
+ del: (key: string) => Promise<void>;
77
+ flush: () => Promise<void>;
78
+ remember: <T>(key: string, options: Cache.RememberOptions, fn: () => Promise<T>) => Promise<T>;
79
+ item: <T>(key: string) => {
80
+ /** Checks whether this key exists in the cache */
81
+ exists: () => Promise<boolean>;
82
+ /** Retrieves the value, or `null` if absent */
83
+ get: () => Promise<T | null>;
84
+ /** Stores a value with an optional TTL in seconds — pass `null` to persist forever */
85
+ set: (value: T, ttlSeconds: number | null) => Promise<void>;
86
+ /** Removes this key and its value from the cache */
87
+ del: () => Promise<void>;
88
+ /** Retrieves the cached value; on miss calls `fn`, caches the result, and returns it */
89
+ remember: (options: Cache.RememberOptions, fn: () => Promise<T>) => Promise<T>;
90
+ };
91
+ lock: (name: string, ttlSeconds: number | null) => Cache.Lock;
92
+ };
93
+ type KeysWithFallback = keyof Cache.Provider.Keys extends never ? {
94
+ default: unknown;
95
+ } : Cache.Provider.Keys;
96
+ /**
97
+ * Key-value caching with optional TTL and distributed locking.
98
+ * Call methods directly on the default instance, or use `Cache.Provider.create()` for named instances.
99
+ */
100
+ declare const Cache: {
101
+ provider: typeof provider;
102
+ drivers: {
103
+ redis: typeof redisCacheDriver;
104
+ mockRedis: typeof mockRedisCacheDriver;
105
+ };
106
+ has: (key: string) => Promise<boolean>;
107
+ get: <T = any>(key: string) => Promise<T | null>;
108
+ set: (key: string, value: any, ttlSeconds: number | null) => Promise<void>;
109
+ add: (key: string, value: any, ttlSeconds: number) => Promise<boolean>;
110
+ increment: (key: string, value?: number) => Promise<number>;
111
+ decrement: (key: string, value?: number) => Promise<number>;
112
+ del: (key: string) => Promise<void>;
113
+ flush: () => Promise<void>;
114
+ remember: <T>(key: string, options: Cache.RememberOptions, fn: () => Promise<T>) => Promise<T>;
115
+ item: <T>(key: string) => {
116
+ /** Checks whether this key exists in the cache */
117
+ exists: () => Promise<boolean>;
118
+ /** Retrieves the value, or `null` if absent */
119
+ get: () => Promise<T | null>;
120
+ /** Stores a value with an optional TTL in seconds — pass `null` to persist forever */
121
+ set: (value: T, ttlSeconds: number | null) => Promise<void>;
122
+ /** Removes this key and its value from the cache */
123
+ del: () => Promise<void>;
124
+ /** Retrieves the cached value; on miss calls `fn`, caches the result, and returns it */
125
+ remember: (options: Cache.RememberOptions, fn: () => Promise<T>) => Promise<T>;
126
+ };
127
+ lock: (name: string, ttlSeconds: number | null) => Cache.Lock;
128
+ Provider: {
129
+ create: typeof create;
130
+ register: (name: Cache.Provider.Key, item: Cache.CacheService) => void;
131
+ };
132
+ };
133
+ /**
134
+ * Key-value caching with optional TTL and distributed locking.
135
+ *
136
+ * All values must be JSON-serializable. Keys can be set with a time-to-live
137
+ * (in seconds) or persisted indefinitely by passing `null`. Use {@link item}
138
+ * for a typed handle scoped to a single key, or {@link lock} for distributed
139
+ * coordination across processes.
140
+ *
141
+ * @example
142
+ * ```ts
143
+ * await Cache.set('user:123', { name: 'Alice' }, 3600)
144
+ * await Cache.get<User>('user:123') // { name: 'Alice' }
145
+ * await Cache.has('user:123') // true
146
+ * await Cache.del('user:123')
147
+ *
148
+ * // Atomic increment (rate-limit counter)
149
+ * const count = await Cache.increment('rate:user:42')
150
+ *
151
+ * // Add only if absent (claim a one-time token)
152
+ * const claimed = await Cache.add('token:abc', { userId: 42 }, 300)
153
+ *
154
+ * // Typed handle for a single key
155
+ * const user = Cache.item<User>('user:456')
156
+ * await user.set({ name: 'Bob' }, 7200)
157
+ * await user.get() // { name: 'Bob' }
158
+ * ```
159
+ */
160
+ declare namespace Cache {
161
+ /** Distributed lock for coordinating exclusive access across processes */
162
+ type Lock = CacheLock;
163
+ /** Pluggable backend implementation — see `Cache.drivers` for built-in options */
164
+ type Driver = CacheDriver;
165
+ /** Duration string like `'5 minutes'` or `'1 hour'` */
166
+ type DurationString = DurationString;
167
+ /** TTL options for `remember` — a number (seconds), `null` (forever), a duration string, or an object with `ttl` and `swr` */
168
+ type RememberOptions = number | null | DurationString | {
169
+ ttl: number | DurationString | null;
170
+ swr: number | DurationString;
171
+ };
172
+ /**
173
+ * Core cache operations for storing and retrieving JSON-serializable values.
174
+ *
175
+ * - **TTL** – pass a number of seconds to {@link set}, or `null` to persist forever.
176
+ * - **Item handles** – {@link item} returns a typed wrapper scoped to a single key.
177
+ * - **Locking** – {@link lock} creates a distributed lock backed by the cache driver.
178
+ *
179
+ * Returned by `Cache.Provider.create()` or resolved via `Cache.provider()`.
180
+ *
181
+ * @example
182
+ * ```ts
183
+ * await Cache.set('config', { apiKey: 'abc' }, 600)
184
+ * const cfg = await Cache.get<Config>('config') // { apiKey: 'abc' }
185
+ * await Cache.del('config')
186
+ * ```
187
+ */
188
+ interface CacheService {
189
+ /** Checks whether the key exists in the cache */
190
+ has: (key: string) => Promise<boolean>;
191
+ /** Retrieves the value for `key`, or `null` if absent */
192
+ get: <T = any>(key: string) => Promise<T | null>;
193
+ /** Stores a value at `key` with an optional TTL in seconds — pass `null` to persist forever */
194
+ set: (key: string, value: any, ttlSeconds: number | null) => Promise<void>;
195
+ /** Stores a value at `key` only if the key does not already exist — returns `true` if added, `false` if the key was already present */
196
+ add: (key: string, value: any, ttlSeconds: number) => Promise<boolean>;
197
+ /** Increments the numeric value at `key` by `value` */
198
+ increment: (key: string, value?: number) => Promise<number>;
199
+ /** Decrements the numeric value at `key` by `value` */
200
+ decrement: (key: string, value?: number) => Promise<number>;
201
+ /** Removes the key and its value from the cache */
202
+ del: (key: string) => Promise<void>;
203
+ /** Clears all entries from the cache — ignores key prefix, affects the entire store */
204
+ flush: () => Promise<void>;
205
+ /** Retrieves `key` from cache; on miss calls `fn`, caches the result, and returns it */
206
+ remember: <T>(key: string, options: RememberOptions, fn: () => Promise<T>) => Promise<T>;
207
+ /** Returns a typed handle scoped to a single key with {@link CacheService.has exists}, get, set, del, and remember methods */
208
+ item: <T>(key: string) => {
209
+ /** Checks whether this key exists in the cache */
210
+ exists: () => Promise<boolean>;
211
+ /** Retrieves the value, or `null` if absent */
212
+ get: () => Promise<T | null>;
213
+ /** Stores a value with an optional TTL in seconds — pass `null` to persist forever */
214
+ set: (value: T, ttlSeconds: number | null) => Promise<void>;
215
+ /** Removes this key and its value from the cache */
216
+ del: () => Promise<void>;
217
+ /** Retrieves the cached value; on miss calls `fn`, caches the result, and returns it */
218
+ remember: (options: RememberOptions, fn: () => Promise<T>) => Promise<T>;
219
+ };
220
+ /** Creates a distributed lock with the given name and optional TTL */
221
+ lock: (name: string, ttlSeconds: number | null) => Cache.Lock;
222
+ }
223
+ namespace Provider {
224
+ interface CacheServiceConfig {
225
+ driver: CacheDriver;
226
+ }
227
+ type Key = keyof KeysWithFallback;
228
+ interface Keys {
229
+ }
230
+ }
231
+ }
232
+
233
+ export { Cache };
package/dist/index.js ADDED
@@ -0,0 +1,530 @@
1
+ // src/index.ts
2
+ import assert from "assert";
3
+ import { ServiceRegistry } from "@maestro-js/service-registry";
4
+
5
+ // src/mock-redis-cache-driver.ts
6
+ import EventEmitter from "events";
7
+ import { randomUUID } from "crypto";
8
+ import { Log } from "@maestro-js/log";
9
+ import MockRedis from "ioredis-mock";
10
+ import "ioredis";
11
+ function mockRedisCacheDriver({
12
+ logger: loggerInput,
13
+ keyPrefix = ""
14
+ }) {
15
+ const logger = loggerInput ?? Log.create();
16
+ const redis = new MockRedis();
17
+ const lockEmitter = new EventEmitter();
18
+ const channelPrefix = [keyPrefix, "maestro.events."].join("");
19
+ const cacheLockChannelName = `${channelPrefix}cache-locks`;
20
+ redis.on("error", (error) => {
21
+ logger.error(error);
22
+ });
23
+ redis.on("message", (channel, message) => {
24
+ if (channel === cacheLockChannelName) {
25
+ lockEmitter.emit(message);
26
+ }
27
+ });
28
+ return {
29
+ async has(key) {
30
+ const res = await redis.exists(key);
31
+ return res > 0;
32
+ },
33
+ async get(key) {
34
+ const res = await redis.get(key);
35
+ if (res) {
36
+ if (typeof res === "string") {
37
+ try {
38
+ return JSON.parse(res);
39
+ } catch (e) {
40
+ throw new Error(`Could not JSON.parse data for key ${key}`, { cause: e });
41
+ }
42
+ } else if (Array.isArray(res)) {
43
+ return res.map((r) => {
44
+ try {
45
+ return JSON.parse(r);
46
+ } catch (e) {
47
+ throw new Error(`Could not JSON.parse data for key ${key}`, { cause: e });
48
+ }
49
+ });
50
+ } else {
51
+ throw new Error(`Unrecognized value for key ${key}`);
52
+ }
53
+ } else {
54
+ return res;
55
+ }
56
+ },
57
+ async set(key, value, ttlSeconds) {
58
+ await redis.set(key, JSON.stringify(value), "EX", ttlSeconds);
59
+ },
60
+ async add(key, value, ttlSeconds) {
61
+ const result = await redis.set(key, JSON.stringify(value), "EX", ttlSeconds, "NX");
62
+ return result !== null;
63
+ },
64
+ async increment(key, value) {
65
+ return redis.incrby(key, value);
66
+ },
67
+ async decrement(key, value) {
68
+ return redis.decrby(key, value);
69
+ },
70
+ async setForever(key, value) {
71
+ await redis.set(key, JSON.stringify(value));
72
+ },
73
+ async del(key) {
74
+ await redis.del(key);
75
+ },
76
+ async flush() {
77
+ await redis.flushdb();
78
+ },
79
+ getPrefix() {
80
+ return keyPrefix;
81
+ },
82
+ connection: redis,
83
+ lock(name, ttlSeconds) {
84
+ const id = `lock-${name}`;
85
+ const callId = randomUUID();
86
+ async function tryLock() {
87
+ if (ttlSeconds !== null) {
88
+ const isSet = await redis.set(id, callId, "EX", ttlSeconds, "NX");
89
+ return isSet === "OK";
90
+ } else {
91
+ const isSet = await redis.set(id, callId, "NX");
92
+ return isSet === "OK";
93
+ }
94
+ }
95
+ async function get() {
96
+ const locked = await tryLock();
97
+ return locked;
98
+ }
99
+ async function block(seconds) {
100
+ const locked = await tryLock();
101
+ if (locked) return;
102
+ return new Promise((resolve, reject) => {
103
+ let settled = false;
104
+ let acquiring = false;
105
+ let waitingForAcquire = false;
106
+ const cleanup = () => {
107
+ settled = true;
108
+ lockEmitter.off(id, onRelease);
109
+ clearInterval(fallbackPoll);
110
+ };
111
+ const timeout = setTimeout(() => {
112
+ if (acquiring) {
113
+ waitingForAcquire = true;
114
+ return;
115
+ }
116
+ cleanup();
117
+ reject(new Error(`Could not acquire lock: ${name}`));
118
+ }, seconds * 1e3);
119
+ const onRelease = async () => {
120
+ if (settled) return;
121
+ acquiring = true;
122
+ const acquired = await tryLock();
123
+ acquiring = false;
124
+ if (acquired) {
125
+ clearTimeout(timeout);
126
+ cleanup();
127
+ resolve();
128
+ return;
129
+ }
130
+ if (waitingForAcquire) {
131
+ cleanup();
132
+ reject(new Error(`Could not acquire lock: ${name}`));
133
+ }
134
+ };
135
+ lockEmitter.on(id, onRelease);
136
+ const fallbackPoll = setInterval(onRelease, 250);
137
+ onRelease();
138
+ });
139
+ }
140
+ async function isAvailable() {
141
+ const value = await redis.get(id);
142
+ return !value;
143
+ }
144
+ async function release() {
145
+ const released = await redis.eval(
146
+ 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) end return 0',
147
+ 1,
148
+ id,
149
+ callId
150
+ );
151
+ if (released === 1) {
152
+ lockEmitter.emit(id);
153
+ await redis.publish(cacheLockChannelName, id);
154
+ }
155
+ }
156
+ async function forceRelease() {
157
+ await redis.del(id);
158
+ lockEmitter.emit(id);
159
+ await redis.publish(cacheLockChannelName, id);
160
+ }
161
+ return { get, release, forceRelease, isAvailable, block };
162
+ }
163
+ };
164
+ }
165
+
166
+ // src/redis-cache-driver.ts
167
+ import EventEmitter2 from "events";
168
+ import { randomUUID as randomUUID2 } from "crypto";
169
+ import Redis2 from "ioredis";
170
+ import RedisTimeout from "ioredis-timeout";
171
+ import { Log as Log2 } from "@maestro-js/log";
172
+ function redisCacheDriver({
173
+ url,
174
+ port,
175
+ operationTimeout = 5e3,
176
+ connectTimeout = 1e4,
177
+ keyPrefix = "",
178
+ logger: loggerInput
179
+ }) {
180
+ const logger = loggerInput ?? Log2.create();
181
+ const redis = new Redis2(port, url, {
182
+ connectTimeout,
183
+ keyPrefix
184
+ });
185
+ const lockEmitter = new EventEmitter2();
186
+ const channelPrefix = [keyPrefix, "maestro.events."].join("");
187
+ const cacheLockChannelName = `${channelPrefix}cache-locks`;
188
+ redis.on("connect", () => {
189
+ logger.info("connected!");
190
+ RedisTimeout.timeout("set", operationTimeout, redis);
191
+ RedisTimeout.timeout("get", operationTimeout, redis);
192
+ RedisTimeout.timeout("exists", operationTimeout, redis);
193
+ RedisTimeout.timeout("del", operationTimeout, redis);
194
+ RedisTimeout.timeout("publish", operationTimeout, redis);
195
+ });
196
+ redis.on("error", (error) => {
197
+ logger.error(error);
198
+ });
199
+ redis.on("message", (channel, message) => {
200
+ if (channel === cacheLockChannelName) {
201
+ lockEmitter.emit(message);
202
+ }
203
+ });
204
+ return {
205
+ async has(key) {
206
+ const res = await redis.exists(key);
207
+ return res > 0;
208
+ },
209
+ async get(key) {
210
+ const res = await redis.get(key);
211
+ if (res) {
212
+ if (typeof res === "string") {
213
+ try {
214
+ return JSON.parse(res);
215
+ } catch (e) {
216
+ throw new Error(`Could not JSON.parse data for key ${key}`, { cause: e });
217
+ }
218
+ } else if (Array.isArray(res)) {
219
+ return res.map((r) => {
220
+ try {
221
+ return JSON.parse(r);
222
+ } catch (e) {
223
+ throw new Error(`Could not JSON.parse data for key ${key}`, { cause: e });
224
+ }
225
+ });
226
+ } else {
227
+ throw new Error(`Unrecognized value for key ${key}`);
228
+ }
229
+ } else {
230
+ return res;
231
+ }
232
+ },
233
+ async set(key, value, ttlSeconds) {
234
+ await redis.set(key, JSON.stringify(value), "EX", ttlSeconds);
235
+ },
236
+ async add(key, value, ttlSeconds) {
237
+ const result = await redis.set(key, JSON.stringify(value), "EX", ttlSeconds, "NX");
238
+ return result !== null;
239
+ },
240
+ async increment(key, value) {
241
+ return redis.incrby(key, value);
242
+ },
243
+ async decrement(key, value) {
244
+ return redis.decrby(key, value);
245
+ },
246
+ async setForever(key, value) {
247
+ await redis.set(key, JSON.stringify(value));
248
+ },
249
+ async del(key) {
250
+ await redis.del(key);
251
+ },
252
+ async flush() {
253
+ await redis.flushdb();
254
+ },
255
+ getPrefix() {
256
+ return keyPrefix;
257
+ },
258
+ connection: redis,
259
+ lock(name, ttlSeconds) {
260
+ const id = `lock-${name}`;
261
+ const callId = randomUUID2();
262
+ async function tryLock() {
263
+ if (ttlSeconds !== null) {
264
+ const isSet = await redis.set(id, callId, "EX", ttlSeconds, "NX");
265
+ return isSet === "OK";
266
+ } else {
267
+ const isSet = await redis.set(id, callId, "NX");
268
+ return isSet === "OK";
269
+ }
270
+ }
271
+ async function get() {
272
+ const locked = await tryLock();
273
+ return locked;
274
+ }
275
+ async function block(seconds) {
276
+ const locked = await tryLock();
277
+ if (locked) return;
278
+ return new Promise((resolve, reject) => {
279
+ let settled = false;
280
+ let acquiring = false;
281
+ let waitingForAcquire = false;
282
+ const cleanup = () => {
283
+ settled = true;
284
+ lockEmitter.off(id, onRelease);
285
+ clearInterval(fallbackPoll);
286
+ };
287
+ const timeout = setTimeout(() => {
288
+ if (acquiring) {
289
+ waitingForAcquire = true;
290
+ return;
291
+ }
292
+ cleanup();
293
+ reject(new Error(`Could not acquire lock: ${name}`));
294
+ }, seconds * 1e3);
295
+ const onRelease = async () => {
296
+ if (settled) return;
297
+ acquiring = true;
298
+ const acquired = await tryLock();
299
+ acquiring = false;
300
+ if (acquired) {
301
+ clearTimeout(timeout);
302
+ cleanup();
303
+ resolve();
304
+ return;
305
+ }
306
+ if (waitingForAcquire) {
307
+ cleanup();
308
+ reject(new Error(`Could not acquire lock: ${name}`));
309
+ }
310
+ };
311
+ lockEmitter.on(id, onRelease);
312
+ const fallbackPoll = setInterval(onRelease, 250);
313
+ onRelease();
314
+ });
315
+ }
316
+ async function isAvailable() {
317
+ const value = await redis.get(id);
318
+ return !value;
319
+ }
320
+ async function release() {
321
+ const released = await redis.eval(
322
+ 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) end return 0',
323
+ 1,
324
+ id,
325
+ callId
326
+ );
327
+ if (released === 1) {
328
+ lockEmitter.emit(id);
329
+ await redis.publish(cacheLockChannelName, id);
330
+ }
331
+ }
332
+ async function forceRelease() {
333
+ await redis.del(id);
334
+ lockEmitter.emit(id);
335
+ await redis.publish(cacheLockChannelName, id);
336
+ }
337
+ return { get, release, forceRelease, isAvailable, block };
338
+ }
339
+ };
340
+ }
341
+
342
+ // src/duration.ts
343
+ function parseDurationToSeconds(duration) {
344
+ const [numberStr, unit] = duration.split(" ");
345
+ const n = Number(numberStr);
346
+ if (Number.isNaN(n)) {
347
+ throw new Error(`${numberStr} not recognized as a number`);
348
+ }
349
+ if (unit === "day" || unit === "days") {
350
+ return n * 60 * 60 * 24;
351
+ } else if (unit === "hour" || unit === "hours") {
352
+ return n * 60 * 60;
353
+ } else if (unit === "minute" || unit === "minutes") {
354
+ return n * 60;
355
+ } else if (unit === "second" || unit === "seconds") {
356
+ return n;
357
+ } else {
358
+ throw new Error(`Unrecognized unit ${unit}`);
359
+ }
360
+ }
361
+
362
+ // src/index.ts
363
+ function create(config) {
364
+ const driver = config.driver;
365
+ const pendingRefreshes = /* @__PURE__ */ new Map();
366
+ async function has(key) {
367
+ return driver.has(key);
368
+ }
369
+ async function get(key) {
370
+ assert.ok(key);
371
+ return driver.get(key);
372
+ }
373
+ async function set(key, value, ttlSeconds) {
374
+ if (ttlSeconds !== null) {
375
+ await driver.set(key, value, ttlSeconds);
376
+ } else {
377
+ await driver.setForever(key, value);
378
+ }
379
+ }
380
+ async function add(key, value, ttlSeconds) {
381
+ return driver.add(key, value, ttlSeconds);
382
+ }
383
+ async function increment(key, value = 1) {
384
+ return driver.increment(key, value);
385
+ }
386
+ async function decrement(key, value = 1) {
387
+ return driver.decrement(key, value);
388
+ }
389
+ async function del(key) {
390
+ await driver.del(key);
391
+ }
392
+ async function flush() {
393
+ await driver.flush();
394
+ }
395
+ async function remember(key, options, fn) {
396
+ assert.ok(key);
397
+ const { ttlSeconds, swrSeconds } = resolveRememberOptions(options);
398
+ const cached = await driver.get(key);
399
+ if (cached !== null) {
400
+ if (!isRememberEntry(cached)) {
401
+ throw new Error(
402
+ `Cache key "${key}" was not stored by remember(). Do not mix cache.set() and cache.remember() on the same key.`
403
+ );
404
+ }
405
+ const ageSeconds = (Date.now() - cached.createdTime) / 1e3;
406
+ const isFresh = ttlSeconds === null || ageSeconds < ttlSeconds;
407
+ const isWithinSwrWindow = swrSeconds > 0 && (ttlSeconds === null || ageSeconds < ttlSeconds + swrSeconds);
408
+ if (isFresh) {
409
+ return cached.value;
410
+ }
411
+ if (isWithinSwrWindow) {
412
+ if (!pendingRefreshes.has(key)) {
413
+ const refreshPromise = fn().then(async (value2) => {
414
+ await storeEntry(key, value2, ttlSeconds, swrSeconds);
415
+ }).catch(() => {
416
+ }).finally(() => {
417
+ pendingRefreshes.delete(key);
418
+ });
419
+ pendingRefreshes.set(key, refreshPromise);
420
+ }
421
+ return cached.value;
422
+ }
423
+ }
424
+ const value = await fn();
425
+ await storeEntry(key, value, ttlSeconds, swrSeconds);
426
+ return value;
427
+ }
428
+ async function storeEntry(key, value, ttlSeconds, swrSeconds) {
429
+ const entry = { __remember: true, createdTime: Date.now(), value };
430
+ const totalTtl = ttlSeconds !== null ? ttlSeconds + swrSeconds : null;
431
+ if (totalTtl !== null) {
432
+ await driver.set(key, entry, totalTtl);
433
+ } else {
434
+ await driver.setForever(key, entry);
435
+ }
436
+ }
437
+ function item(key) {
438
+ async function exists() {
439
+ return await driver.has(key);
440
+ }
441
+ async function get2() {
442
+ assert.ok(key);
443
+ const res = await driver.get(key);
444
+ return res;
445
+ }
446
+ async function set2(value, ttlSeconds) {
447
+ if (ttlSeconds !== null) {
448
+ await driver.set(key, value, ttlSeconds);
449
+ } else {
450
+ await driver.setForever(key, value);
451
+ }
452
+ }
453
+ async function del2() {
454
+ await driver.del(key);
455
+ }
456
+ async function itemRemember(options, fn) {
457
+ return remember(key, options, fn);
458
+ }
459
+ return { exists, get: get2, set: set2, del: del2, remember: itemRemember };
460
+ }
461
+ function lock(name, ttlSeconds) {
462
+ return driver.lock(name, ttlSeconds);
463
+ }
464
+ return {
465
+ has,
466
+ get,
467
+ set,
468
+ increment,
469
+ decrement,
470
+ add,
471
+ del,
472
+ flush,
473
+ remember,
474
+ item,
475
+ lock
476
+ };
477
+ }
478
+ var registry = ServiceRegistry.createRegistry(
479
+ ServiceRegistry.proxyFunctionsForObject
480
+ );
481
+ var Provider = {
482
+ create,
483
+ register: registry.register
484
+ };
485
+ function provider(key) {
486
+ const service = registry.resolve(key);
487
+ return {
488
+ has: service.has,
489
+ get: service.get,
490
+ set: service.set,
491
+ add: service.add,
492
+ increment: service.increment,
493
+ decrement: service.decrement,
494
+ del: service.del,
495
+ flush: service.flush,
496
+ remember: service.remember,
497
+ item: service.item,
498
+ lock: service.lock
499
+ };
500
+ }
501
+ var Cache = {
502
+ Provider,
503
+ ...provider("default"),
504
+ provider,
505
+ drivers: {
506
+ redis: redisCacheDriver,
507
+ mockRedis: mockRedisCacheDriver
508
+ }
509
+ };
510
+ function isSwrOptions(opts) {
511
+ return typeof opts === "object" && opts !== null && "swr" in opts;
512
+ }
513
+ function resolveDuration(d) {
514
+ return typeof d === "string" ? parseDurationToSeconds(d) : d;
515
+ }
516
+ function resolveRememberOptions(opts) {
517
+ if (isSwrOptions(opts)) {
518
+ const ttlSeconds = opts.ttl === null ? null : resolveDuration(opts.ttl);
519
+ const swrSeconds = resolveDuration(opts.swr);
520
+ return { ttlSeconds, swrSeconds };
521
+ }
522
+ if (opts === null) return { ttlSeconds: null, swrSeconds: 0 };
523
+ return { ttlSeconds: resolveDuration(opts), swrSeconds: 0 };
524
+ }
525
+ function isRememberEntry(value) {
526
+ return typeof value === "object" && value !== null && value.__remember === true;
527
+ }
528
+ export {
529
+ Cache
530
+ };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@maestro-js/cache",
3
+ "description": "Key-value caching with TTL, stale-while-revalidate, distributed locking, cache-aside pattern, atomic increment/decrement, and set-if-not-exists (add) for @maestro-js/cache. Use when working with @maestro-js/cache, adding caching to services, implementing distributed locks, using the remember/SWR pattern, incrementing or decrementing counters, adding values only if absent, configuring Redis or MockRedis drivers, or following the canonical Provider pattern from service-registry.",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "types": "./dist/index.d.ts",
8
+ "default": "./dist/index.js"
9
+ }
10
+ },
11
+ "dependencies": {
12
+ "ioredis": "^5.4.1",
13
+ "ioredis-timeout": "^1.5.0",
14
+ "@maestro-js/log": "1.0.0-alpha.0",
15
+ "@maestro-js/service-registry": "1.0.0-alpha.0"
16
+ },
17
+ "devDependencies": {
18
+ "@types/ioredis-mock": "^8.2.6",
19
+ "@types/node": "^22.19.11",
20
+ "ioredis-mock": "^8.2.0"
21
+ },
22
+ "version": "1.0.0-alpha.0",
23
+ "publishConfig": {
24
+ "access": "restricted"
25
+ },
26
+ "files": [
27
+ "dist"
28
+ ],
29
+ "license": "UNLICENSED",
30
+ "engines": {
31
+ "node": ">=22.18.0"
32
+ },
33
+ "repository": "https://github.com/Marcato-Partners/maestro-js",
34
+ "scripts": {
35
+ "build": "tsup --config ../../tsup.config.ts",
36
+ "typecheck": "tsc --noEmit",
37
+ "test": "beartest ./tests/**/*",
38
+ "format": "prettier --write src/ tests/",
39
+ "lint": "prettier --check src/ tests/"
40
+ }
41
+ }