@brust/cache-redis 0.0.0-stage → 2.0.0-alpha.4

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 CHANGED
@@ -1,3 +1,6 @@
1
- # Temporary Holding Version
1
+ # @brust/cache-redis
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The Redis and Dragonfly cache adapter for brust (`[cache] adapter = "redis"` or `"dragonfly"`).
4
+ Built on Bun's `RedisClient`: no npm dependency, Bun only (not Cloudflare Workers).
5
+
6
+ Configure it in `brust.toml` (`[cache]` + `[cache.redis]`, the URL always via `env:NAME`); see the brust docs, "Redis / Dragonfly".
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "@brust/cache-redis",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "2.0.0-alpha.4",
4
+ "description": "brust cache adapter for Redis and Dragonfly (Bun RedisClient, no npm dependency)",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/AssetsArt/brust.git"
9
+ },
10
+ "type": "module",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./src/index.ts",
14
+ "default": "./src/index.ts"
15
+ },
16
+ "./dragonfly": {
17
+ "types": "./src/dragonfly.ts",
18
+ "default": "./src/dragonfly.ts"
19
+ }
20
+ },
21
+ "scripts": {
22
+ "test": "bun test",
23
+ "typecheck": "bun check"
24
+ },
25
+ "dependencies": {},
26
+ "peerDependencies": {
27
+ "@brust/core": "2.0.0-alpha.4"
28
+ },
29
+ "devDependencies": {
30
+ "@brust/core": "2.0.0-alpha.4",
31
+ "@types/bun": "^1.4.0",
32
+ "typescript": "^7.0.2"
33
+ },
34
+ "engines": {
35
+ "bun": ">=1.2.23"
36
+ },
37
+ "files": [
38
+ "src",
39
+ "README.md"
40
+ ],
41
+ "publishConfig": {
42
+ "access": "public"
43
+ }
44
+ }
package/src/adapter.ts ADDED
@@ -0,0 +1,114 @@
1
+ import type { CacheAdapter, CacheAdapterInit, GetResult, InvalidateArgs, InvalidationEvent, SetMeta, StoredPage } from '@brust/core/cache-adapter'
2
+ import { Link } from './conn'
3
+ import { decodeFrame, encodeFrame } from './frame'
4
+ import { type Keys, keys as makeKeys } from './keys'
5
+ import { evalsha, SET_SCRIPT } from './lua'
6
+ import { Pairing } from './pairing'
7
+ import { parseOptions, type ResolvedOptions } from './options'
8
+ import { type SubscriberState, startSubscriber } from './subscriber'
9
+
10
+ const STAGE_TTL_MS = 30_000
11
+
12
+ export interface RedisAdapterTest {
13
+ /** Close the subscriber socket as a network loss would. */
14
+ dropSubscriber(): void
15
+ state(): { connected: boolean; subscribed: boolean; last: number | null; reconnects: number }
16
+ /** The command client (raw access for tests). */
17
+ client(): import('bun').RedisClient
18
+ keys(): Keys
19
+ }
20
+
21
+ export type RedisAdapter = CacheAdapter & { __test: RedisAdapterTest }
22
+
23
+ export function createAdapter(name: 'redis' | 'dragonfly', raw: Record<string, unknown>): RedisAdapter {
24
+ const o: ResolvedOptions = parseOptions(raw)
25
+ let ks: Keys | null = null
26
+ let cmd: Link | null = null
27
+ const k = (): Keys => ks ?? fail()
28
+ const link = (): Link => cmd ?? fail()
29
+ const fail = (): never => {
30
+ throw new Error('cache-redis: used before init')
31
+ }
32
+ const sub: SubscriberState = { link: null, subscribed: false, last: null }
33
+ const pairing = new Pairing(() => link().c, k, o.markerTtlMs)
34
+
35
+ const store = {
36
+ maxBodyBytes: o.maxBodyBytes,
37
+ async get(key: string): Promise<GetResult> {
38
+ const c = link().c
39
+ const [buf, seq] = await Promise.all([c.getBuffer(k().page(key)), c.get(k().seq)])
40
+ const stamp = Number(seq ?? 0)
41
+ if (buf === null) return { page: null, stamp, entryStamp: null, storedAt: null }
42
+ const f = decodeFrame(buf)
43
+ if (f === null) throw new Error('cache-redis: stored page has an unknown frame version')
44
+ return { page: f.page, stamp, entryStamp: f.entryStamp, storedAt: f.storedAt }
45
+ },
46
+ async set(key: string, page: StoredPage, meta: SetMeta): Promise<boolean> {
47
+ if (page.body.length > o.maxBodyBytes) return false
48
+ const c = link().c
49
+ const K = k()
50
+ const method = meta.method.toUpperCase()
51
+ const tags = [...new Set(meta.tags)]
52
+ const stage = K.stage(crypto.randomUUID())
53
+ const idx = [...tags.map((t) => K.tag(t)), K.path(method, meta.path)]
54
+ const marks = [...tags.map((t) => K.markTag(t)), K.markPath(method, meta.path)]
55
+ const frame = encodeFrame(page, { entryStamp: meta.stamp, storedAt: Date.now(), tags, method, path: meta.path })
56
+ // Both commands go out on the one auto-pipelined connection: one round trip.
57
+ const staged = c.set(stage, frame, 'PX', STAGE_TTL_MS)
58
+ const ran = evalsha(c, SET_SCRIPT, [stage, K.page(key), ...idx, ...marks], [meta.stamp === null ? '' : meta.stamp, Math.max(1, Math.ceil(meta.ttlMs)), idx.length])
59
+ const [a, b] = await Promise.allSettled([staged, ran])
60
+ if (a.status === 'rejected') throw a.reason
61
+ if (b.status === 'rejected') throw b.reason
62
+ if (b.value === -1) {
63
+ console.warn('[brust] cache-redis: staging key vanished before the guarded write')
64
+ return false
65
+ }
66
+ return b.value === 1
67
+ },
68
+ async invalidate(args: InvalidateArgs): Promise<void> {
69
+ const { members } = await pairing.run(args, 'invalidate')
70
+ if (!members.length) return
71
+ const c = link().c
72
+ // UNLINK frees off the main thread (Dragonfly too); batched so one call never carries every key.
73
+ const jobs: Promise<number>[] = []
74
+ for (let i = 0; i < members.length; i += o.unlinkBatch) jobs.push(c.unlink(...members.slice(i, i + o.unlinkBatch)))
75
+ await Promise.all(jobs)
76
+ },
77
+ }
78
+
79
+ const broadcast = {
80
+ async publish(args: InvalidateArgs, origin: string): Promise<number> {
81
+ const { seq } = await pairing.run(args, 'publish')
82
+ await link().c.publish(k().channel, JSON.stringify({ seq, origin, args }))
83
+ return seq
84
+ },
85
+ async subscribe(on: (e: InvalidationEvent) => void, onGap: () => void): Promise<() => Promise<void>> {
86
+ return startSubscriber(o, k(), async () => Number((await link().c.get(k().seq)) ?? 0), sub, on, onGap)
87
+ },
88
+ }
89
+
90
+ const adapter: RedisAdapter = {
91
+ name,
92
+ runtimes: ['bun'],
93
+ store,
94
+ broadcast,
95
+ async init(ctx: CacheAdapterInit) {
96
+ ks = makeKeys(ctx.namespace)
97
+ cmd = new Link(o, 'commands')
98
+ await cmd.start()
99
+ },
100
+ async close() {
101
+ sub.link?.close()
102
+ cmd?.close()
103
+ },
104
+ __test: {
105
+ dropSubscriber: () => sub.link?.drop(),
106
+ state: () => ({ connected: !!cmd?.connected, subscribed: sub.subscribed, last: sub.last, reconnects: (cmd?.reconnects ?? 0) + (sub.link?.reconnects ?? 0) }),
107
+ client: () => link().c,
108
+ keys: () => k(),
109
+ },
110
+ }
111
+ return adapter
112
+ }
113
+
114
+ export type { InvalidationEvent }
package/src/conn.ts ADDED
@@ -0,0 +1,128 @@
1
+ // A Bun RedisClient with an adapter-owned reconnect loop (cache-redis R8): the client itself never
2
+ // reconnects and never queues offline, so a dead server fails commands at once instead of pinning
3
+ // a worker slot until the get budget. A fresh client per attempt (a closed one is not reusable).
4
+ import { RedisClient } from 'bun'
5
+ import type { ResolvedOptions } from './options'
6
+ import { redact } from './redact'
7
+
8
+ export class Link {
9
+ private client: RedisClient | null = null
10
+ private closing = false
11
+ private timer: ReturnType<typeof setTimeout> | null = null
12
+ private fails = 0
13
+ private warned = 0
14
+ /** Reconnect attempts started (a test hook: stable once `close()` ran). */
15
+ reconnects = 0
16
+
17
+ constructor(
18
+ private readonly o: ResolvedOptions,
19
+ private readonly what: string,
20
+ /** Runs on every successful connect (subscribe + gap check); a throw drops the connection and retries. */
21
+ private readonly onUp?: (c: RedisClient) => Promise<void>,
22
+ /** Runs when a live connection is lost. */
23
+ private readonly onDown?: () => void,
24
+ ) {}
25
+
26
+ get connected(): boolean {
27
+ return this.client !== null
28
+ }
29
+
30
+ /** The live client; throws at once when disconnected. */
31
+ get c(): RedisClient {
32
+ if (!this.client) throw new Error(`cache-redis: not connected to ${redact(this.o.url)}`)
33
+ return this.client
34
+ }
35
+
36
+ /** One bounded connect; a failure starts the loop. Never throws for network reasons (R9). */
37
+ async start(): Promise<void> {
38
+ if (!(await this.attempt())) this.schedule()
39
+ }
40
+
41
+ /** Close the socket as a network loss would (the loop reconnects). */
42
+ drop(): void {
43
+ const cl = this.client
44
+ if (cl) {
45
+ cl.close()
46
+ this.lost(cl)
47
+ }
48
+ }
49
+
50
+ close(): void {
51
+ this.closing = true
52
+ if (this.timer) clearTimeout(this.timer)
53
+ this.timer = null
54
+ const cl = this.client
55
+ this.client = null
56
+ cl?.close()
57
+ }
58
+
59
+ private async attempt(): Promise<boolean> {
60
+ const { url, tls, connectTimeoutMs } = this.o
61
+ const cl = new RedisClient(url, { autoReconnect: false, enableOfflineQueue: false, connectionTimeout: connectTimeoutMs, ...(tls ? { tls } : {}) })
62
+ cl.onclose = (e) => {
63
+ if (this.client === cl) this.lost(cl, e)
64
+ }
65
+ let timer: ReturnType<typeof setTimeout> | undefined
66
+ try {
67
+ await Promise.race([
68
+ cl.connect(),
69
+ new Promise<never>((_, rej) => {
70
+ timer = setTimeout(() => rej(new Error('connect timed out')), connectTimeoutMs)
71
+ }),
72
+ ])
73
+ clearTimeout(timer)
74
+ if (this.closing) {
75
+ cl.close()
76
+ return true
77
+ }
78
+ this.client = cl
79
+ await this.onUp?.(cl)
80
+ this.fails = 0
81
+ this.warned = 0
82
+ return true
83
+ } catch (e) {
84
+ clearTimeout(timer)
85
+ if (this.client === cl) this.client = null
86
+ cl.close()
87
+ this.warn(e)
88
+ return false
89
+ }
90
+ }
91
+
92
+ private lost(cl: RedisClient, e?: unknown): void {
93
+ if (this.client !== cl) return
94
+ this.client = null
95
+ this.onDown?.()
96
+ if (this.closing) return
97
+ this.warn(e ?? new Error('connection lost'))
98
+ this.schedule()
99
+ }
100
+
101
+ private schedule(): void {
102
+ if (this.closing || this.timer) return
103
+ const { reconnectMinMs, reconnectMaxMs } = this.o
104
+ const base = Math.min(reconnectMaxMs, reconnectMinMs * 2 ** Math.min(this.fails++, 20))
105
+ const delay = base * (0.8 + Math.random() * 0.4)
106
+ this.timer = setTimeout(async () => {
107
+ this.timer = null
108
+ if (this.closing) return
109
+ this.reconnects++
110
+ if (!(await this.attempt())) this.schedule()
111
+ }, delay)
112
+ }
113
+
114
+ /** The first failure, then every 10th; host:port only, the password scrubbed from the message. */
115
+ private warn(e: unknown): void {
116
+ if (this.warned++ % 10 !== 0) return
117
+ console.warn(`[brust] cache-redis ${this.what}: ${redact(this.o.url)} unavailable (${this.scrub(e)}); retrying`)
118
+ }
119
+
120
+ private scrub(e: unknown): string {
121
+ let m = e instanceof Error ? e.message : String(e)
122
+ try {
123
+ const u = new URL(this.o.url)
124
+ for (const s of [this.o.url, u.password, u.username]) if (s) m = m.split(s).join('<redacted>')
125
+ } catch {}
126
+ return m.slice(0, 200)
127
+ }
128
+ }
@@ -0,0 +1,11 @@
1
+ import { createAdapter, type RedisAdapter } from './adapter'
2
+
3
+ export type { RedisAdapter, RedisAdapterTest } from './adapter'
4
+ export type { RedisCacheOptions } from './options'
5
+
6
+ /** `[cache] adapter = "dragonfly"`: the same code as Redis (every script declares all its keys). */
7
+ export function dragonflyAdapter(options: Record<string, unknown>): RedisAdapter {
8
+ return createAdapter('dragonfly', options)
9
+ }
10
+
11
+ export default dragonflyAdapter
package/src/frame.ts ADDED
@@ -0,0 +1,46 @@
1
+ // One page = one binary string (cache-redis R3): [u32 LE meta_len][meta JSON][body].
2
+ import type { StoredPage } from '@brust/core/cache-adapter'
3
+
4
+ export interface FrameMeta {
5
+ v: 1
6
+ status: number
7
+ headers: [string, string][]
8
+ entryStamp: number | null
9
+ storedAt: number
10
+ tags: string[]
11
+ method: string
12
+ path: string
13
+ }
14
+
15
+ export interface DecodedFrame {
16
+ page: StoredPage
17
+ entryStamp: number | null
18
+ storedAt: number
19
+ }
20
+
21
+ const enc = new TextEncoder()
22
+ const dec = new TextDecoder()
23
+
24
+ export function encodeFrame(page: StoredPage, m: Omit<FrameMeta, 'v' | 'status' | 'headers'>): Uint8Array {
25
+ const meta = enc.encode(JSON.stringify({ v: 1, status: page.status, headers: page.headers, ...m } satisfies FrameMeta))
26
+ const out = new Uint8Array(4 + meta.length + page.body.length)
27
+ new DataView(out.buffer).setUint32(0, meta.length, true)
28
+ out.set(meta, 4)
29
+ out.set(page.body, 4 + meta.length)
30
+ return out
31
+ }
32
+
33
+ /** `null` = a frame of an unknown version (a miss); throws on a truncated or corrupt frame. */
34
+ export function decodeFrame(buf: Uint8Array): DecodedFrame | null {
35
+ if (buf.length < 4) throw new Error('cache-redis: truncated page frame')
36
+ const len = new DataView(buf.buffer, buf.byteOffset, buf.byteLength).getUint32(0, true)
37
+ if (4 + len > buf.length) throw new Error('cache-redis: page frame meta length exceeds the frame')
38
+ const meta = JSON.parse(dec.decode(buf.subarray(4, 4 + len))) as Partial<FrameMeta> & { v?: unknown }
39
+ if (meta.v !== 1) return null
40
+ if (typeof meta.status !== 'number' || !Array.isArray(meta.headers)) throw new Error('cache-redis: corrupt page frame meta')
41
+ return {
42
+ page: { status: meta.status, headers: meta.headers, body: buf.slice(4 + len) },
43
+ entryStamp: typeof meta.entryStamp === 'number' ? meta.entryStamp : null,
44
+ storedAt: typeof meta.storedAt === 'number' ? meta.storedAt : 0,
45
+ }
46
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { createAdapter, type RedisAdapter } from './adapter'
2
+
3
+ export type { RedisAdapter, RedisAdapterTest } from './adapter'
4
+ export type { RedisCacheOptions } from './options'
5
+
6
+ /** `[cache] adapter = "redis"`: the factory brust calls with `[cache.redis]` (after `env:` resolution). */
7
+ export function redisAdapter(options: Record<string, unknown>): RedisAdapter {
8
+ return createAdapter('redis', options)
9
+ }
10
+
11
+ export default redisAdapter
package/src/keys.ts ADDED
@@ -0,0 +1,47 @@
1
+ // The key schema (cache-redis R1): every key of a namespace carries the cluster hash tag
2
+ // `{brust:<ns>}`, so the multi-key scripts stay on one slot. Only page keys are build-scoped
3
+ // (their L2 key carries the build); index sets, markers, seq and the channel are namespace-level.
4
+ import type { InvalidateArgs } from '@brust/core/cache-adapter'
5
+
6
+ export interface Keys {
7
+ page(l2Key: string): string
8
+ tag(tag: string): string
9
+ path(method: string | undefined, path: string): string
10
+ markTag(tag: string): string
11
+ markPath(method: string | undefined, path: string): string
12
+ seq: string
13
+ stage(id: string): string
14
+ channel: string
15
+ /** SCAN pattern of every key of the namespace. */
16
+ all: string
17
+ }
18
+
19
+ const pathName = (method: string | undefined, path: string) => `${(method ?? 'GET').toUpperCase()} ${path}`
20
+
21
+ export function keys(ns: string): Keys {
22
+ const p = `{brust:${ns}}`
23
+ return {
24
+ page: (k) => `${p}:page:${k}`,
25
+ tag: (t) => `${p}:tag:${t}`,
26
+ path: (m, path) => `${p}:path:${pathName(m, path)}`,
27
+ markTag: (t) => `${p}:inval:tag:${t}`,
28
+ markPath: (m, path) => `${p}:inval:path:${pathName(m, path)}`,
29
+ seq: `${p}:seq`,
30
+ stage: (id) => `${p}:stage:${id}`,
31
+ channel: `brust:${ns}:inval`,
32
+ all: `${p}:*`,
33
+ }
34
+ }
35
+
36
+ /** Order- and case-insensitive identity of an invalidation (pairs its store and broadcast halves). */
37
+ export function canonical(a: InvalidateArgs): string {
38
+ return JSON.stringify({
39
+ tags: [...new Set(a.tags ?? [])].sort(),
40
+ ...(a.path !== undefined ? { path: a.path, method: (a.method ?? 'GET').toUpperCase() } : {}),
41
+ })
42
+ }
43
+
44
+ /** Does the invalidation touch pages? (A key-only one is tier 1's business: the store holds none by key.) */
45
+ export function hasStoreWork(a: InvalidateArgs): boolean {
46
+ return (a.tags?.length ?? 0) > 0 || a.path !== undefined
47
+ }
package/src/lua.ts ADDED
@@ -0,0 +1,59 @@
1
+ // The two scripts (cache-redis R4, R6). The text is exact: the reviewer diffs it against the plan.
2
+ // Every key a script touches is declared in KEYS (Dragonfly needs it; cluster needs one slot).
3
+ import type { RedisClient } from 'bun'
4
+
5
+ // SET_LUA KEYS = [stage, page, idx_1..idx_n, mark_1..mark_n] ARGV = [stamp|"", ttl_ms, n]
6
+ export const SET_LUA = `local n = tonumber(ARGV[3]); local ttl = tonumber(ARGV[2])
7
+ if ARGV[1] ~= '' then
8
+ local stamp = tonumber(ARGV[1])
9
+ for i = 1, n do
10
+ local m = redis.call('GET', KEYS[2 + n + i])
11
+ if m and tonumber(m) > stamp then redis.call('DEL', KEYS[1]); return 0 end
12
+ end
13
+ end
14
+ if redis.call('EXISTS', KEYS[1]) == 0 then return -1 end
15
+ redis.call('RENAME', KEYS[1], KEYS[2]); redis.call('PEXPIRE', KEYS[2], ttl)
16
+ for i = 1, n do
17
+ redis.call('SADD', KEYS[2 + i], KEYS[2])
18
+ local t = redis.call('PTTL', KEYS[2 + i])
19
+ if t == -1 or (t >= 0 and t < ttl) then redis.call('PEXPIRE', KEYS[2 + i], ttl) end
20
+ end
21
+ return 1`
22
+
23
+ // INVAL_LUA KEYS = [seq, idx_1..idx_n, mark_1..mark_n] ARGV = [mode ('both'|'delete'|'publish'), n, marker_ttl_ms]
24
+ export const INVAL_LUA = `local unpack = table.unpack or unpack -- Lua 5.1 (Redis) and 5.4 (Dragonfly)
25
+ local mode = ARGV[1]; local n = tonumber(ARGV[2]); local mttl = tonumber(ARGV[3])
26
+ local v
27
+ if mode == 'delete' then v = tonumber(redis.call('GET', KEYS[1]) or '0') + 1 else v = redis.call('INCR', KEYS[1]) end
28
+ for i = 1, n do
29
+ local k = KEYS[1 + n + i]; local c = tonumber(redis.call('GET', k) or '0')
30
+ if v > c then redis.call('SET', k, v, 'PX', mttl) else redis.call('PEXPIRE', k, mttl) end
31
+ end
32
+ local out = { mode == 'delete' and v - 1 or v }
33
+ if mode ~= 'publish' and n > 0 then
34
+ local idx = {}; for i = 1, n do idx[i] = KEYS[1 + i] end
35
+ for _, m in ipairs(redis.call('SUNION', unpack(idx))) do out[#out + 1] = m end
36
+ redis.call('DEL', unpack(idx))
37
+ end
38
+ return out`
39
+
40
+ const sha1 = (s: string) => new Bun.CryptoHasher('sha1').update(s).digest('hex')
41
+
42
+ export interface Script {
43
+ readonly source: string
44
+ readonly sha: string
45
+ }
46
+
47
+ export const SET_SCRIPT: Script = { source: SET_LUA, sha: sha1(SET_LUA) }
48
+ export const INVAL_SCRIPT: Script = { source: INVAL_LUA, sha: sha1(INVAL_LUA) }
49
+
50
+ /** EVALSHA; a NOSCRIPT reply (restart, failover, SCRIPT FLUSH) → EVAL, which also caches it. */
51
+ export async function evalsha(c: RedisClient, s: Script, keys: string[], argv: (string | number)[]): Promise<unknown> {
52
+ const tail = [String(keys.length), ...keys, ...argv.map(String)]
53
+ try {
54
+ return await c.send('EVALSHA', [s.sha, ...tail])
55
+ } catch (e) {
56
+ if (!/NOSCRIPT/.test(e instanceof Error ? e.message : String(e))) throw e
57
+ return await c.send('EVAL', [s.source, ...tail])
58
+ }
59
+ }
package/src/options.ts ADDED
@@ -0,0 +1,82 @@
1
+ // `[cache.redis]` (cache-redis R10, R11): validated at worker boot; the same key list is checked at
2
+ // `brust build` by `parseCache`. Errors name the key and never echo the url (it may hold a password).
3
+ import { readFileSync } from 'node:fs'
4
+
5
+ export const OPTION_KEYS = ['url', 'tls', 'connect_timeout_ms', 'max_body_bytes', 'marker_ttl_ms', 'unlink_batch', 'reconnect_min_ms', 'reconnect_max_ms'] as const
6
+
7
+ export interface RedisCacheOptions {
8
+ url: string
9
+ tls?: boolean | { reject_unauthorized?: boolean; server_name?: string; ca_file?: string }
10
+ connect_timeout_ms?: number
11
+ max_body_bytes?: number
12
+ marker_ttl_ms?: number
13
+ unlink_batch?: number
14
+ reconnect_min_ms?: number
15
+ reconnect_max_ms?: number
16
+ }
17
+
18
+ export interface ResolvedOptions {
19
+ url: string
20
+ tls: boolean | Bun.TLSOptions
21
+ connectTimeoutMs: number
22
+ maxBodyBytes: number
23
+ markerTtlMs: number
24
+ unlinkBatch: number
25
+ reconnectMinMs: number
26
+ reconnectMaxMs: number
27
+ }
28
+
29
+ const bad = (m: string): never => {
30
+ throw new TypeError(`cache-redis: ${m}`)
31
+ }
32
+
33
+ function int(o: Record<string, unknown>, k: string, min: number, max: number, dflt: number): number {
34
+ const v = o[k]
35
+ if (v === undefined) return dflt
36
+ if (typeof v !== 'number' || !Number.isInteger(v) || v < min || v > max) return bad(`${k} must be an integer in ${min}..${max} (got ${JSON.stringify(v)})`)
37
+ return v
38
+ }
39
+
40
+ function tlsOf(v: unknown): boolean | Bun.TLSOptions {
41
+ if (v === undefined || typeof v === 'boolean') return v ?? false
42
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) return bad('tls must be true, false or a table')
43
+ const t = v as Record<string, unknown>
44
+ for (const k of Object.keys(t)) if (k !== 'reject_unauthorized' && k !== 'server_name' && k !== 'ca_file') bad(`unknown option "tls.${k}"`)
45
+ const out: Bun.TLSOptions = {}
46
+ if (t.reject_unauthorized !== undefined) {
47
+ if (typeof t.reject_unauthorized !== 'boolean') bad('tls.reject_unauthorized must be a boolean')
48
+ out.rejectUnauthorized = t.reject_unauthorized as boolean
49
+ }
50
+ if (t.server_name !== undefined) {
51
+ if (typeof t.server_name !== 'string' || t.server_name === '') bad('tls.server_name must be a non-empty string')
52
+ out.serverName = t.server_name as string
53
+ }
54
+ if (t.ca_file !== undefined) {
55
+ if (typeof t.ca_file !== 'string' || t.ca_file === '') bad('tls.ca_file must be a non-empty path')
56
+ try {
57
+ out.ca = readFileSync(t.ca_file as string, 'utf8')
58
+ } catch {
59
+ bad('tls.ca_file cannot be read')
60
+ }
61
+ }
62
+ return out
63
+ }
64
+
65
+ export function parseOptions(raw: Record<string, unknown>): ResolvedOptions {
66
+ for (const k of Object.keys(raw)) if (!(OPTION_KEYS as readonly string[]).includes(k)) bad(`unknown option "${k}"`)
67
+ const url = raw.url
68
+ if (typeof url !== 'string' || url.trim() === '') bad('url is required (set it as "env:NAME" in [cache.redis])')
69
+ const reconnectMinMs = int(raw, 'reconnect_min_ms', 10, 60_000, 100)
70
+ const reconnectMaxMs = int(raw, 'reconnect_max_ms', 10, 600_000, Math.max(5000, reconnectMinMs))
71
+ if (reconnectMaxMs < reconnectMinMs) bad('reconnect_max_ms must be >= reconnect_min_ms')
72
+ return {
73
+ url: (url as string).trim(),
74
+ tls: tlsOf(raw.tls),
75
+ connectTimeoutMs: int(raw, 'connect_timeout_ms', 100, 60_000, 2000),
76
+ maxBodyBytes: int(raw, 'max_body_bytes', 1, 67_108_864, 1_048_576),
77
+ markerTtlMs: int(raw, 'marker_ttl_ms', 60_000, 604_800_000, 86_400_000),
78
+ unlinkBatch: int(raw, 'unlink_batch', 1, 10_000, 500),
79
+ reconnectMinMs,
80
+ reconnectMaxMs,
81
+ }
82
+ }
package/src/pairing.ts ADDED
@@ -0,0 +1,75 @@
1
+ // Pairs the two halves of one invalidation (cache-redis R4). lane A issues `store.invalidate` and
2
+ // `broadcast.publish` for the same args; when both arrive in the same tick ONE INVAL_LUA run does
3
+ // INCR + markers + SUNION/DEL atomically (mode `both`). Otherwise each half runs alone: `delete`
4
+ // (markers at seq+1, seq untouched, so it never jumps without an event) or `publish` (INCR + markers).
5
+ import type { RedisClient } from 'bun'
6
+ import type { InvalidateArgs } from '@brust/core/cache-adapter'
7
+ import { canonical, hasStoreWork, type Keys } from './keys'
8
+ import { evalsha, INVAL_SCRIPT } from './lua'
9
+
10
+ export type Side = 'invalidate' | 'publish'
11
+ export interface InvalResult {
12
+ seq: number
13
+ members: string[]
14
+ }
15
+
16
+ interface Entry {
17
+ sides: Set<Side>
18
+ promise: Promise<InvalResult>
19
+ }
20
+
21
+ export class Pairing {
22
+ private readonly pending = new Map<string, Entry[]>()
23
+
24
+ constructor(
25
+ private readonly c: () => RedisClient,
26
+ private readonly keys: () => Keys,
27
+ private readonly markerTtlMs: number,
28
+ ) {}
29
+
30
+ run(args: InvalidateArgs, side: Side): Promise<InvalResult> {
31
+ if (side === 'invalidate' && !hasStoreWork(args)) return Promise.resolve({ seq: 0, members: [] })
32
+ const id = canonical(args)
33
+ const list = this.pending.get(id) ?? []
34
+ // Created synchronously, before any await: the partner finds it in the same tick.
35
+ const open = list.find((e) => !e.sides.has(side))
36
+ if (open) {
37
+ open.sides.add(side)
38
+ return open.promise
39
+ }
40
+ const entry: Entry = { sides: new Set([side]), promise: undefined as never }
41
+ entry.promise = new Promise<InvalResult>((resolve, reject) => {
42
+ queueMicrotask(() => {
43
+ const l = this.pending.get(id)
44
+ if (l) {
45
+ l.splice(l.indexOf(entry), 1)
46
+ if (!l.length) this.pending.delete(id)
47
+ }
48
+ const mode = entry.sides.size === 2 ? 'both' : side === 'invalidate' ? 'delete' : 'publish'
49
+ this.exec(args, mode).then(resolve, reject)
50
+ })
51
+ })
52
+ list.push(entry)
53
+ this.pending.set(id, list)
54
+ return entry.promise
55
+ }
56
+
57
+ private async exec(args: InvalidateArgs, mode: 'both' | 'delete' | 'publish'): Promise<InvalResult> {
58
+ const K = this.keys()
59
+ const method = args.method
60
+ const tags = [...new Set(args.tags ?? [])]
61
+ const idx: string[] = []
62
+ const marks: string[] = []
63
+ // A publish-only half marks but deletes nothing; a key-only publish has n = 0.
64
+ for (const t of tags) {
65
+ idx.push(K.tag(t))
66
+ marks.push(K.markTag(t))
67
+ }
68
+ if (args.path !== undefined) {
69
+ idx.push(K.path(method, args.path))
70
+ marks.push(K.markPath(method, args.path))
71
+ }
72
+ const r = (await evalsha(this.c(), INVAL_SCRIPT, [K.seq, ...idx, ...marks], [mode, idx.length, this.markerTtlMs])) as (number | string)[]
73
+ return { seq: Number(r[0]), members: r.slice(1).map(String) }
74
+ }
75
+ }
package/src/redact.ts ADDED
@@ -0,0 +1,10 @@
1
+ /** `host:port` of a Redis URL, never the userinfo, path or query (a URL may carry a password). */
2
+ export function redact(url: string): string {
3
+ try {
4
+ const u = new URL(url)
5
+ if (!u.hostname) return '<invalid url>'
6
+ return u.port ? `${u.hostname}:${u.port}` : u.hostname
7
+ } catch {
8
+ return '<invalid url>'
9
+ }
10
+ }
@@ -0,0 +1,65 @@
1
+ // The push half (cache-redis R7, R8): a dedicated subscriber connection with the adapter-owned
2
+ // reconnect loop, and a gap check on every (re)subscribe so an event lost during an outage clears
3
+ // tier 1 (spec §4.2.5) instead of leaving a stale page for up to its ttl.
4
+ import type { InvalidationEvent } from '@brust/core/cache-adapter'
5
+ import { Link } from './conn'
6
+ import type { Keys } from './keys'
7
+ import type { ResolvedOptions } from './options'
8
+
9
+ export interface SubscriberState {
10
+ link: Link | null
11
+ subscribed: boolean
12
+ last: number | null
13
+ }
14
+
15
+ export async function startSubscriber(
16
+ o: ResolvedOptions,
17
+ keys: Keys,
18
+ readSeq: () => Promise<number>,
19
+ state: SubscriberState,
20
+ on: (e: InvalidationEvent) => void,
21
+ onGap: () => void,
22
+ ): Promise<() => Promise<void>> {
23
+ const listener = (message: string) => {
24
+ let e: InvalidationEvent
25
+ try {
26
+ const j = JSON.parse(message) as Partial<InvalidationEvent>
27
+ if (!Number.isSafeInteger(j.seq) || typeof j.origin !== 'string' || typeof j.args !== 'object' || j.args === null) throw new Error('shape')
28
+ e = j as InvalidationEvent
29
+ } catch {
30
+ console.warn('[brust] cache-redis: dropped a malformed invalidation message')
31
+ return
32
+ }
33
+ state.last = Math.max(state.last ?? 0, e.seq)
34
+ try {
35
+ on(e)
36
+ } catch (err) {
37
+ console.error(`[brust] cache-redis: invalidation handler failed: ${err instanceof Error ? err.message : String(err)}`)
38
+ }
39
+ }
40
+ const link = new Link(o, 'subscriber', async (cl) => {
41
+ state.subscribed = false
42
+ await cl.subscribe(keys.channel, listener)
43
+ // The command link may be down too: a throw here drops this attempt and retries after the backoff.
44
+ const seq = await readSeq()
45
+ const first = state.last === null
46
+ if (first) state.last = seq
47
+ else if (seq > state.last!) {
48
+ state.last = seq
49
+ try {
50
+ onGap()
51
+ } catch (err) {
52
+ console.error(`[brust] cache-redis: gap handler failed: ${err instanceof Error ? err.message : String(err)}`)
53
+ }
54
+ }
55
+ state.subscribed = true
56
+ }, () => {
57
+ state.subscribed = false
58
+ })
59
+ state.link = link
60
+ await link.start()
61
+ return async () => {
62
+ link.close()
63
+ state.subscribed = false
64
+ }
65
+ }