@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 +392 -0
- package/dist/index.d.ts +233 -0
- package/dist/index.js +530 -0
- package/package.json +41 -0
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
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|