@strav/cache 1.0.0-alpha.25

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.
@@ -0,0 +1,362 @@
1
+ /**
2
+ * `RedisCache` — Redis-backed cross-process cache.
3
+ *
4
+ * Uses Bun's built-in `Bun.RedisClient` (since Bun 1.2) — no
5
+ * third-party Redis client dependency, same pure-fetch ethos as the
6
+ * rest of the framework.
7
+ *
8
+ * Operation mapping:
9
+ *
10
+ * - `get` → `GET key`, JSON.parse the result. Non-JSON strings
11
+ * (returned from numeric counters) round-trip as strings.
12
+ * - `put` → `SET key value EX seconds` (or without EX for no-expiry).
13
+ * - `has` → `EXISTS key`.
14
+ * - `forget` → `DEL key`.
15
+ * - `flush` → `SCAN MATCH prefix:* COUNT n` + `DEL` in batches.
16
+ * **NOT `FLUSHDB`** — apps may share the Redis DB with other code.
17
+ * - `add` → `SET key value NX EX seconds` (atomic put-if-absent).
18
+ * - `increment` / `decrement` → `INCRBY` / `DECRBY` (atomic). Note:
19
+ * Redis stores counters as strings; existing values from `put`
20
+ * get re-coerced via the SET-INCRBY path. Expired counters reset
21
+ * to the delta — Redis's natural behaviour.
22
+ * - `lock(name, ttl)` → SET NX EX with a per-acquire owner token
23
+ * and a Lua-eval release that compares-and-deletes.
24
+ * - `tags(...)` → SADD/SREM/SMEMBERS keyed by `prefix:tag:<tag>`.
25
+ *
26
+ * Keys are namespaced by `prefix` (default `'strav:'`) so the driver
27
+ * coexists with other Redis use. Tag-tracking keys use
28
+ * `<prefix>tag:<tag>` and `<prefix>tagged:<key>` so they don't collide
29
+ * with app keys that happen to start with `tag:`.
30
+ */
31
+
32
+ import { ulid } from '@strav/kernel'
33
+ import { RedisClient } from 'bun'
34
+ import { Cache } from '../../cache.ts'
35
+ import { CacheConfigError, CacheLockTimeoutError } from '../../cache_error.ts'
36
+ import { parseTtl } from '../../ttl.ts'
37
+ import type { CacheLock, CacheTtl, TaggedCache } from '../../types.ts'
38
+
39
+ export interface RedisCacheOptions {
40
+ /** Redis connection URL — `redis://host:port` or `rediss://…` for TLS. */
41
+ url: string
42
+ /**
43
+ * Key namespace prefix. Default `'strav:'`. Set differently per app
44
+ * if multiple apps share the same Redis DB.
45
+ */
46
+ prefix?: string
47
+ /**
48
+ * Custom `RedisClient` for tests. When omitted, the driver
49
+ * constructs one from `url`.
50
+ */
51
+ client?: RedisClient
52
+ }
53
+
54
+ const RELEASE_SCRIPT = `
55
+ if redis.call("get", KEYS[1]) == ARGV[1] then
56
+ return redis.call("del", KEYS[1])
57
+ else
58
+ return 0
59
+ end
60
+ `.trim()
61
+
62
+ export class RedisCache extends Cache {
63
+ private readonly client: RedisClient
64
+ private readonly ownsClient: boolean
65
+ private readonly prefix: string
66
+
67
+ constructor(options: RedisCacheOptions) {
68
+ super()
69
+ if (!options.url && options.client === undefined) {
70
+ throw new CacheConfigError('RedisCache requires a `url` (or an injected `client`).')
71
+ }
72
+ this.prefix = options.prefix ?? 'strav:'
73
+ this.ownsClient = options.client === undefined
74
+ this.client = options.client ?? new RedisClient(options.url)
75
+ }
76
+
77
+ // ─── Core primitives ───────────────────────────────────────────────────────
78
+
79
+ override async get<T = unknown>(key: string, fallback: T | null = null): Promise<T | null> {
80
+ const raw = await this.client.get(this.k(key))
81
+ if (raw === null) return fallback
82
+ return this.decode<T>(raw, fallback)
83
+ }
84
+
85
+ override async put(key: string, value: unknown, ttl?: CacheTtl): Promise<void> {
86
+ const seconds = parseTtl(ttl)
87
+ if (seconds === null) {
88
+ await this.client.set(this.k(key), JSON.stringify(value))
89
+ } else {
90
+ await this.client.set(this.k(key), JSON.stringify(value), 'EX', seconds)
91
+ }
92
+ }
93
+
94
+ override async has(key: string): Promise<boolean> {
95
+ return this.client.exists(this.k(key))
96
+ }
97
+
98
+ override async forget(key: string): Promise<boolean> {
99
+ const removed = await this.client.del(this.k(key))
100
+ // Tag wiring lives in two parallel sets — drop the per-key set;
101
+ // its membership in the tag-keyed sets gets reaped lazily at the
102
+ // next flush() (membership of a deleted key is harmless).
103
+ await this.client.del(this.taggedKey(key))
104
+ return removed > 0
105
+ }
106
+
107
+ override async flush(): Promise<void> {
108
+ // SCAN + DEL: walk every key under our prefix, in batches.
109
+ // FLUSHDB would wipe other apps sharing the same Redis DB.
110
+ let cursor = '0'
111
+ do {
112
+ const reply = (await this.client.send('SCAN', [
113
+ cursor,
114
+ 'MATCH',
115
+ `${this.prefix}*`,
116
+ 'COUNT',
117
+ '500',
118
+ ])) as [string, string[]]
119
+ const [nextCursor, keys] = reply
120
+ if (keys.length > 0) {
121
+ await this.client.del(...keys)
122
+ }
123
+ cursor = nextCursor
124
+ } while (cursor !== '0')
125
+ }
126
+
127
+ override async add(key: string, value: unknown, ttl: CacheTtl): Promise<boolean> {
128
+ const seconds = parseTtl(ttl)
129
+ let reply: 'OK' | null
130
+ if (seconds === null) {
131
+ reply = await this.client.set(this.k(key), JSON.stringify(value), 'NX')
132
+ } else {
133
+ reply = (await this.client.send('SET', [
134
+ this.k(key),
135
+ JSON.stringify(value),
136
+ 'NX',
137
+ 'EX',
138
+ String(seconds),
139
+ ])) as 'OK' | null
140
+ }
141
+ return reply === 'OK'
142
+ }
143
+
144
+ override async increment(key: string, by = 1): Promise<number> {
145
+ return this.client.incrby(this.k(key), by)
146
+ }
147
+
148
+ override async decrement(key: string, by = 1): Promise<number> {
149
+ return this.client.decrby(this.k(key), by)
150
+ }
151
+
152
+ override lock(name: string, ttl: CacheTtl): CacheLock {
153
+ return new RedisCacheLock(this, name, ttl)
154
+ }
155
+
156
+ override tags(...tags: string[]): TaggedCache {
157
+ return new RedisTaggedCache(this, tags)
158
+ }
159
+
160
+ override async close(): Promise<void> {
161
+ if (!this.ownsClient) return
162
+ try {
163
+ this.client.close()
164
+ } catch {
165
+ // Already closed or never connected.
166
+ }
167
+ }
168
+
169
+ // ─── Lock + tag internals ──────────────────────────────────────────────────
170
+
171
+ /** @internal */
172
+ async _tryAcquireLock(name: string, ttl: CacheTtl): Promise<string | undefined> {
173
+ const seconds = parseTtl(ttl)
174
+ if (seconds === null) {
175
+ throw new CacheConfigError('RedisCache.lock: TTL must be set — no "forever" locks.')
176
+ }
177
+ const owner = ulid()
178
+ const reply = (await this.client.send('SET', [
179
+ this.lockKey(name),
180
+ owner,
181
+ 'NX',
182
+ 'EX',
183
+ String(seconds),
184
+ ])) as 'OK' | null
185
+ return reply === 'OK' ? owner : undefined
186
+ }
187
+
188
+ /** @internal */
189
+ async _releaseLock(name: string, owner: string): Promise<boolean> {
190
+ const reply = (await this.client.send('EVAL', [
191
+ RELEASE_SCRIPT,
192
+ '1',
193
+ this.lockKey(name),
194
+ owner,
195
+ ])) as number
196
+ return reply === 1
197
+ }
198
+
199
+ /** @internal */
200
+ async _putWithTags(
201
+ key: string,
202
+ value: unknown,
203
+ ttl: CacheTtl,
204
+ tags: readonly string[],
205
+ ): Promise<void> {
206
+ await this.put(key, value, ttl)
207
+ if (tags.length === 0) {
208
+ await this.client.del(this.taggedKey(key))
209
+ return
210
+ }
211
+ // Two parallel sets: tag → keys (the flush target) and
212
+ // key → tags (so re-tagging swaps cleanly). SADD is variadic but
213
+ // Bun's typed surface tightens to `...members: string[]`.
214
+ const old = await this.client.smembers(this.taggedKey(key))
215
+ if (old.length > 0) {
216
+ for (const tag of old) {
217
+ await this.client.srem(this.tagKey(tag), this.k(key))
218
+ }
219
+ await this.client.del(this.taggedKey(key))
220
+ }
221
+ await this.client.sadd(this.taggedKey(key), ...tags)
222
+ for (const tag of tags) {
223
+ await this.client.sadd(this.tagKey(tag), this.k(key))
224
+ }
225
+ }
226
+
227
+ /** @internal */
228
+ async _flushTags(tags: readonly string[]): Promise<number> {
229
+ if (tags.length === 0) return 0
230
+ const allKeys = new Set<string>()
231
+ for (const tag of tags) {
232
+ const members = await this.client.smembers(this.tagKey(tag))
233
+ for (const m of members) allKeys.add(m)
234
+ // Drop the tag's index set itself — every membership we just
235
+ // read is about to be DEL'd, so the set is stale.
236
+ await this.client.del(this.tagKey(tag))
237
+ }
238
+ if (allKeys.size === 0) return 0
239
+ // The keys we got are already prefixed; pass them straight to DEL.
240
+ const list = [...allKeys]
241
+ await this.client.del(...list)
242
+ // Drop the per-key tag-list set too — it points at the same tags
243
+ // we just deleted.
244
+ for (const k of list) {
245
+ // Reverse the prefix to get the bare app key, then build the
246
+ // tagged-key.
247
+ if (k.startsWith(this.prefix)) {
248
+ const bare = k.slice(this.prefix.length)
249
+ await this.client.del(this.taggedKey(bare))
250
+ }
251
+ }
252
+ return list.length
253
+ }
254
+
255
+ // ─── Internals ─────────────────────────────────────────────────────────────
256
+
257
+ private k(key: string): string {
258
+ return `${this.prefix}${key}`
259
+ }
260
+
261
+ private lockKey(name: string): string {
262
+ return `${this.prefix}lock:${name}`
263
+ }
264
+
265
+ private tagKey(tag: string): string {
266
+ return `${this.prefix}tag:${tag}`
267
+ }
268
+
269
+ private taggedKey(key: string): string {
270
+ return `${this.prefix}tagged:${key}`
271
+ }
272
+
273
+ private decode<T>(raw: string, fallback: T | null): T | null {
274
+ // Counter values (INCRBY/DECRBY) come back as numeric strings;
275
+ // they're not JSON, so JSON.parse will succeed on `"42"` but
276
+ // throws on values like `"OK"`. Try JSON.parse first, fall back
277
+ // to the raw string for non-JSON-looking content.
278
+ try {
279
+ return JSON.parse(raw) as T
280
+ } catch {
281
+ // Either a counter that bypassed JSON encoding or an unexpected
282
+ // non-JSON payload. Returning the raw string is the friendlier
283
+ // default; bad payloads degrade gracefully rather than going
284
+ // null.
285
+ return raw as unknown as T
286
+ }
287
+ }
288
+ }
289
+
290
+ class RedisCacheLock implements CacheLock {
291
+ private owner: string | undefined
292
+
293
+ constructor(
294
+ private readonly cache: RedisCache,
295
+ readonly name: string,
296
+ private readonly ttl: CacheTtl,
297
+ ) {}
298
+
299
+ async acquire(): Promise<boolean> {
300
+ if (this.owner !== undefined) return false
301
+ const token = await this.cache._tryAcquireLock(this.name, this.ttl)
302
+ if (token === undefined) return false
303
+ this.owner = token
304
+ return true
305
+ }
306
+
307
+ async release(): Promise<boolean> {
308
+ if (this.owner === undefined) return false
309
+ const released = await this.cache._releaseLock(this.name, this.owner)
310
+ this.owner = undefined
311
+ return released
312
+ }
313
+
314
+ async block<T>(timeoutMs: number, fn: () => Promise<T> | T): Promise<T> {
315
+ if (timeoutMs < 0 || !Number.isFinite(timeoutMs)) {
316
+ throw new Error(
317
+ `CacheLock.block: timeoutMs must be a non-negative finite number; got: ${timeoutMs}`,
318
+ )
319
+ }
320
+ const deadline = Date.now() + timeoutMs
321
+ const pollIntervalMs = 50
322
+ while (true) {
323
+ if (await this.acquire()) {
324
+ try {
325
+ return await fn()
326
+ } finally {
327
+ await this.release()
328
+ }
329
+ }
330
+ if (Date.now() >= deadline) {
331
+ throw new CacheLockTimeoutError(
332
+ `CacheLock "${this.name}" not acquired within ${timeoutMs}ms.`,
333
+ { context: { lock: this.name, timeoutMs } },
334
+ )
335
+ }
336
+ await new Promise<void>((r) => setTimeout(r, pollIntervalMs))
337
+ }
338
+ }
339
+ }
340
+
341
+ class RedisTaggedCache implements TaggedCache {
342
+ constructor(
343
+ private readonly cache: RedisCache,
344
+ readonly tags: readonly string[],
345
+ ) {}
346
+
347
+ put(key: string, value: unknown, ttl?: CacheTtl): Promise<void> {
348
+ return this.cache._putWithTags(key, value, ttl, this.tags)
349
+ }
350
+
351
+ get<T = unknown>(key: string, fallback: T | null = null): Promise<T | null> {
352
+ return this.cache.get<T>(key, fallback)
353
+ }
354
+
355
+ forget(key: string): Promise<boolean> {
356
+ return this.cache.forget(key)
357
+ }
358
+
359
+ flush(): Promise<number> {
360
+ return this.cache._flushTags(this.tags)
361
+ }
362
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * `RedisCacheProvider` — wires `RedisCache` under the `Cache` token.
3
+ * Apps register this INSTEAD OF `CacheProvider` to use Redis as the
4
+ * cross-process backplane.
5
+ */
6
+
7
+ import { type Application, ConfigRepository, ServiceProvider } from '@strav/kernel'
8
+ import { Cache } from '../../cache.ts'
9
+ import { CacheConfigError } from '../../cache_error.ts'
10
+ import { RedisCache, type RedisCacheOptions } from './redis_cache.ts'
11
+
12
+ export interface RedisCacheConfig extends Omit<RedisCacheOptions, 'client'> {
13
+ driver: 'redis'
14
+ }
15
+
16
+ export class RedisCacheProvider extends ServiceProvider {
17
+ override readonly name = 'cache'
18
+ override readonly dependencies = ['config']
19
+
20
+ override register(app: Application): void {
21
+ app.singleton(Cache, (c) => {
22
+ const cfg = c.resolve(ConfigRepository).get('cache') as RedisCacheConfig | undefined
23
+ if (cfg === undefined || !cfg.url) {
24
+ throw new CacheConfigError(
25
+ 'RedisCacheProvider: `config.cache.url` is required (e.g. `redis://127.0.0.1:6379`).',
26
+ )
27
+ }
28
+ return new RedisCache({
29
+ url: cfg.url,
30
+ ...(cfg.prefix !== undefined ? { prefix: cfg.prefix } : {}),
31
+ })
32
+ })
33
+ }
34
+
35
+ override async boot(app: Application): Promise<void> {
36
+ app.resolve(Cache)
37
+ }
38
+
39
+ override async shutdown(app: Application): Promise<void> {
40
+ await app.resolve(Cache).close()
41
+ }
42
+ }
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ // Public API of @strav/cache.
2
+ //
3
+ // Root barrel exports the primitive — `Cache` base class + types +
4
+ // errors + the in-memory provider. Drivers ship under subpaths:
5
+ // - `@strav/cache/memory` (re-exports for explicit construction)
6
+ // - `@strav/cache/postgres` (Postgres cross-process backplane)
7
+
8
+ export { Cache } from './cache.ts'
9
+ export {
10
+ CacheConfigError,
11
+ CacheDriverError,
12
+ CacheError,
13
+ CacheLockTimeoutError,
14
+ CacheTtlParseError,
15
+ } from './cache_error.ts'
16
+ export { CacheProvider, type MemoryCacheConfig } from './cache_provider.ts'
17
+ export { MemoryCache, type MemoryCacheOptions } from './drivers/memory/memory_cache.ts'
18
+ export { parseTtl, ttlToExpiresAt } from './ttl.ts'
19
+ export type { CacheLock, CacheTtl, TaggedCache } from './types.ts'
package/src/ttl.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * TTL parsing — `'10m'` / `'1h'` / `'60s'` / number-as-seconds /
3
+ * null|undefined.
4
+ *
5
+ * Returned values are **seconds from now**. The driver converts to
6
+ * absolute timestamps internally.
7
+ *
8
+ * Throws `CacheTtlParseError` for malformed strings — fail fast rather
9
+ * than silently default. The cost of a typo (`'5min'` instead of
10
+ * `'5m'`) being treated as "no expiry" would be cache entries that
11
+ * never go away in production.
12
+ */
13
+
14
+ import { CacheTtlParseError } from './cache_error.ts'
15
+ import type { CacheTtl } from './types.ts'
16
+
17
+ const SUFFIX_SECONDS: Record<string, number> = {
18
+ s: 1,
19
+ m: 60,
20
+ h: 60 * 60,
21
+ d: 60 * 60 * 24,
22
+ }
23
+
24
+ const TTL_PATTERN = /^\s*(\d+)\s*([smhd]?)\s*$/i
25
+
26
+ /**
27
+ * Parse a `CacheTtl` into seconds, or `null` for "no expiry".
28
+ *
29
+ * parseTtl('10m') → 600
30
+ * parseTtl('1h') → 3600
31
+ * parseTtl('45s') → 45
32
+ * parseTtl(300) → 300
33
+ * parseTtl(null) → null
34
+ * parseTtl(undefined) → null
35
+ * parseTtl('5min') → throws CacheTtlParseError
36
+ */
37
+ export function parseTtl(ttl: CacheTtl): number | null {
38
+ if (ttl === null || ttl === undefined) return null
39
+ if (typeof ttl === 'number') {
40
+ if (!Number.isFinite(ttl) || ttl < 0) {
41
+ throw new CacheTtlParseError(`Cache TTL must be a non-negative finite number; got: ${ttl}`)
42
+ }
43
+ return Math.floor(ttl)
44
+ }
45
+ const match = TTL_PATTERN.exec(ttl)
46
+ if (match === null) {
47
+ throw new CacheTtlParseError(
48
+ `Cache TTL "${ttl}" is not parseable. Expected: a number (seconds), '10m', '1h', '60s', etc.`,
49
+ )
50
+ }
51
+ const amount = Number(match[1])
52
+ const suffix = (match[2] ?? '').toLowerCase()
53
+ const multiplier = suffix === '' ? 1 : SUFFIX_SECONDS[suffix]
54
+ if (multiplier === undefined) {
55
+ throw new CacheTtlParseError(`Cache TTL "${ttl}" has unknown suffix "${suffix}".`)
56
+ }
57
+ return amount * multiplier
58
+ }
59
+
60
+ /**
61
+ * Convert a TTL spec into an absolute "expires at" unix-ms timestamp.
62
+ * Returns `null` when the TTL is null (forever).
63
+ */
64
+ export function ttlToExpiresAt(ttl: CacheTtl, now = Date.now()): number | null {
65
+ const seconds = parseTtl(ttl)
66
+ if (seconds === null) return null
67
+ return now + seconds * 1000
68
+ }
package/src/types.ts ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Public types for `@strav/cache`.
3
+ *
4
+ * The Cache surface is small on purpose — drivers implement a tight
5
+ * set of primitive ops, and the abstract base composes higher-level
6
+ * patterns (`remember`, `rememberForever`) on top so every driver
7
+ * behaves identically there.
8
+ */
9
+
10
+ /**
11
+ * TTL accepted by `put` / `add` / `remember` / `lock`. Three forms:
12
+ *
13
+ * - `'10m'` / `'1h'` / `'45s'` — short-form string. Suffixes:
14
+ * `s` (seconds), `m` (minutes), `h` (hours), `d` (days).
15
+ * - `300` — number, interpreted as seconds.
16
+ * - `null` / `undefined` — no expiry (caller takes responsibility for
17
+ * invalidation). Use sparingly; entries persist until the process
18
+ * restarts (Memory) or you `forget` them (Postgres).
19
+ *
20
+ * The parser is forgiving — whitespace allowed, case-insensitive.
21
+ * `'1.5m'` is NOT supported; integer values only.
22
+ */
23
+ export type CacheTtl = string | number | null | undefined
24
+
25
+ /**
26
+ * Distributed lock — atomic "only one caller holds this name".
27
+ *
28
+ * Acquired locks expire after their TTL — set it long enough that the
29
+ * holder finishes its work, short enough that a crashed holder doesn't
30
+ * block forever. The release path uses an owner token so a slow
31
+ * caller whose lock already expired can't release someone else's
32
+ * newer lock.
33
+ */
34
+ export interface CacheLock {
35
+ /** The name passed to `cache.lock(name, ttl)`. */
36
+ readonly name: string
37
+ /**
38
+ * Try to acquire the lock once. Returns `true` if held, `false`
39
+ * if another caller already holds it (or this caller already
40
+ * acquired + didn't release).
41
+ */
42
+ acquire(): Promise<boolean>
43
+ /**
44
+ * Release the lock IF the current caller holds it. No-op if the
45
+ * lock expired or someone else holds it now. Returns `true` if
46
+ * the release actually fired, `false` otherwise.
47
+ */
48
+ release(): Promise<boolean>
49
+ /**
50
+ * Poll `acquire()` until success or `timeoutMs` elapses. On
51
+ * success runs `fn` then `release()`s. On timeout throws
52
+ * `CacheLockTimeoutError`. The wait interval defaults to 200ms.
53
+ */
54
+ block<T>(timeoutMs: number, fn: () => Promise<T> | T): Promise<T>
55
+ }
56
+
57
+ /**
58
+ * Tagged-cache namespace.
59
+ *
60
+ * Keys put through a `TaggedCache` are associated with one or more
61
+ * tags; `flush()` invalidates every key carrying any of these tags.
62
+ *
63
+ * Use case: "every cache entry that references the user with id N"
64
+ * gets tagged `user:N`, and a single `cache.tags(['user:N']).flush()`
65
+ * on user-update drops all of them — without you tracking which keys
66
+ * those were.
67
+ */
68
+ export interface TaggedCache {
69
+ readonly tags: readonly string[]
70
+ put(key: string, value: unknown, ttl?: CacheTtl): Promise<void>
71
+ get<T = unknown>(key: string, fallback?: T | null): Promise<T | null>
72
+ forget(key: string): Promise<boolean>
73
+ /**
74
+ * Drop every key carrying any of this namespace's tags. Returns the
75
+ * number of keys removed.
76
+ */
77
+ flush(): Promise<number>
78
+ }
79
+
80
+ /**
81
+ * Driver-internal cache record — what's persisted on the wire.
82
+ * Not exported from the package barrel; the abstract base uses it
83
+ * to share the `remember` implementation across drivers.
84
+ */
85
+ export interface CacheEntry<T = unknown> {
86
+ value: T
87
+ expiresAt: number | null // unix ms; null = forever
88
+ }