@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,410 @@
1
+ /**
2
+ * `PostgresCache` — cross-process cache backed by three tables.
3
+ *
4
+ * Atomic ops are atomic at the DB layer:
5
+ *
6
+ * - `add` uses `INSERT ... ON CONFLICT DO UPDATE WHERE expires_at <=
7
+ * now() RETURNING xmax::int = 0` to distinguish "first insert"
8
+ * from "updated an expired row" (both succeed) and "row was
9
+ * fresh, conflict skipped" (fails). Concurrent callers see
10
+ * consistent semantics — exactly one wins.
11
+ * - `increment` / `decrement` use a single `INSERT ... ON CONFLICT
12
+ * DO UPDATE` that flips the value via `(data::text::numeric)`.
13
+ * Concurrent increments serialize at the row lock; the final
14
+ * value reflects every increment.
15
+ * - Lock `acquire` uses the same upsert pattern against
16
+ * `strav_cache_locks`. Lock release scopes on `name + owner` so
17
+ * a slow caller can't release someone else's newer lock.
18
+ *
19
+ * Bun's `SQL` driver returns jsonb columns as strings (no
20
+ * auto-hydration). The driver `JSON.parse`s on the way out — same
21
+ * pattern as `@strav/broadcast`'s Postgres driver.
22
+ */
23
+
24
+ import { ulid } from '@strav/kernel'
25
+ import { Cache } from '../../cache.ts'
26
+ import { CacheDriverError, CacheLockTimeoutError } from '../../cache_error.ts'
27
+ import { parseTtl } from '../../ttl.ts'
28
+ import type { CacheLock, CacheTtl, TaggedCache } from '../../types.ts'
29
+
30
+ /**
31
+ * Minimal Database surface — declared inline so the package's
32
+ * runtime dep on `@strav/database` stays an *optional* peer.
33
+ */
34
+ export interface PostgresCacheDatabase {
35
+ query<T = Record<string, unknown>>(sql: string, params?: readonly unknown[]): Promise<T[]>
36
+ execute(sql: string, params?: readonly unknown[]): Promise<number>
37
+ }
38
+
39
+ export interface PostgresCacheOptions {
40
+ db: PostgresCacheDatabase
41
+ /**
42
+ * How often to sweep expired rows. Set to `0` to disable (apps with
43
+ * Postgres pre-12 or aggressive table partitioning may want to
44
+ * handle GC themselves). Default `60_000` (one minute).
45
+ */
46
+ cleanupIntervalMs?: number
47
+ }
48
+
49
+ const TABLE = '"strav_cache"'
50
+ const LOCK_TABLE = '"strav_cache_locks"'
51
+ const TAGS_TABLE = '"strav_cache_tags"'
52
+
53
+ export class PostgresCache extends Cache {
54
+ private readonly db: PostgresCacheDatabase
55
+ private readonly cleanupIntervalMs: number
56
+ private cleanupTimer: ReturnType<typeof setInterval> | undefined
57
+ private closed = false
58
+
59
+ constructor(options: PostgresCacheOptions) {
60
+ super()
61
+ this.db = options.db
62
+ this.cleanupIntervalMs = options.cleanupIntervalMs ?? 60_000
63
+ this.startCleanupLoop()
64
+ }
65
+
66
+ // ─── Core primitives ───────────────────────────────────────────────────────
67
+
68
+ override async get<T = unknown>(key: string, fallback: T | null = null): Promise<T | null> {
69
+ // Bun.SQL auto-hydrates jsonb scalars (numbers, booleans, strings,
70
+ // null) to native JS types and returns objects/arrays as their
71
+ // JSON-encoded text representation. So:
72
+ // - non-string → return as-is.
73
+ // - string that parses as JSON → use the parsed result (object).
74
+ // - string that doesn't parse → it's a string-scalar we got back
75
+ // unwrapped — return the string itself.
76
+ const rows = await this.q<{ data: unknown }>(
77
+ `SELECT "data" FROM ${TABLE}
78
+ WHERE "key" = $1 AND ("expires_at" IS NULL OR "expires_at" > now())`,
79
+ [key],
80
+ )
81
+ const row = rows[0]
82
+ if (row === undefined || row.data === null) return fallback
83
+ if (typeof row.data !== 'string') return row.data as T
84
+ try {
85
+ return JSON.parse(row.data) as T
86
+ } catch {
87
+ return row.data as T
88
+ }
89
+ }
90
+
91
+ override async put(key: string, value: unknown, ttl?: CacheTtl): Promise<void> {
92
+ const seconds = parseTtl(ttl)
93
+ await this.x(
94
+ `INSERT INTO ${TABLE} ("key", "data", "expires_at")
95
+ VALUES ($1, ($2::text)::jsonb, ${this.ttlSqlExpr(seconds)})
96
+ ON CONFLICT ("key") DO UPDATE
97
+ SET "data" = EXCLUDED."data", "expires_at" = EXCLUDED."expires_at"`,
98
+ [key, JSON.stringify(value)],
99
+ )
100
+ }
101
+
102
+ override async has(key: string): Promise<boolean> {
103
+ const rows = await this.q<{ exists: boolean }>(
104
+ `SELECT EXISTS(
105
+ SELECT 1 FROM ${TABLE}
106
+ WHERE "key" = $1 AND ("expires_at" IS NULL OR "expires_at" > now())
107
+ ) AS "exists"`,
108
+ [key],
109
+ )
110
+ return rows[0]?.exists === true
111
+ }
112
+
113
+ override async forget(key: string): Promise<boolean> {
114
+ const affected = await this.x(`DELETE FROM ${TABLE} WHERE "key" = $1`, [key])
115
+ return affected > 0
116
+ }
117
+
118
+ override async flush(): Promise<void> {
119
+ // Truncate cascades through the FK to strav_cache_tags. Locks are
120
+ // a separate table and survive flush — matches MemoryCache.
121
+ await this.x(`TRUNCATE ${TABLE} CASCADE`)
122
+ }
123
+
124
+ override async add(key: string, value: unknown, ttl: CacheTtl): Promise<boolean> {
125
+ const seconds = parseTtl(ttl)
126
+ // The `WHERE` clause on the UPDATE branch means we only overwrite
127
+ // an existing row when it has expired. A fresh row skips the
128
+ // UPDATE; the INSERT path runs only when no row exists at all. So
129
+ // RETURNING is non-empty iff we successfully stored.
130
+ const rows = await this.q<{ stored: number }>(
131
+ `INSERT INTO ${TABLE} ("key", "data", "expires_at")
132
+ VALUES ($1, ($2::text)::jsonb, ${this.ttlSqlExpr(seconds)})
133
+ ON CONFLICT ("key") DO UPDATE
134
+ SET "data" = EXCLUDED."data", "expires_at" = EXCLUDED."expires_at"
135
+ WHERE ${TABLE}."expires_at" IS NOT NULL AND ${TABLE}."expires_at" <= now()
136
+ RETURNING 1 AS "stored"`,
137
+ [key, JSON.stringify(value)],
138
+ )
139
+ return rows.length > 0
140
+ }
141
+
142
+ override async increment(key: string, by = 1): Promise<number> {
143
+ return this.adjust(key, by)
144
+ }
145
+
146
+ override async decrement(key: string, by = 1): Promise<number> {
147
+ return this.adjust(key, -by)
148
+ }
149
+
150
+ override lock(name: string, ttl: CacheTtl): CacheLock {
151
+ return new PostgresCacheLock(this, name, ttl)
152
+ }
153
+
154
+ override tags(...tags: string[]): TaggedCache {
155
+ return new PostgresTaggedCache(this, tags)
156
+ }
157
+
158
+ override async close(): Promise<void> {
159
+ this.closed = true
160
+ if (this.cleanupTimer !== undefined) {
161
+ clearInterval(this.cleanupTimer)
162
+ this.cleanupTimer = undefined
163
+ }
164
+ }
165
+
166
+ // ─── Maintenance ──────────────────────────────────────────────────────────
167
+
168
+ /** Run one cleanup pass. Returns the number of expired rows deleted. */
169
+ async sweepOnce(): Promise<number> {
170
+ return this.x(
171
+ `DELETE FROM ${TABLE}
172
+ WHERE "expires_at" IS NOT NULL AND "expires_at" <= now()`,
173
+ )
174
+ }
175
+
176
+ /** Sweep the lock table — different schema, run alongside `sweepOnce`. */
177
+ async sweepLocksOnce(): Promise<number> {
178
+ return this.x(`DELETE FROM ${LOCK_TABLE} WHERE "expires_at" <= now()`)
179
+ }
180
+
181
+ // ─── Lock + tag internals (exposed to the wrapper classes) ─────────────────
182
+
183
+ /** @internal */
184
+ async _tryAcquireLock(name: string, ttl: CacheTtl): Promise<string | undefined> {
185
+ const seconds = parseTtl(ttl)
186
+ if (seconds === null) {
187
+ throw new CacheDriverError('PostgresCache.lock: TTL must be set — no "forever" locks.', {
188
+ context: { name },
189
+ })
190
+ }
191
+ const owner = ulid()
192
+ const rows = await this.q<{ owner: string }>(
193
+ `INSERT INTO ${LOCK_TABLE} ("name", "owner", "expires_at")
194
+ VALUES ($1, $2, now() + ($3::text || ' seconds')::interval)
195
+ ON CONFLICT ("name") DO UPDATE
196
+ SET "owner" = EXCLUDED."owner", "expires_at" = EXCLUDED."expires_at"
197
+ WHERE ${LOCK_TABLE}."expires_at" <= now()
198
+ RETURNING "owner"`,
199
+ [name, owner, String(seconds)],
200
+ )
201
+ if (rows[0]?.owner === owner) return owner
202
+ return undefined
203
+ }
204
+
205
+ /** @internal */
206
+ async _releaseLock(name: string, owner: string): Promise<boolean> {
207
+ const affected = await this.x(`DELETE FROM ${LOCK_TABLE} WHERE "name" = $1 AND "owner" = $2`, [
208
+ name,
209
+ owner,
210
+ ])
211
+ return affected > 0
212
+ }
213
+
214
+ /** @internal */
215
+ async _putWithTags(
216
+ key: string,
217
+ value: unknown,
218
+ ttl: CacheTtl,
219
+ tags: readonly string[],
220
+ ): Promise<void> {
221
+ // Two writes — putting the cache entry, then re-syncing its tags.
222
+ // No transaction wrapper: a partial failure leaves stale tag rows,
223
+ // but the worst-case impact is "next flush() catches them" since
224
+ // the tag rows alone don't keep entries alive. Adding a tx is
225
+ // worth it later if app traffic shows the partial-write being a
226
+ // real problem.
227
+ await this.put(key, value, ttl)
228
+ await this.x(`DELETE FROM ${TAGS_TABLE} WHERE "key" = $1`, [key])
229
+ if (tags.length === 0) return
230
+ // Bun.SQL doesn't transparently bind JS arrays to Postgres `text[]`
231
+ // parameters — pass the Postgres array literal as a string and let
232
+ // the server cast it.
233
+ await this.x(
234
+ `INSERT INTO ${TAGS_TABLE} ("key", "tag")
235
+ SELECT $1, unnest($2::text[])
236
+ ON CONFLICT DO NOTHING`,
237
+ [key, toPgTextArray(tags)],
238
+ )
239
+ }
240
+
241
+ /** @internal */
242
+ async _flushTags(tags: readonly string[]): Promise<number> {
243
+ if (tags.length === 0) return 0
244
+ const affected = await this.x(
245
+ `DELETE FROM ${TABLE}
246
+ WHERE "key" IN (
247
+ SELECT DISTINCT "key" FROM ${TAGS_TABLE} WHERE "tag" = ANY($1::text[])
248
+ )`,
249
+ [toPgTextArray(tags)],
250
+ )
251
+ return affected
252
+ }
253
+
254
+ // ─── Internals ─────────────────────────────────────────────────────────────
255
+
256
+ private async adjust(key: string, delta: number): Promise<number> {
257
+ // INSERT or UPDATE in one statement.
258
+ // - If the key is missing, store `delta` with no TTL.
259
+ // - If the key is present and unexpired, add `delta` to the
260
+ // existing numeric value (carrying jsonb through ::text::numeric
261
+ // so non-numeric stored values fail loudly rather than silently
262
+ // reset).
263
+ // - If the key is present but expired, reset to `delta` and clear
264
+ // the TTL — treat expired rows as missing.
265
+ const rows = await this.q<{ value: string | number }>(
266
+ `INSERT INTO ${TABLE} ("key", "data", "expires_at")
267
+ VALUES ($1, to_jsonb($2::numeric), NULL)
268
+ ON CONFLICT ("key") DO UPDATE
269
+ SET "data" = to_jsonb(
270
+ CASE
271
+ WHEN ${TABLE}."expires_at" IS NOT NULL AND ${TABLE}."expires_at" <= now()
272
+ THEN $2::numeric
273
+ ELSE ${TABLE}."data"::text::numeric + $2::numeric
274
+ END
275
+ ),
276
+ "expires_at" = CASE
277
+ WHEN ${TABLE}."expires_at" IS NOT NULL AND ${TABLE}."expires_at" <= now()
278
+ THEN NULL
279
+ ELSE ${TABLE}."expires_at"
280
+ END
281
+ RETURNING "data"::text::numeric AS "value"`,
282
+ [key, delta],
283
+ )
284
+ const value = rows[0]?.value
285
+ if (value === undefined) {
286
+ throw new CacheDriverError(
287
+ `PostgresCache.${delta >= 0 ? 'increment' : 'decrement'}: no value returned (driver bug).`,
288
+ { context: { key, delta } },
289
+ )
290
+ }
291
+ return Number(value)
292
+ }
293
+
294
+ private ttlSqlExpr(seconds: number | null): string {
295
+ if (seconds === null) return 'NULL'
296
+ return `now() + interval '${seconds} seconds'`
297
+ }
298
+
299
+ private startCleanupLoop(): void {
300
+ if (this.cleanupIntervalMs <= 0) return
301
+ this.cleanupTimer = setInterval(() => {
302
+ void this.sweepOnce().catch(() => {
303
+ // Same rationale as the broadcast poller: transient DB
304
+ // failures shouldn't tear the cache down. Apps wire DB-driver
305
+ // logging if they need visibility into sweep failures.
306
+ })
307
+ void this.sweepLocksOnce().catch(() => {})
308
+ }, this.cleanupIntervalMs)
309
+ // Don't let the timer keep the process alive on its own.
310
+ this.cleanupTimer.unref?.()
311
+ }
312
+
313
+ private async q<T = Record<string, unknown>>(
314
+ sql: string,
315
+ params: readonly unknown[] = [],
316
+ ): Promise<T[]> {
317
+ if (this.closed) throw new CacheDriverError('PostgresCache is closed.')
318
+ return this.db.query<T>(sql, params)
319
+ }
320
+
321
+ private async x(sql: string, params: readonly unknown[] = []): Promise<number> {
322
+ if (this.closed) throw new CacheDriverError('PostgresCache is closed.')
323
+ return this.db.execute(sql, params)
324
+ }
325
+ }
326
+
327
+ class PostgresCacheLock implements CacheLock {
328
+ private owner: string | undefined
329
+
330
+ constructor(
331
+ private readonly cache: PostgresCache,
332
+ readonly name: string,
333
+ private readonly ttl: CacheTtl,
334
+ ) {}
335
+
336
+ async acquire(): Promise<boolean> {
337
+ if (this.owner !== undefined) return false
338
+ const token = await this.cache._tryAcquireLock(this.name, this.ttl)
339
+ if (token === undefined) return false
340
+ this.owner = token
341
+ return true
342
+ }
343
+
344
+ async release(): Promise<boolean> {
345
+ if (this.owner === undefined) return false
346
+ const released = await this.cache._releaseLock(this.name, this.owner)
347
+ this.owner = undefined
348
+ return released
349
+ }
350
+
351
+ async block<T>(timeoutMs: number, fn: () => Promise<T> | T): Promise<T> {
352
+ if (timeoutMs < 0 || !Number.isFinite(timeoutMs)) {
353
+ throw new Error(
354
+ `CacheLock.block: timeoutMs must be a non-negative finite number; got: ${timeoutMs}`,
355
+ )
356
+ }
357
+ const deadline = Date.now() + timeoutMs
358
+ const pollIntervalMs = 200
359
+ while (true) {
360
+ if (await this.acquire()) {
361
+ try {
362
+ return await fn()
363
+ } finally {
364
+ await this.release()
365
+ }
366
+ }
367
+ if (Date.now() >= deadline) {
368
+ throw new CacheLockTimeoutError(
369
+ `CacheLock "${this.name}" not acquired within ${timeoutMs}ms.`,
370
+ { context: { lock: this.name, timeoutMs } },
371
+ )
372
+ }
373
+ await new Promise<void>((r) => setTimeout(r, pollIntervalMs))
374
+ }
375
+ }
376
+ }
377
+
378
+ class PostgresTaggedCache implements TaggedCache {
379
+ constructor(
380
+ private readonly cache: PostgresCache,
381
+ readonly tags: readonly string[],
382
+ ) {}
383
+
384
+ put(key: string, value: unknown, ttl?: CacheTtl): Promise<void> {
385
+ return this.cache._putWithTags(key, value, ttl, this.tags)
386
+ }
387
+
388
+ get<T = unknown>(key: string, fallback: T | null = null): Promise<T | null> {
389
+ return this.cache.get<T>(key, fallback)
390
+ }
391
+
392
+ forget(key: string): Promise<boolean> {
393
+ return this.cache.forget(key)
394
+ }
395
+
396
+ flush(): Promise<number> {
397
+ return this.cache._flushTags(this.tags)
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Format a JS string array as a Postgres array literal so it can ride
403
+ * a single text parameter and the server can cast it via `$N::text[]`.
404
+ * Escapes embedded `"` and `\\` per the Postgres array literal spec —
405
+ * commas inside quoted items are fine.
406
+ */
407
+ function toPgTextArray(items: readonly string[]): string {
408
+ const escaped = items.map((s) => `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`)
409
+ return `{${escaped.join(',')}}`
410
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * `PostgresCacheProvider` — wires `PostgresCache` under the `Cache`
3
+ * token. Apps register this INSTEAD OF `CacheProvider` to swap the
4
+ * dev-friendly memory cache for the cross-process Postgres backplane.
5
+ */
6
+
7
+ import { type Application, ConfigRepository, ServiceProvider } from '@strav/kernel'
8
+ import { Cache } from '../../cache.ts'
9
+ import {
10
+ PostgresCache,
11
+ type PostgresCacheDatabase,
12
+ type PostgresCacheOptions,
13
+ } from './postgres_cache.ts'
14
+
15
+ export interface PostgresCacheConfig extends Omit<PostgresCacheOptions, 'db'> {
16
+ driver: 'postgres'
17
+ }
18
+
19
+ export class PostgresCacheProvider extends ServiceProvider {
20
+ override readonly name = 'cache'
21
+ override readonly dependencies = ['config', 'database']
22
+
23
+ override register(app: Application): void {
24
+ app.singleton(Cache, (c) => {
25
+ // Resolve `Database` by string token to keep the runtime peer-dep
26
+ // on `@strav/database` optional — apps using `MemoryCache`
27
+ // shouldn't pay for `@strav/database` in their bundle.
28
+ const db = c.resolve<PostgresCacheDatabase>('database' as never)
29
+ const cfg = c.resolve(ConfigRepository).get('cache') as PostgresCacheConfig | undefined
30
+ return new PostgresCache({
31
+ db,
32
+ ...(cfg?.cleanupIntervalMs !== undefined
33
+ ? { cleanupIntervalMs: cfg.cleanupIntervalMs }
34
+ : {}),
35
+ })
36
+ })
37
+ }
38
+
39
+ override async boot(app: Application): Promise<void> {
40
+ app.resolve(Cache)
41
+ }
42
+
43
+ override async shutdown(app: Application): Promise<void> {
44
+ await app.resolve(Cache).close()
45
+ }
46
+ }
@@ -0,0 +1,5 @@
1
+ export { RedisCache, type RedisCacheOptions } from './redis_cache.ts'
2
+ export {
3
+ type RedisCacheConfig,
4
+ RedisCacheProvider,
5
+ } from './redis_cache_provider.ts'