@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,251 @@
1
+ /**
2
+ * Minimal Memcached text-protocol client over `Bun.connect`.
3
+ *
4
+ * Just enough surface to back `MemcachedCache`: `set` / `add` / `get`
5
+ * / `delete` / `incr` / `decr` / `flush_all`. No SASL auth, no
6
+ * pipelining, no consistent-hash routing — one TCP connection to one
7
+ * server, serialized request stream.
8
+ *
9
+ * Requests are queued; the next request waits for the previous
10
+ * response to land so the parser stays simple (response bytes can
11
+ * only be matched against the head of the queue). Throughput at one
12
+ * server is fine for cache workloads; if you outgrow it, an app
13
+ * builds its own multi-conn wrapper or uses Redis.
14
+ *
15
+ * Bytes go through `TextEncoder` / `TextDecoder` — Memcached's text
16
+ * protocol is single-byte ASCII for control lines but values are
17
+ * binary-safe. The driver `JSON.stringify`s on the way in and is
18
+ * tolerant of non-JSON strings on the way out (counters return
19
+ * decimal strings).
20
+ */
21
+
22
+ const ENCODER = new TextEncoder()
23
+ const DECODER = new TextDecoder()
24
+
25
+ export interface MemcachedClientOptions {
26
+ host: string
27
+ port: number
28
+ /** Connection timeout in ms. Default `5000`. */
29
+ connectTimeoutMs?: number
30
+ /** Per-request response timeout in ms. Default `5000`. */
31
+ requestTimeoutMs?: number
32
+ }
33
+
34
+ interface PendingRequest {
35
+ // Lines we've collected so far (excluding the terminator).
36
+ buffer: Uint8Array
37
+ resolve: (response: Uint8Array) => void
38
+ reject: (err: Error) => void
39
+ timer: ReturnType<typeof setTimeout> | undefined
40
+ }
41
+
42
+ type Socket = Awaited<ReturnType<typeof Bun.connect>>
43
+
44
+ export class MemcachedClient {
45
+ private readonly host: string
46
+ private readonly port: number
47
+ private readonly connectTimeoutMs: number
48
+ private readonly requestTimeoutMs: number
49
+
50
+ private socket: Socket | undefined
51
+ private connecting: Promise<Socket> | undefined
52
+ private closed = false
53
+
54
+ /** FIFO queue of (request → pending response). */
55
+ private readonly queue: PendingRequest[] = []
56
+
57
+ constructor(options: MemcachedClientOptions) {
58
+ this.host = options.host
59
+ this.port = options.port
60
+ this.connectTimeoutMs = options.connectTimeoutMs ?? 5_000
61
+ this.requestTimeoutMs = options.requestTimeoutMs ?? 5_000
62
+ }
63
+
64
+ async send(command: string | Uint8Array): Promise<Uint8Array> {
65
+ if (this.closed) throw new Error('MemcachedClient: closed.')
66
+ const socket = await this.connect()
67
+ return new Promise<Uint8Array>((resolve, reject) => {
68
+ const timer = setTimeout(() => {
69
+ // Pull this request from the queue when it times out so the
70
+ // parser doesn't fire callbacks on a dead promise. Subsequent
71
+ // responses still land on the next pending request.
72
+ const idx = this.queue.findIndex((r) => r === pending)
73
+ if (idx >= 0) this.queue.splice(idx, 1)
74
+ reject(new Error(`MemcachedClient: request timed out after ${this.requestTimeoutMs}ms.`))
75
+ }, this.requestTimeoutMs)
76
+ const pending: PendingRequest = {
77
+ buffer: new Uint8Array(0),
78
+ resolve,
79
+ reject,
80
+ timer,
81
+ }
82
+ this.queue.push(pending)
83
+ const payload = typeof command === 'string' ? ENCODER.encode(command) : command
84
+ socket.write(payload)
85
+ })
86
+ }
87
+
88
+ async close(): Promise<void> {
89
+ this.closed = true
90
+ for (const p of this.queue) {
91
+ if (p.timer !== undefined) clearTimeout(p.timer)
92
+ p.reject(new Error('MemcachedClient: closed.'))
93
+ }
94
+ this.queue.length = 0
95
+ if (this.socket !== undefined) {
96
+ try {
97
+ this.socket.end()
98
+ } catch {
99
+ // Already torn down.
100
+ }
101
+ this.socket = undefined
102
+ }
103
+ }
104
+
105
+ // ─── Internals ─────────────────────────────────────────────────────────────
106
+
107
+ private async connect(): Promise<Socket> {
108
+ if (this.socket !== undefined) return this.socket
109
+ if (this.connecting !== undefined) return this.connecting
110
+ this.connecting = this.openSocket()
111
+ try {
112
+ this.socket = await this.connecting
113
+ return this.socket
114
+ } finally {
115
+ this.connecting = undefined
116
+ }
117
+ }
118
+
119
+ private async openSocket(): Promise<Socket> {
120
+ let resolveOpen: ((socket: Socket) => void) | undefined
121
+ let rejectOpen: ((err: Error) => void) | undefined
122
+ const opened = new Promise<Socket>((res, rej) => {
123
+ resolveOpen = res
124
+ rejectOpen = rej
125
+ })
126
+ const timeout = setTimeout(() => {
127
+ rejectOpen?.(
128
+ new Error(`MemcachedClient: connect timed out after ${this.connectTimeoutMs}ms.`),
129
+ )
130
+ }, this.connectTimeoutMs)
131
+
132
+ const sock = await Bun.connect({
133
+ hostname: this.host,
134
+ port: this.port,
135
+ socket: {
136
+ open: (socket) => {
137
+ clearTimeout(timeout)
138
+ resolveOpen?.(socket as Socket)
139
+ },
140
+ data: (_socket, chunk) => {
141
+ // Bun typings model the chunk as Buffer; downstream code
142
+ // uses Uint8Array operations only, so the cast is safe.
143
+ this.onData(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength))
144
+ },
145
+ error: (_socket, err) => {
146
+ this.failQueue(err)
147
+ },
148
+ close: () => {
149
+ this.failQueue(new Error('MemcachedClient: socket closed.'))
150
+ this.socket = undefined
151
+ },
152
+ },
153
+ })
154
+ return opened.catch((err) => {
155
+ try {
156
+ sock.end()
157
+ } catch {
158
+ // ignore
159
+ }
160
+ throw err
161
+ })
162
+ }
163
+
164
+ /**
165
+ * Append chunk to the head-of-queue request and check whether the
166
+ * accumulated buffer now represents a complete reply. Replies end on
167
+ * one of the protocol's terminator lines:
168
+ *
169
+ * - `END\r\n` (after a `get` / `gets` block)
170
+ * - `STORED\r\n` / `NOT_STORED\r\n` / `EXISTS\r\n` (storage commands)
171
+ * - `DELETED\r\n` / `NOT_FOUND\r\n` / `TOUCHED\r\n`
172
+ * - `OK\r\n` (flush_all, version)
173
+ * - a single decimal line (incr/decr — `12345\r\n`)
174
+ * - `CLIENT_ERROR <msg>\r\n` / `SERVER_ERROR <msg>\r\n` / `ERROR\r\n`
175
+ */
176
+ private onData(chunk: Uint8Array): void {
177
+ while (chunk.length > 0) {
178
+ const pending = this.queue[0]
179
+ if (pending === undefined) {
180
+ // Stray bytes after a queue purge — drop them.
181
+ return
182
+ }
183
+ const merged = new Uint8Array(pending.buffer.length + chunk.length)
184
+ merged.set(pending.buffer)
185
+ merged.set(chunk, pending.buffer.length)
186
+ pending.buffer = merged
187
+ const text = DECODER.decode(pending.buffer)
188
+ const end = findReplyEnd(text)
189
+ if (end === -1) {
190
+ // Need more bytes — exit and wait.
191
+ return
192
+ }
193
+ const replyText = text.slice(0, end)
194
+ const replyBytes = ENCODER.encode(replyText)
195
+ const leftoverText = text.slice(end)
196
+ this.queue.shift()
197
+ if (pending.timer !== undefined) clearTimeout(pending.timer)
198
+ pending.resolve(replyBytes)
199
+ chunk = ENCODER.encode(leftoverText)
200
+ }
201
+ }
202
+
203
+ private failQueue(err: Error): void {
204
+ for (const p of this.queue) {
205
+ if (p.timer !== undefined) clearTimeout(p.timer)
206
+ p.reject(err)
207
+ }
208
+ this.queue.length = 0
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Locate the end-of-reply boundary in the text-form accumulated buffer.
214
+ * Returns the index *after* the terminator. `-1` if the reply is
215
+ * incomplete.
216
+ */
217
+ function findReplyEnd(text: string): number {
218
+ // Multi-line `get` reply ends with `END\r\n` on its own line. Scan
219
+ // for that first — the response may have any number of `VALUE` /
220
+ // data lines before it.
221
+ const endMarker = '\r\nEND\r\n'
222
+ const endIdx = text.indexOf(endMarker)
223
+ if (endIdx >= 0) return endIdx + endMarker.length
224
+
225
+ // Replies that ARE just `END\r\n` (empty get) — the buffer starts
226
+ // with it.
227
+ if (text.startsWith('END\r\n')) return 'END\r\n'.length
228
+
229
+ // Single-line replies — find the first CRLF and check the line.
230
+ const firstCrlf = text.indexOf('\r\n')
231
+ if (firstCrlf === -1) return -1
232
+ const firstLine = text.slice(0, firstCrlf)
233
+ if (SINGLE_LINE_REPLIES.has(firstLine)) return firstCrlf + 2
234
+ if (firstLine.startsWith('CLIENT_ERROR ')) return firstCrlf + 2
235
+ if (firstLine.startsWith('SERVER_ERROR ')) return firstCrlf + 2
236
+ if (firstLine.startsWith('VERSION ')) return firstCrlf + 2
237
+ // incr/decr return a bare decimal value.
238
+ if (/^\d+$/.test(firstLine)) return firstCrlf + 2
239
+ return -1
240
+ }
241
+
242
+ const SINGLE_LINE_REPLIES = new Set([
243
+ 'STORED',
244
+ 'NOT_STORED',
245
+ 'EXISTS',
246
+ 'DELETED',
247
+ 'NOT_FOUND',
248
+ 'TOUCHED',
249
+ 'OK',
250
+ 'ERROR',
251
+ ])
@@ -0,0 +1 @@
1
+ export { MemoryCache, type MemoryCacheOptions } from './memory_cache.ts'
@@ -0,0 +1,279 @@
1
+ /**
2
+ * `MemoryCache` — in-process cache backed by a single `Map`.
3
+ *
4
+ * Right driver for: dev, tests, per-process caches that don't need
5
+ * cross-process visibility. Wrong driver for: production deployments
6
+ * with more than one node (each node sees its own copy).
7
+ *
8
+ * Atomic ops (`add`, `increment`, `decrement`) are atomic *within
9
+ * this process* — Bun's event loop is single-threaded so no JS-level
10
+ * race exists. Cross-process, you want `PostgresCache`.
11
+ *
12
+ * Locks + tags are first-class here for parity with the Postgres
13
+ * driver, but the lock guarantee is only meaningful in a single
14
+ * process (use Postgres for cross-process mutual exclusion). The tag
15
+ * implementation walks two parallel maps (`key → tag-set`,
16
+ * `tag → key-set`) — cheap because everything is in memory.
17
+ */
18
+
19
+ import { ulid } from '@strav/kernel'
20
+ import { Cache } from '../../cache.ts'
21
+ import { CacheLockTimeoutError } from '../../cache_error.ts'
22
+ import { ttlToExpiresAt } from '../../ttl.ts'
23
+ import type { CacheEntry, CacheLock, CacheTtl, TaggedCache } from '../../types.ts'
24
+
25
+ export interface MemoryCacheOptions {
26
+ /**
27
+ * Override the clock for deterministic TTL tests. Default `Date.now`.
28
+ */
29
+ now?: () => number
30
+ }
31
+
32
+ export class MemoryCache extends Cache {
33
+ private readonly entries = new Map<string, CacheEntry<unknown>>()
34
+ private readonly keyTags = new Map<string, Set<string>>()
35
+ private readonly tagKeys = new Map<string, Set<string>>()
36
+ private readonly locks = new Map<string, { owner: string; expiresAt: number }>()
37
+ private readonly nowFn: () => number
38
+
39
+ constructor(options: MemoryCacheOptions = {}) {
40
+ super()
41
+ this.nowFn = options.now ?? Date.now
42
+ }
43
+
44
+ override async get<T = unknown>(key: string, fallback: T | null = null): Promise<T | null> {
45
+ const entry = this.entries.get(key)
46
+ if (entry === undefined) return fallback
47
+ if (this.expired(entry)) {
48
+ this.entries.delete(key)
49
+ this.removeKeyFromAllTags(key)
50
+ return fallback
51
+ }
52
+ return entry.value as T
53
+ }
54
+
55
+ override async put(key: string, value: unknown, ttl?: CacheTtl): Promise<void> {
56
+ this.entries.set(key, { value, expiresAt: ttlToExpiresAt(ttl, this.nowFn()) })
57
+ }
58
+
59
+ override async has(key: string): Promise<boolean> {
60
+ const entry = this.entries.get(key)
61
+ if (entry === undefined) return false
62
+ if (this.expired(entry)) {
63
+ this.entries.delete(key)
64
+ this.removeKeyFromAllTags(key)
65
+ return false
66
+ }
67
+ return true
68
+ }
69
+
70
+ override async forget(key: string): Promise<boolean> {
71
+ this.removeKeyFromAllTags(key)
72
+ return this.entries.delete(key)
73
+ }
74
+
75
+ override async flush(): Promise<void> {
76
+ this.entries.clear()
77
+ this.keyTags.clear()
78
+ this.tagKeys.clear()
79
+ // Active locks aren't cleared — they're a separate concept from
80
+ // cached values, and `flush()` shouldn't surprise a holder by
81
+ // dropping their lock.
82
+ }
83
+
84
+ override async add(key: string, value: unknown, ttl: CacheTtl): Promise<boolean> {
85
+ const existing = this.entries.get(key)
86
+ if (existing !== undefined && !this.expired(existing)) return false
87
+ this.entries.set(key, { value, expiresAt: ttlToExpiresAt(ttl, this.nowFn()) })
88
+ return true
89
+ }
90
+
91
+ override async increment(key: string, by = 1): Promise<number> {
92
+ return this.adjust(key, by)
93
+ }
94
+
95
+ override async decrement(key: string, by = 1): Promise<number> {
96
+ return this.adjust(key, -by)
97
+ }
98
+
99
+ override lock(name: string, ttl: CacheTtl): CacheLock {
100
+ return new MemoryCacheLock(this, name, ttl)
101
+ }
102
+
103
+ override tags(...tags: string[]): TaggedCache {
104
+ return new MemoryTaggedCache(this, tags)
105
+ }
106
+
107
+ // ─── Diagnostics / helpers ─────────────────────────────────────────────────
108
+
109
+ /** Total entry count, including expired-but-not-yet-evicted rows. */
110
+ size(): number {
111
+ return this.entries.size
112
+ }
113
+
114
+ // ─── Driver-internals exposed to the lock + tagged wrappers ────────────────
115
+
116
+ /** @internal */
117
+ _now(): number {
118
+ return this.nowFn()
119
+ }
120
+
121
+ /** @internal */
122
+ _tryAcquireLock(name: string, ttl: CacheTtl): string | undefined {
123
+ const current = this.locks.get(name)
124
+ if (current !== undefined && current.expiresAt > this.nowFn()) return undefined
125
+ const owner = ulid()
126
+ this.locks.set(name, { owner, expiresAt: ttlToExpiresAt(ttl, this.nowFn()) ?? Infinity })
127
+ return owner
128
+ }
129
+
130
+ /** @internal */
131
+ _releaseLock(name: string, owner: string): boolean {
132
+ const current = this.locks.get(name)
133
+ if (current === undefined) return false
134
+ if (current.owner !== owner) return false
135
+ this.locks.delete(name)
136
+ return true
137
+ }
138
+
139
+ /** @internal */
140
+ _putWithTags(key: string, value: unknown, ttl: CacheTtl, tags: readonly string[]): void {
141
+ this.entries.set(key, { value, expiresAt: ttlToExpiresAt(ttl, this.nowFn()) })
142
+ this.setKeyTags(key, tags)
143
+ }
144
+
145
+ /** @internal */
146
+ _flushTags(tags: readonly string[]): number {
147
+ const toDrop = new Set<string>()
148
+ for (const tag of tags) {
149
+ const keys = this.tagKeys.get(tag)
150
+ if (keys === undefined) continue
151
+ for (const k of keys) toDrop.add(k)
152
+ }
153
+ for (const k of toDrop) {
154
+ this.entries.delete(k)
155
+ this.removeKeyFromAllTags(k)
156
+ }
157
+ return toDrop.size
158
+ }
159
+
160
+ private adjust(key: string, delta: number): number {
161
+ const entry = this.entries.get(key)
162
+ if (entry === undefined || this.expired(entry)) {
163
+ this.entries.set(key, { value: delta, expiresAt: null })
164
+ return delta
165
+ }
166
+ const current = typeof entry.value === 'number' ? entry.value : Number(entry.value)
167
+ const next = current + delta
168
+ entry.value = next
169
+ return next
170
+ }
171
+
172
+ private setKeyTags(key: string, tags: readonly string[]): void {
173
+ // Reset existing tag wiring for this key first — same key getting
174
+ // re-tagged is a legit operation, and stale tag links would
175
+ // make `flushTags` over-delete.
176
+ this.removeKeyFromAllTags(key)
177
+ const tagSet = new Set(tags)
178
+ this.keyTags.set(key, tagSet)
179
+ for (const tag of tagSet) {
180
+ let bucket = this.tagKeys.get(tag)
181
+ if (bucket === undefined) {
182
+ bucket = new Set()
183
+ this.tagKeys.set(tag, bucket)
184
+ }
185
+ bucket.add(key)
186
+ }
187
+ }
188
+
189
+ private removeKeyFromAllTags(key: string): void {
190
+ const tagSet = this.keyTags.get(key)
191
+ if (tagSet === undefined) return
192
+ for (const tag of tagSet) {
193
+ const bucket = this.tagKeys.get(tag)
194
+ if (bucket === undefined) continue
195
+ bucket.delete(key)
196
+ if (bucket.size === 0) this.tagKeys.delete(tag)
197
+ }
198
+ this.keyTags.delete(key)
199
+ }
200
+
201
+ private expired(entry: CacheEntry<unknown>): boolean {
202
+ return entry.expiresAt !== null && entry.expiresAt <= this.nowFn()
203
+ }
204
+ }
205
+
206
+ class MemoryCacheLock implements CacheLock {
207
+ private owner: string | undefined
208
+
209
+ constructor(
210
+ private readonly cache: MemoryCache,
211
+ readonly name: string,
212
+ private readonly ttl: CacheTtl,
213
+ ) {}
214
+
215
+ async acquire(): Promise<boolean> {
216
+ if (this.owner !== undefined) return false
217
+ const token = this.cache._tryAcquireLock(this.name, this.ttl)
218
+ if (token === undefined) return false
219
+ this.owner = token
220
+ return true
221
+ }
222
+
223
+ async release(): Promise<boolean> {
224
+ if (this.owner === undefined) return false
225
+ const released = this.cache._releaseLock(this.name, this.owner)
226
+ this.owner = undefined
227
+ return released
228
+ }
229
+
230
+ async block<T>(timeoutMs: number, fn: () => Promise<T> | T): Promise<T> {
231
+ // parseTtl validates the input even though we don't use seconds here.
232
+ if (timeoutMs < 0 || !Number.isFinite(timeoutMs)) {
233
+ throw new Error(
234
+ `CacheLock.block: timeoutMs must be a non-negative finite number; got: ${timeoutMs}`,
235
+ )
236
+ }
237
+ const deadline = this.cache._now() + timeoutMs
238
+ const pollIntervalMs = 50
239
+ while (true) {
240
+ if (await this.acquire()) {
241
+ try {
242
+ return await fn()
243
+ } finally {
244
+ await this.release()
245
+ }
246
+ }
247
+ if (this.cache._now() >= deadline) {
248
+ throw new CacheLockTimeoutError(
249
+ `CacheLock "${this.name}" not acquired within ${timeoutMs}ms.`,
250
+ { context: { lock: this.name, timeoutMs } },
251
+ )
252
+ }
253
+ await new Promise<void>((r) => setTimeout(r, pollIntervalMs))
254
+ }
255
+ }
256
+ }
257
+
258
+ class MemoryTaggedCache implements TaggedCache {
259
+ constructor(
260
+ private readonly cache: MemoryCache,
261
+ readonly tags: readonly string[],
262
+ ) {}
263
+
264
+ async put(key: string, value: unknown, ttl?: CacheTtl): Promise<void> {
265
+ this.cache._putWithTags(key, value, ttl, this.tags)
266
+ }
267
+
268
+ get<T = unknown>(key: string, fallback: T | null = null): Promise<T | null> {
269
+ return this.cache.get<T>(key, fallback)
270
+ }
271
+
272
+ forget(key: string): Promise<boolean> {
273
+ return this.cache.forget(key)
274
+ }
275
+
276
+ async flush(): Promise<number> {
277
+ return this.cache._flushTags(this.tags)
278
+ }
279
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * `applyCacheMigration` — emit DDL for the three tables `PostgresCache`
3
+ * uses.
4
+ *
5
+ * - `strav_cache (key text PK, data jsonb, expires_at timestamptz NULL)`
6
+ * — the main key/value store. `expires_at` indexed for the
7
+ * periodic sweep.
8
+ * - `strav_cache_locks (name text PK, owner text NOT NULL,
9
+ * expires_at timestamptz NOT NULL)` — distributed-lock slots.
10
+ * - `strav_cache_tags (key text NOT NULL, tag text NOT NULL,
11
+ * PRIMARY KEY (key, tag))` — tag-to-key index. `tag` indexed
12
+ * for the flush path. FK to `strav_cache.key` ON DELETE CASCADE
13
+ * so cache-entry deletion takes its tag wiring with it.
14
+ *
15
+ * Schemas are NOT registered with the SchemaRegistry because the cache
16
+ * table layout (text PK, composite PK on tags) doesn't fit the schema
17
+ * DSL — apps just invoke this helper from a migration `up()` and DROP
18
+ * the tables in `down()`.
19
+ */
20
+
21
+ import type { DatabaseExecutor } from '@strav/database'
22
+
23
+ export async function applyCacheMigration(db: DatabaseExecutor): Promise<void> {
24
+ await db.execute(
25
+ `CREATE TABLE IF NOT EXISTS "strav_cache" (
26
+ "key" text PRIMARY KEY,
27
+ "data" jsonb NOT NULL,
28
+ "expires_at" timestamptz NULL
29
+ )`,
30
+ )
31
+ await db.execute(
32
+ `CREATE INDEX IF NOT EXISTS "idx_strav_cache_expires_at"
33
+ ON "strav_cache" ("expires_at")
34
+ WHERE "expires_at" IS NOT NULL`,
35
+ )
36
+ await db.execute(
37
+ `CREATE TABLE IF NOT EXISTS "strav_cache_locks" (
38
+ "name" text PRIMARY KEY,
39
+ "owner" text NOT NULL,
40
+ "expires_at" timestamptz NOT NULL
41
+ )`,
42
+ )
43
+ await db.execute(
44
+ `CREATE TABLE IF NOT EXISTS "strav_cache_tags" (
45
+ "key" text NOT NULL,
46
+ "tag" text NOT NULL,
47
+ PRIMARY KEY ("key", "tag"),
48
+ FOREIGN KEY ("key") REFERENCES "strav_cache" ("key") ON DELETE CASCADE
49
+ )`,
50
+ )
51
+ await db.execute(
52
+ `CREATE INDEX IF NOT EXISTS "idx_strav_cache_tags_tag" ON "strav_cache_tags" ("tag")`,
53
+ )
54
+ }
@@ -0,0 +1,10 @@
1
+ export { applyCacheMigration } from './apply_cache_migration.ts'
2
+ export {
3
+ PostgresCache,
4
+ type PostgresCacheDatabase,
5
+ type PostgresCacheOptions,
6
+ } from './postgres_cache.ts'
7
+ export {
8
+ type PostgresCacheConfig,
9
+ PostgresCacheProvider,
10
+ } from './postgres_cache_provider.ts'