@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,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
|
+
}
|