/** * Redis adapter — Bun.RedisClient per PHASE 0 findings. * * Query shape: command string `"GET foo"` or JSON command array * `["HSET", "k", "f", "v"]`. * * "Schema" introspection lists keys via SCAN (capped at 1000) and * exposes type/TTL on object details. Structure operations are * key-level (rename, expire, delete). */ import { RedisClient } from 'bun'; import type { DbClientConnection, DbClientHealth, DbClientObjectDetails, DbClientOverview, DbClientQueryResult, DbClientSchemaNode, DbClientSchemaNodeType } from '$shared/types/db-client'; import type { DbClientDriverAdapter } from './types'; import { debug } from '$shared/utils/logger'; import { buildConnectionUrl } from '../connection-url'; const SCAN_CAP = 1000; export class RedisAdapter implements DbClientDriverAdapter { readonly kind = 'redis' as const; private client: RedisClient | null = null; private alive = false; async connect(conn: DbClientConnection, tunnelPort?: number): Promise { const url = buildConnectionUrl(conn, { tunnelPort }); this.client = new RedisClient(url); await this.client.connect(); this.alive = true; } async close(): Promise { this.alive = false; if (this.client) { try { this.client.close(); } catch (err) { debug.warn('db-client', 'Redis close error:', err); } this.client = null; } } isAlive(): boolean { return this.alive && this.client !== null; } async health(): Promise { if (!this.client) return notConnected(); const start = performance.now(); try { await this.client.send('PING', []); let serverVersion: string | null = null; try { const info = (await this.client.send('INFO', ['server'])) as string; const match = /redis_version:([^\r\n]+)/.exec(info ?? ''); serverVersion = match?.[1]?.trim() ?? null; } catch { serverVersion = null; } return { ok: true, latencyMs: Math.round(performance.now() - start), serverVersion, sshOk: null, error: null }; } catch (err) { return { ok: false, latencyMs: null, serverVersion: null, sshOk: null, error: err instanceof Error ? err.message : String(err) }; } } private requireClient(): RedisClient { if (!this.client) throw new Error('Redis not connected'); return this.client; } // ── Overview ────────────────────────────────────────────────────────── async overview(): Promise { const client = this.requireClient(); const start = performance.now(); const info = (await client.send('INFO', [])) as string; const latencyMs = Math.round(performance.now() - start); const ver = /redis_version:([^\r\n]+)/.exec(info ?? '')?.[1]?.trim() ?? null; const usedMemory = /used_memory:([0-9]+)/.exec(info ?? '')?.[1]; const dbsize = (await client.send('DBSIZE', [])) as number | string; const keys = Number(dbsize); return { serverVersion: ver, latencyMs, sizeBytes: usedMemory ? Number(usedMemory) : null, tableCount: Number.isFinite(keys) ? keys : null, viewCount: null, extra: [{ label: 'Keys', value: Number.isFinite(keys) ? String(keys) : '—' }] }; } // ── Schema (key listing) ────────────────────────────────────────────── async listDatabases(): Promise { // Redis has 16 logical DBs by default — surface 0..15 as virtual entries. return Array.from({ length: 16 }, (_, i) => ({ name: String(i), type: 'database' as const })); } async listObjects(): Promise { const client = this.requireClient(); const keys: string[] = []; let cursor = '0'; do { const reply = (await client.send('SCAN', [cursor, 'COUNT', '200'])) as [string, string[]]; cursor = reply[0]; for (const k of reply[1]) { keys.push(k); if (keys.length >= SCAN_CAP) break; } } while (cursor !== '0' && keys.length < SCAN_CAP); keys.sort(); return keys.map((name) => ({ name, type: 'key' as const })); } async getObjectDetails(name: string, _type: DbClientSchemaNodeType): Promise { const client = this.requireClient(); const [type, ttl] = await Promise.all([ client.send('TYPE', [name]) as Promise, client.send('TTL', [name]) as Promise ]); return { name, type: 'key', redisValueType: type, redisTtlSeconds: ttl < 0 ? null : ttl }; } // ── Query execution ────────────────────────────────────────────────── async executeRead(q: string): Promise { return this.runCommand(q); } async executeWrite(q: string): Promise { return this.runCommand(q); } private async runCommand(q: string): Promise { const { cmd, args } = parseRedisCommand(q); const client = this.requireClient(); const start = performance.now(); const reply = await client.send(cmd, args); const durationMs = Math.round(performance.now() - start); const rows = formatReply(reply); const columns = rows.length > 0 ? Object.keys(rows[0]).map((name) => ({ name, type: null as string | null })) : []; return { columns, rows, rowCount: rows.length, affectedRows: null, durationMs, driverMeta: {} }; } // ── "Structure" — key-level operations ─────────────────────────────── async dropTable(name: string): Promise { await this.requireClient().send('DEL', [name]); return `DEL ${name}`; } async flushDatabase(): Promise { await this.requireClient().send('FLUSHDB', []); return 'FLUSHDB'; } async renameTable(name: string, newName: string): Promise { await this.requireClient().send('RENAME', [name, newName]); return `RENAME ${name} ${newName}`; } async deleteRows(table: string, _pks: Record[]): Promise { await this.requireClient().send('DEL', [table]); return { columns: [], rows: [], rowCount: 0, affectedRows: 1, durationMs: 0, driverMeta: {} }; } } function parseRedisCommand(q: string): { cmd: string; args: string[] } { const trimmed = q.trim(); if (trimmed.startsWith('[')) { try { const parsed = JSON.parse(trimmed) as unknown; if (Array.isArray(parsed) && parsed.length > 0) { return { cmd: String(parsed[0]).toUpperCase(), args: parsed.slice(1).map((v) => String(v)) }; } } catch { // fall through to plain parsing } } // Naive whitespace tokenizer — quoted args supported. const tokens = tokenize(trimmed); if (tokens.length === 0) throw new Error('Empty Redis command'); return { cmd: tokens[0].toUpperCase(), args: tokens.slice(1) }; } function tokenize(s: string): string[] { const out: string[] = []; let buf = ''; let quote: '"' | "'" | null = null; for (let i = 0; i < s.length; i++) { const ch = s[i]; if (quote) { if (ch === quote) { quote = null; } else if (ch === '\\' && i + 1 < s.length) { buf += s[++i]; } else { buf += ch; } continue; } if (ch === '"' || ch === "'") { quote = ch; continue; } if (/\s/.test(ch)) { if (buf) { out.push(buf); buf = ''; } continue; } buf += ch; } if (buf) out.push(buf); return out; } function formatReply(reply: unknown): Array> { if (reply === null || reply === undefined) { return [{ value: null }]; } if (typeof reply === 'string' || typeof reply === 'number' || typeof reply === 'boolean') { return [{ value: reply }]; } if (Array.isArray(reply)) { return reply.map((v, i) => ({ index: i, value: v })); } if (typeof reply === 'object') { // HGETALL etc. return null-prototype objects per Phase 0. return [{ ...(reply as Record) }]; } return [{ value: String(reply) }]; } function notConnected(): DbClientHealth { return { ok: false, latencyMs: null, serverVersion: null, sshOk: null, error: 'Not connected' }; }