@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.
- package/README.md +29 -0
- package/package.json +38 -0
- package/src/cache.ts +104 -0
- package/src/cache_error.ts +75 -0
- package/src/cache_provider.ts +45 -0
- package/src/drivers/memcached/index.ts +6 -0
- package/src/drivers/memcached/memcached_cache.ts +288 -0
- package/src/drivers/memcached/memcached_cache_provider.ts +39 -0
- package/src/drivers/memcached/memcached_client.ts +251 -0
- package/src/drivers/memory/index.ts +1 -0
- package/src/drivers/memory/memory_cache.ts +279 -0
- package/src/drivers/postgres/apply_cache_migration.ts +54 -0
- package/src/drivers/postgres/index.ts +10 -0
- package/src/drivers/postgres/postgres_cache.ts +410 -0
- package/src/drivers/postgres/postgres_cache_provider.ts +46 -0
- package/src/drivers/redis/index.ts +5 -0
- package/src/drivers/redis/redis_cache.ts +362 -0
- package/src/drivers/redis/redis_cache_provider.ts +42 -0
- package/src/index.ts +19 -0
- package/src/ttl.ts +68 -0
- package/src/types.ts +88 -0
|
@@ -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
|
+
}
|