import { sha1Hex } from "./hash.js"; import { RedisKVMarker } from "./keys.js"; import type { createPinboard } from "./server.js"; import { allScripts } from "./scripts/index.js"; import type { Script } from "./scripts/twins.js"; type RedisKV = createPinboard.RedisKV; type SetOptions = createPinboard.SetOptions; interface StringEntry { kind: "string"; value: string; expiresAt: number | null; } interface HashEntry { kind: "hash"; fields: Map; expiresAt: number | null; } interface ListEntry { kind: "list"; values: string[]; expiresAt: number | null; } type Entry = StringEntry | HashEntry | ListEntry; const scriptsBySha = new Map( allScripts.map((def) => [sha1Hex(def.lua), def]), ); /** In-memory RedisKV; scripts run as their synchronous (atomic) JS twins. */ export class MemoryRedis implements RedisKV { readonly [RedisKVMarker] = true; private readonly entries = new Map(); private readonly now: () => number; constructor(opts?: { now?: () => number }) { this.now = opts?.now ?? (() => Date.now()); } private live(key: string): Entry | undefined { const entry = this.entries.get(key); if (entry === undefined) return undefined; if (entry.expiresAt !== null && entry.expiresAt <= this.now()) { this.entries.delete(key); return undefined; } return entry; } async get(key: string): Promise { return this.kv.get(key); } async set( key: string, value: string, opts?: SetOptions, ): Promise<"OK" | null> { const existing = this.live(key); if (opts?.nx && existing !== undefined) return null; const expiresAt = opts?.pxMs !== undefined ? this.now() + opts.pxMs : null; this.entries.set(key, { kind: "string", value, expiresAt }); return "OK"; } async del(key: string): Promise { return this.kv.del(key); } async pexpire(key: string, ms: number): Promise { const entry = this.live(key); if (entry === undefined) return 0; entry.expiresAt = this.now() + ms; return 1; } private hash(key: string): Map | undefined { const entry = this.live(key); return entry?.kind === "hash" ? entry.fields : undefined; } private hashEntry(key: string): Map { const existing = this.hash(key); if (existing !== undefined) return existing; const fields = new Map(); this.entries.set(key, { kind: "hash", fields, expiresAt: null }); return fields; } async hget(key: string, field: string): Promise { return this.kv.hget(key, field); } async hset(key: string, field: string, value: string): Promise { const fields = this.hashEntry(key); const added = fields.has(field) ? 0 : 1; fields.set(field, value); return added; } async hsetnx(key: string, field: string, value: string): Promise { const fields = this.hashEntry(key); if (fields.has(field)) return 0; fields.set(field, value); return 1; } async hdel(key: string, field: string): Promise { const fields = this.hash(key); if (fields === undefined) return 0; const deleted = fields.delete(field) ? 1 : 0; if (fields.size === 0) this.entries.delete(key); return deleted; } async hgetall(key: string): Promise> { return this.kv.hgetall(key); } async hscan( key: string, cursor: string, count: number, ): Promise<{ cursor: string; entries: [string, string][] }> { return this.kv.hscan(key, cursor, count); } async rpush(key: string, value: string): Promise { const entry = this.live(key); if (entry?.kind === "list") { entry.values.push(value); return entry.values.length; } this.entries.set(key, { kind: "list", values: [value], expiresAt: null }); return 1; } async lrange(key: string, start: number, stop: number): Promise { const entry = this.live(key); if (entry?.kind !== "list") return []; const len = entry.values.length; const from = Math.max(start < 0 ? len + start : start, 0); const to = Math.min(stop < 0 ? len + stop : stop, len - 1); return from > to ? [] : entry.values.slice(from, to + 1); } async lrem(key: string, count: number, value: string): Promise { const entry = this.live(key); if (entry?.kind !== "list") return 0; const max = count === 0 ? Infinity : Math.abs(count); const source = count < 0 ? entry.values.slice().reverse() : entry.values; let removed = 0; const kept: string[] = []; for (const item of source) { if (item === value && removed < max) removed += 1; else kept.push(item); } entry.values = count < 0 ? kept.reverse() : kept; if (entry.values.length === 0) this.entries.delete(key); return removed; } async scriptLoad(script: string): Promise { const sha = sha1Hex(script); if (!scriptsBySha.has(sha)) { throw new Error("MemoryRedis: unknown script"); } return sha; } async evalsha(sha: string, keys: string[], argv: string[]): Promise { const def = scriptsBySha.get(sha); if (def === undefined) { throw new Error("NOSCRIPT No matching script"); } return def.js(this.kv, keys, argv); } async quit(): Promise { this.entries.clear(); } private readonly kv: Script.SyncKV = { get: (key) => { const entry = this.live(key); return entry?.kind === "string" ? entry.value : null; }, set: (key, value, pxMs) => { this.entries.set(key, { kind: "string", value, expiresAt: pxMs !== undefined ? this.now() + pxMs : null, }); }, del: (key) => this.live(key) !== undefined && this.entries.delete(key) ? 1 : 0, pexpire: (key, ms) => { const entry = this.live(key); if (entry !== undefined) entry.expiresAt = this.now() + ms; }, hget: (key, field) => this.hash(key)?.get(field) ?? null, hset: (key, field, value) => { this.hashEntry(key).set(field, value); }, hdel: (key, field) => { const fields = this.hash(key); if (fields === undefined) return; fields.delete(field); if (fields.size === 0) this.entries.delete(key); }, hgetall: (key) => Object.fromEntries(this.hash(key) ?? []), // Cursor is the last-seen field name (sorted order), so fields present // throughout a full scan are returned even when the hash mutates mid-scan. hscan: (key, cursor, count) => { if (cursor !== "0" && !cursor.startsWith(">")) { throw new Error(`invalid hscan cursor: ${cursor}`); } const after = cursor === "0" ? null : cursor.slice(1); const remaining = [...(this.hash(key) ?? [])] .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) .filter(([field]) => after === null || field > after); const entries = remaining.slice(0, count); return { cursor: entries.length < remaining.length ? `>${entries[entries.length - 1]![0]}` : "0", entries, }; }, }; }