@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 ADDED
@@ -0,0 +1,29 @@
1
+ # @strav/cache
2
+
3
+ Key/value cache with TTLs for Strav 1.0. Apps inject the abstract `Cache` token; the provider in the container picks the concrete driver — `MemoryCache` for single-node dev, `PostgresCache` for multi-node deployments. Same dependency shape as `@strav/broadcast` (kernel-free core in the root, optional Postgres driver under a subpath).
4
+
5
+ ```ts
6
+ import { Cache } from '@strav/cache'
7
+
8
+ @inject()
9
+ class LeadsService {
10
+ constructor(private readonly cache: Cache) {}
11
+
12
+ async trending(): Promise<Lead[]> {
13
+ return this.cache.remember('leads.trending', '5m', async () => {
14
+ return this.leads.query().orderBy('score', 'desc').limit(10).get()
15
+ })
16
+ }
17
+ }
18
+ ```
19
+
20
+ Canonical docs live in [`docs/cache/README.md`](../../docs/cache/README.md).
21
+
22
+ ## What ships
23
+
24
+ | Driver | Subpath | Notes |
25
+ |---|---|---|
26
+ | Memory | `@strav/cache` (root) + `@strav/cache/memory` | In-process. Bounded buffer; locks + tags first-class. Single-node only. |
27
+ | Postgres | `@strav/cache/postgres` | Cross-process backplane via three tables (`strav_cache`, `strav_cache_locks`, `strav_cache_tags`). Atomic increments via row locks, FK cascade keeps tag rows tight. |
28
+
29
+ The full Cache surface (`get`/`put`/`forget`/`has`/`flush`/`add`/`increment`/`decrement`/`remember`/`rememberForever`/`lock`/`tags`) ships on both drivers; the abstract base provides `remember` + `rememberForever` so every driver behaves identically there. No Redis driver yet — apps that need one write against the `Cache` contract (two abstract methods + the wrapper classes for locks/tags).
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@strav/cache",
3
+ "version": "1.0.0-alpha.25",
4
+ "description": "Strav cache layer — Cache abstraction + MemoryCache (in-process) + PostgresCache (cross-process ledger) + RedisCache (Bun.RedisClient, all data structures incl. tagged sets) + MemcachedCache (text-protocol client over Bun.connect). Atomic increments, distributed locks, tagged invalidation. Mirrors @strav/broadcast: kernel-free core in the root, every driver under its own subpath.",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./memory": "./src/drivers/memory/index.ts",
11
+ "./postgres": "./src/drivers/postgres/index.ts",
12
+ "./redis": "./src/drivers/redis/index.ts",
13
+ "./memcached": "./src/drivers/memcached/index.ts"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "README.md"
18
+ ],
19
+ "engines": {
20
+ "bun": ">=1.3.14"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "dependencies": {
26
+ "@strav/kernel": "1.0.0-alpha.25"
27
+ },
28
+ "peerDependencies": {
29
+ "@strav/database": "1.0.0-alpha.25",
30
+ "@types/bun": ">=1.3.14"
31
+ },
32
+ "peerDependenciesMeta": {
33
+ "@strav/database": {
34
+ "optional": true
35
+ }
36
+ },
37
+ "devDependencies": null
38
+ }
package/src/cache.ts ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * `Cache` — the abstract base every driver extends and apps inject
3
+ * via the container.
4
+ *
5
+ * Driver primitives (subclass overrides):
6
+ *
7
+ * - `get(key)` — returns the stored value or `null`. Expired entries
8
+ * return `null` AND are removed in passing.
9
+ * - `put(key, value, ttl?)` — overwrite. `ttl` parsed via `parseTtl`.
10
+ * - `has(key)` — boolean check.
11
+ * - `forget(key)` — delete. Returns true if a key was actually
12
+ * removed.
13
+ * - `flush()` — clear everything. DEV-mode tool; production code
14
+ * should prefer scoped invalidation.
15
+ * - `add(key, value, ttl)` — atomic put-if-absent. Returns true if
16
+ * stored, false if a value was already there (and unexpired).
17
+ * - `increment(key, by)` / `decrement(key, by)` — atomic numeric
18
+ * update. Drivers must guarantee atomicity across concurrent
19
+ * callers; the Memory driver is fine for single-process dev,
20
+ * Postgres uses an UPDATE … RETURNING.
21
+ * - `lock(name, ttl)` — return a `CacheLock` handle. The same `name`
22
+ * across processes / requests competes for one slot.
23
+ * - `tags(...tags)` — return a `TaggedCache` namespace.
24
+ *
25
+ * The base provides:
26
+ *
27
+ * - `remember(key, ttl, fn)` / `rememberForever(key, fn)` — the
28
+ * "get or compute and cache" pattern. Implemented once on top of
29
+ * `get` + `put` so every driver behaves identically.
30
+ * - `close()` — default no-op; drivers override to release pools.
31
+ *
32
+ * Non-abstract on purpose so the class can serve as the container
33
+ * token (`app.singleton(Cache, factory)`). Subclasses MUST override
34
+ * the primitives; the default implementations throw to surface
35
+ * forgotten overrides during development. Same trade-off as
36
+ * `kernel`'s `Logger` and `@strav/broadcast`'s `Broadcaster`.
37
+ */
38
+
39
+ import type { CacheLock, CacheTtl, TaggedCache } from './types.ts'
40
+
41
+ export class Cache {
42
+ // biome-ignore lint/correctness/noUnusedFunctionParameters: subclass contract
43
+ get<T = unknown>(key: string, fallback?: T | null): Promise<T | null> {
44
+ throw new Error('Cache.get must be overridden by the driver subclass.')
45
+ }
46
+ // biome-ignore lint/correctness/noUnusedFunctionParameters: subclass contract
47
+ put(key: string, value: unknown, ttl?: CacheTtl): Promise<void> {
48
+ throw new Error('Cache.put must be overridden by the driver subclass.')
49
+ }
50
+ // biome-ignore lint/correctness/noUnusedFunctionParameters: subclass contract
51
+ has(key: string): Promise<boolean> {
52
+ throw new Error('Cache.has must be overridden by the driver subclass.')
53
+ }
54
+ // biome-ignore lint/correctness/noUnusedFunctionParameters: subclass contract
55
+ forget(key: string): Promise<boolean> {
56
+ throw new Error('Cache.forget must be overridden by the driver subclass.')
57
+ }
58
+ flush(): Promise<void> {
59
+ throw new Error('Cache.flush must be overridden by the driver subclass.')
60
+ }
61
+ // biome-ignore lint/correctness/noUnusedFunctionParameters: subclass contract
62
+ add(key: string, value: unknown, ttl: CacheTtl): Promise<boolean> {
63
+ throw new Error('Cache.add must be overridden by the driver subclass.')
64
+ }
65
+ // biome-ignore lint/correctness/noUnusedFunctionParameters: subclass contract
66
+ increment(key: string, by = 1): Promise<number> {
67
+ throw new Error('Cache.increment must be overridden by the driver subclass.')
68
+ }
69
+ // biome-ignore lint/correctness/noUnusedFunctionParameters: subclass contract
70
+ decrement(key: string, by = 1): Promise<number> {
71
+ throw new Error('Cache.decrement must be overridden by the driver subclass.')
72
+ }
73
+ // biome-ignore lint/correctness/noUnusedFunctionParameters: subclass contract
74
+ lock(name: string, ttl: CacheTtl): CacheLock {
75
+ throw new Error('Cache.lock must be overridden by the driver subclass.')
76
+ }
77
+ // biome-ignore lint/correctness/noUnusedFunctionParameters: subclass contract
78
+ tags(...tags: string[]): TaggedCache {
79
+ throw new Error('Cache.tags must be overridden by the driver subclass.')
80
+ }
81
+
82
+ // ─── Higher-level patterns (driver-agnostic) ───────────────────────────────
83
+
84
+ /**
85
+ * "Get this value, or compute and cache it." The most common
86
+ * cache-aside shape — and the one driver-portable code reaches for
87
+ * by default.
88
+ */
89
+ async remember<T>(key: string, ttl: CacheTtl, fn: () => Promise<T> | T): Promise<T> {
90
+ const cached = await this.get<T>(key)
91
+ if (cached !== null) return cached
92
+ const fresh = await fn()
93
+ await this.put(key, fresh, ttl)
94
+ return fresh
95
+ }
96
+
97
+ /** `remember` with no TTL. The entry persists until `forget` / `flush`. */
98
+ rememberForever<T>(key: string, fn: () => Promise<T> | T): Promise<T> {
99
+ return this.remember(key, null, fn)
100
+ }
101
+
102
+ /** Optional driver-resource cleanup. Default no-op. */
103
+ async close(): Promise<void> {}
104
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Typed error hierarchy. Same shape as `@strav/broadcast` /
3
+ * `@strav/notification` — base `CacheError` extending `StravError`,
4
+ * narrower subclasses with stable `code`s apps can branch on.
5
+ */
6
+
7
+ import { StravError } from '@strav/kernel'
8
+
9
+ interface CacheErrorOptions {
10
+ code?: string
11
+ status?: number
12
+ context?: Record<string, unknown>
13
+ cause?: unknown
14
+ }
15
+
16
+ export class CacheError extends StravError {
17
+ constructor(message: string, options: CacheErrorOptions = {}) {
18
+ super(
19
+ message,
20
+ { code: options.code ?? 'cache.error', status: options.status ?? 500 },
21
+ {
22
+ ...(options.context ? { context: options.context } : {}),
23
+ ...(options.cause !== undefined ? { cause: options.cause } : {}),
24
+ },
25
+ )
26
+ }
27
+ }
28
+
29
+ /** Driver constructed with bad config (empty connection url, etc.). */
30
+ export class CacheConfigError extends CacheError {
31
+ constructor(message: string, options: { context?: Record<string, unknown> } = {}) {
32
+ super(message, {
33
+ code: 'cache.config',
34
+ status: 500,
35
+ ...(options.context ? { context: options.context } : {}),
36
+ })
37
+ }
38
+ }
39
+
40
+ /** A driver primitive rejected at I/O time (network, query failed, etc.). */
41
+ export class CacheDriverError extends CacheError {
42
+ constructor(
43
+ message: string,
44
+ options: { context?: Record<string, unknown>; cause?: unknown } = {},
45
+ ) {
46
+ super(message, {
47
+ code: 'cache.driver',
48
+ status: 502,
49
+ ...(options.context ? { context: options.context } : {}),
50
+ ...(options.cause !== undefined ? { cause: options.cause } : {}),
51
+ })
52
+ }
53
+ }
54
+
55
+ /** `CacheLock.block(timeoutMs, fn)` exhausted its window. */
56
+ export class CacheLockTimeoutError extends CacheError {
57
+ constructor(message: string, options: { context?: Record<string, unknown> } = {}) {
58
+ super(message, {
59
+ code: 'cache.lock_timeout',
60
+ status: 503,
61
+ ...(options.context ? { context: options.context } : {}),
62
+ })
63
+ }
64
+ }
65
+
66
+ /** `cache.put(key, value, ttl)` got a TTL string it couldn't parse. */
67
+ export class CacheTtlParseError extends CacheError {
68
+ constructor(message: string, options: { context?: Record<string, unknown> } = {}) {
69
+ super(message, {
70
+ code: 'cache.ttl_parse',
71
+ status: 400,
72
+ ...(options.context ? { context: options.context } : {}),
73
+ })
74
+ }
75
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `CacheProvider` — registers `MemoryCache` under the `Cache` token by
3
+ * default.
4
+ *
5
+ * Apps that want the Postgres backplane swap providers:
6
+ *
7
+ * import { CacheProvider } from '@strav/cache'
8
+ * import { PostgresCacheProvider } from '@strav/cache/postgres'
9
+ *
10
+ * providers: [
11
+ * ...,
12
+ * new PostgresCacheProvider(), // INSTEAD OF CacheProvider
13
+ * ]
14
+ *
15
+ * Both providers register under the same `Cache` token, so app code
16
+ * injecting `Cache` doesn't change between dev and prod.
17
+ */
18
+
19
+ import { type Application, ConfigRepository, ServiceProvider } from '@strav/kernel'
20
+ import { Cache } from './cache.ts'
21
+ import { MemoryCache, type MemoryCacheOptions } from './drivers/memory/memory_cache.ts'
22
+
23
+ export interface MemoryCacheConfig extends MemoryCacheOptions {
24
+ driver: 'memory'
25
+ }
26
+
27
+ export class CacheProvider extends ServiceProvider {
28
+ override readonly name = 'cache'
29
+ override readonly dependencies = ['config']
30
+
31
+ override register(app: Application): void {
32
+ app.singleton(Cache, (c) => {
33
+ const cfg = c.resolve(ConfigRepository).get('cache') as MemoryCacheConfig | undefined
34
+ return new MemoryCache(cfg !== undefined && cfg.now !== undefined ? { now: cfg.now } : {})
35
+ })
36
+ }
37
+
38
+ override async boot(app: Application): Promise<void> {
39
+ app.resolve(Cache)
40
+ }
41
+
42
+ override async shutdown(app: Application): Promise<void> {
43
+ await app.resolve(Cache).close()
44
+ }
45
+ }
@@ -0,0 +1,6 @@
1
+ export { MemcachedCache, type MemcachedCacheOptions } from './memcached_cache.ts'
2
+ export {
3
+ type MemcachedCacheConfig,
4
+ MemcachedCacheProvider,
5
+ } from './memcached_cache_provider.ts'
6
+ export { MemcachedClient, type MemcachedClientOptions } from './memcached_client.ts'
@@ -0,0 +1,288 @@
1
+ /**
2
+ * `MemcachedCache` — Memcached-backed cross-process cache.
3
+ *
4
+ * Operation mapping (text protocol):
5
+ *
6
+ * - `get` → `get key\r\n` → `VALUE key 0 N\r\n<value>\r\nEND\r\n`.
7
+ * JSON.parse, fall back to the raw string for non-JSON payloads
8
+ * (counters return bare decimal text).
9
+ * - `put` → `set key 0 N bytes\r\n<value>\r\n`.
10
+ * - `has` → `get` + non-null check.
11
+ * - `forget` → `delete key\r\n`.
12
+ * - `flush` → `flush_all\r\n` — wipes the WHOLE server. There's no
13
+ * prefix-scoped equivalent in Memcached. Apps that share their
14
+ * Memcached instance with other code shouldn't use `flush`.
15
+ * - `add` → `add key 0 N bytes\r\n<value>\r\n` (atomic put-if-absent).
16
+ * - `increment` / `decrement` → `incr key by\r\n` (or `decr`).
17
+ * Memcached returns `NOT_FOUND` for missing keys; the driver
18
+ * falls back to an `add` of the delta, then re-`incr`s on race.
19
+ * - `lock(name, ttl)` → `add` (atomic). **No CAS-scoped release** —
20
+ * a slow caller's expired lock can be released by a different
21
+ * holder. Apps that need strict ownership use Redis or Postgres.
22
+ * - `tags(...)` → throws `CacheDriverError`. Memcached has no
23
+ * native sets and no SCAN; emulating tags would require a
24
+ * full-server scan per flush. Out of scope.
25
+ *
26
+ * Keys are namespaced with `prefix` (default `'strav:'`). Memcached
27
+ * keys are ASCII-only, no whitespace, ≤250 bytes — the driver does
28
+ * NOT validate this; callers are expected to keep keys clean.
29
+ */
30
+
31
+ import { Cache } from '../../cache.ts'
32
+ import { CacheConfigError, CacheDriverError, CacheLockTimeoutError } from '../../cache_error.ts'
33
+ import { parseTtl } from '../../ttl.ts'
34
+ import type { CacheLock, CacheTtl, TaggedCache } from '../../types.ts'
35
+ import { MemcachedClient, type MemcachedClientOptions } from './memcached_client.ts'
36
+
37
+ const ENCODER = new TextEncoder()
38
+ const DECODER = new TextDecoder()
39
+
40
+ export interface MemcachedCacheOptions extends Omit<MemcachedClientOptions, 'host' | 'port'> {
41
+ host: string
42
+ port: number
43
+ /** Key namespace prefix. Default `'strav:'`. */
44
+ prefix?: string
45
+ /** Pre-constructed client for tests. */
46
+ client?: MemcachedClient
47
+ }
48
+
49
+ export class MemcachedCache extends Cache {
50
+ private readonly client: MemcachedClient
51
+ private readonly ownsClient: boolean
52
+ private readonly prefix: string
53
+
54
+ constructor(options: MemcachedCacheOptions) {
55
+ super()
56
+ this.prefix = options.prefix ?? 'strav:'
57
+ if (options.client !== undefined) {
58
+ this.client = options.client
59
+ this.ownsClient = false
60
+ } else {
61
+ if (!options.host || !options.port) {
62
+ throw new CacheConfigError(
63
+ 'MemcachedCache requires `host` + `port` (or an injected `client`).',
64
+ )
65
+ }
66
+ this.client = new MemcachedClient({
67
+ host: options.host,
68
+ port: options.port,
69
+ ...(options.connectTimeoutMs !== undefined
70
+ ? { connectTimeoutMs: options.connectTimeoutMs }
71
+ : {}),
72
+ ...(options.requestTimeoutMs !== undefined
73
+ ? { requestTimeoutMs: options.requestTimeoutMs }
74
+ : {}),
75
+ })
76
+ this.ownsClient = true
77
+ }
78
+ }
79
+
80
+ // ─── Core primitives ───────────────────────────────────────────────────────
81
+
82
+ override async get<T = unknown>(key: string, fallback: T | null = null): Promise<T | null> {
83
+ const reply = DECODER.decode(await this.client.send(`get ${this.k(key)}\r\n`))
84
+ if (reply === 'END\r\n') return fallback
85
+ if (!reply.startsWith('VALUE ')) {
86
+ throw new CacheDriverError(`MemcachedCache.get: unexpected reply: ${trimError(reply)}`, {
87
+ context: { key },
88
+ })
89
+ }
90
+ const headerEnd = reply.indexOf('\r\n')
91
+ const header = reply.slice(0, headerEnd) // `VALUE key 0 N`
92
+ const bytes = Number(header.split(' ')[3] ?? '0')
93
+ const valueStart = headerEnd + 2
94
+ const value = reply.slice(valueStart, valueStart + bytes)
95
+ return this.decode<T>(value, fallback)
96
+ }
97
+
98
+ override async put(key: string, value: unknown, ttl?: CacheTtl): Promise<void> {
99
+ const seconds = parseTtl(ttl) ?? 0
100
+ await this.storeCommand('set', key, value, seconds)
101
+ }
102
+
103
+ override async has(key: string): Promise<boolean> {
104
+ const reply = DECODER.decode(await this.client.send(`get ${this.k(key)}\r\n`))
105
+ return !reply.startsWith('END\r\n')
106
+ }
107
+
108
+ override async forget(key: string): Promise<boolean> {
109
+ const reply = DECODER.decode(await this.client.send(`delete ${this.k(key)}\r\n`))
110
+ return reply.startsWith('DELETED')
111
+ }
112
+
113
+ override async flush(): Promise<void> {
114
+ // flush_all is server-wide — apps sharing the instance shouldn't
115
+ // call this. There is no scoped equivalent in Memcached.
116
+ await this.client.send('flush_all\r\n')
117
+ }
118
+
119
+ override async add(key: string, value: unknown, ttl: CacheTtl): Promise<boolean> {
120
+ const seconds = parseTtl(ttl) ?? 0
121
+ return this.storeCommand('add', key, value, seconds)
122
+ }
123
+
124
+ override async increment(key: string, by = 1): Promise<number> {
125
+ return this.adjust(key, by, 'incr')
126
+ }
127
+
128
+ override async decrement(key: string, by = 1): Promise<number> {
129
+ return this.adjust(key, by, 'decr')
130
+ }
131
+
132
+ override lock(name: string, ttl: CacheTtl): CacheLock {
133
+ return new MemcachedCacheLock(this, name, ttl)
134
+ }
135
+
136
+ override tags(..._tags: string[]): TaggedCache {
137
+ throw new CacheDriverError(
138
+ 'MemcachedCache does not support tagged invalidation — Memcached has no native sets and no SCAN. Use RedisCache or PostgresCache for tag support.',
139
+ )
140
+ }
141
+
142
+ override async close(): Promise<void> {
143
+ if (!this.ownsClient) return
144
+ await this.client.close()
145
+ }
146
+
147
+ // ─── Lock internals ────────────────────────────────────────────────────────
148
+
149
+ /** @internal */
150
+ async _tryAcquireLock(name: string, ttl: CacheTtl): Promise<string | undefined> {
151
+ const seconds = parseTtl(ttl)
152
+ if (seconds === null) {
153
+ throw new CacheConfigError('MemcachedCache.lock: TTL must be set — no "forever" locks.')
154
+ }
155
+ const owner = randomToken()
156
+ const stored = await this.storeCommand('add', `lock:${name}`, owner, seconds)
157
+ return stored ? owner : undefined
158
+ }
159
+
160
+ /** @internal */
161
+ async _releaseLock(name: string, owner: string): Promise<boolean> {
162
+ // No CAS-scoped delete in Memcached's text protocol — read the
163
+ // value, compare, then delete. A slim race window exists where
164
+ // another holder acquired between our `get` and `delete`; document
165
+ // the limitation and let apps that need strict ownership pick a
166
+ // different driver.
167
+ const current = await this.get<string>(`lock:${name}`)
168
+ if (current !== owner) return false
169
+ return this.forget(`lock:${name}`)
170
+ }
171
+
172
+ // ─── Internals ─────────────────────────────────────────────────────────────
173
+
174
+ private async storeCommand(
175
+ cmd: 'set' | 'add',
176
+ key: string,
177
+ value: unknown,
178
+ seconds: number,
179
+ ): Promise<boolean> {
180
+ const encoded = ENCODER.encode(JSON.stringify(value))
181
+ const header = `${cmd} ${this.k(key)} 0 ${seconds} ${encoded.length}\r\n`
182
+ const headerBytes = ENCODER.encode(header)
183
+ const trailer = ENCODER.encode('\r\n')
184
+ const payload = new Uint8Array(headerBytes.length + encoded.length + trailer.length)
185
+ payload.set(headerBytes, 0)
186
+ payload.set(encoded, headerBytes.length)
187
+ payload.set(trailer, headerBytes.length + encoded.length)
188
+ const reply = DECODER.decode(await this.client.send(payload))
189
+ if (reply.startsWith('STORED')) return true
190
+ if (reply.startsWith('NOT_STORED')) return false
191
+ throw new CacheDriverError(`MemcachedCache.${cmd}: unexpected reply: ${trimError(reply)}`, {
192
+ context: { key },
193
+ })
194
+ }
195
+
196
+ private async adjust(key: string, by: number, op: 'incr' | 'decr'): Promise<number> {
197
+ const reply = DECODER.decode(await this.client.send(`${op} ${this.k(key)} ${by}\r\n`))
198
+ if (/^\d+/.test(reply)) return Number(reply.split('\r\n')[0])
199
+ if (!reply.startsWith('NOT_FOUND')) {
200
+ throw new CacheDriverError(`MemcachedCache.${op}: unexpected reply: ${trimError(reply)}`, {
201
+ context: { key },
202
+ })
203
+ }
204
+ // NOT_FOUND — try to seed via add(delta). Use no expiry (0) so the
205
+ // counter behaves like Redis (forever until forget).
206
+ const seedValue = op === 'incr' ? by : -by
207
+ const stored = await this.storeCommand('add', key, seedValue, 0)
208
+ if (stored) return seedValue
209
+ // Race — another caller created the key first. Re-run incr/decr.
210
+ const replyAfter = DECODER.decode(await this.client.send(`${op} ${this.k(key)} ${by}\r\n`))
211
+ if (/^\d+/.test(replyAfter)) return Number(replyAfter.split('\r\n')[0])
212
+ throw new CacheDriverError(
213
+ `MemcachedCache.${op}: still NOT_FOUND after seed retry — odd race or non-numeric value at "${key}".`,
214
+ { context: { key } },
215
+ )
216
+ }
217
+
218
+ private k(key: string): string {
219
+ return `${this.prefix}${key}`
220
+ }
221
+
222
+ private decode<T>(raw: string, fallback: T | null): T | null {
223
+ try {
224
+ return JSON.parse(raw) as T
225
+ } catch {
226
+ return raw as unknown as T
227
+ }
228
+ }
229
+ }
230
+
231
+ class MemcachedCacheLock implements CacheLock {
232
+ private owner: string | undefined
233
+
234
+ constructor(
235
+ private readonly cache: MemcachedCache,
236
+ readonly name: string,
237
+ private readonly ttl: CacheTtl,
238
+ ) {}
239
+
240
+ async acquire(): Promise<boolean> {
241
+ if (this.owner !== undefined) return false
242
+ const token = await this.cache._tryAcquireLock(this.name, this.ttl)
243
+ if (token === undefined) return false
244
+ this.owner = token
245
+ return true
246
+ }
247
+
248
+ async release(): Promise<boolean> {
249
+ if (this.owner === undefined) return false
250
+ const released = await this.cache._releaseLock(this.name, this.owner)
251
+ this.owner = undefined
252
+ return released
253
+ }
254
+
255
+ async block<T>(timeoutMs: number, fn: () => Promise<T> | T): Promise<T> {
256
+ if (timeoutMs < 0 || !Number.isFinite(timeoutMs)) {
257
+ throw new Error(
258
+ `CacheLock.block: timeoutMs must be a non-negative finite number; got: ${timeoutMs}`,
259
+ )
260
+ }
261
+ const deadline = Date.now() + timeoutMs
262
+ const pollIntervalMs = 75
263
+ while (true) {
264
+ if (await this.acquire()) {
265
+ try {
266
+ return await fn()
267
+ } finally {
268
+ await this.release()
269
+ }
270
+ }
271
+ if (Date.now() >= deadline) {
272
+ throw new CacheLockTimeoutError(
273
+ `CacheLock "${this.name}" not acquired within ${timeoutMs}ms.`,
274
+ { context: { lock: this.name, timeoutMs } },
275
+ )
276
+ }
277
+ await new Promise<void>((r) => setTimeout(r, pollIntervalMs))
278
+ }
279
+ }
280
+ }
281
+
282
+ function randomToken(): string {
283
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
284
+ }
285
+
286
+ function trimError(reply: string): string {
287
+ return reply.replace(/\r\n$/, '').slice(0, 200)
288
+ }
@@ -0,0 +1,39 @@
1
+ import { type Application, ConfigRepository, ServiceProvider } from '@strav/kernel'
2
+ import { Cache } from '../../cache.ts'
3
+ import { CacheConfigError } from '../../cache_error.ts'
4
+ import { MemcachedCache, type MemcachedCacheOptions } from './memcached_cache.ts'
5
+
6
+ export interface MemcachedCacheConfig extends Omit<MemcachedCacheOptions, 'client'> {
7
+ driver: 'memcached'
8
+ }
9
+
10
+ export class MemcachedCacheProvider extends ServiceProvider {
11
+ override readonly name = 'cache'
12
+ override readonly dependencies = ['config']
13
+
14
+ override register(app: Application): void {
15
+ app.singleton(Cache, (c) => {
16
+ const cfg = c.resolve(ConfigRepository).get('cache') as MemcachedCacheConfig | undefined
17
+ if (cfg === undefined || !cfg.host || !cfg.port) {
18
+ throw new CacheConfigError(
19
+ 'MemcachedCacheProvider: `config.cache.host` and `config.cache.port` are required.',
20
+ )
21
+ }
22
+ return new MemcachedCache({
23
+ host: cfg.host,
24
+ port: cfg.port,
25
+ ...(cfg.prefix !== undefined ? { prefix: cfg.prefix } : {}),
26
+ ...(cfg.connectTimeoutMs !== undefined ? { connectTimeoutMs: cfg.connectTimeoutMs } : {}),
27
+ ...(cfg.requestTimeoutMs !== undefined ? { requestTimeoutMs: cfg.requestTimeoutMs } : {}),
28
+ })
29
+ })
30
+ }
31
+
32
+ override async boot(app: Application): Promise<void> {
33
+ app.resolve(Cache)
34
+ }
35
+
36
+ override async shutdown(app: Application): Promise<void> {
37
+ await app.resolve(Cache).close()
38
+ }
39
+ }