@iskra-bun/kv-kit 0.2.0 → 0.3.0

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/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/adapters/memory.ts","../src/adapters/redis.ts","../src/manager.ts"],"sourcesContent":["import type { KVAdapter } from '../types';\n\nexport class MemoryAdapter implements KVAdapter {\n id = 'memory';\n private store = new Map<string, unknown>();\n private timers = new Map<string, ReturnType<typeof setTimeout>>();\n\n connect() {\n // No-op\n }\n\n disconnect() {\n for (const timer of this.timers.values()) {\n clearTimeout(timer);\n }\n this.timers.clear();\n this.store.clear();\n }\n\n async get<T = unknown>(key: string): Promise<T | undefined> {\n return this.store.get(key) as T | undefined;\n }\n\n async set<T = unknown>(key: string, value: T, ttl?: number): Promise<void> {\n // Note: storing `null`/`undefined` is undefined behavior across adapters.\n // This in-memory adapter stores the value verbatim (so `get` returns it\n // as-is), whereas the RedisAdapter normalizes both to \"absent\" because\n // they have no faithful JSON round-trip. Callers should not depend on\n // either form being preserved.\n //\n // Clear any existing expiry timer for this key before setting a new one\n const existing = this.timers.get(key);\n if (existing !== undefined) {\n clearTimeout(existing);\n this.timers.delete(key);\n }\n\n this.store.set(key, value);\n\n if (ttl) {\n const timer = setTimeout(() => {\n this.store.delete(key);\n this.timers.delete(key);\n }, ttl * 1000);\n\n // Avoid keeping the process alive just for expiry timers\n timer.unref?.();\n\n this.timers.set(key, timer);\n }\n }\n\n async del(key: string): Promise<void> {\n const timer = this.timers.get(key);\n if (timer !== undefined) {\n clearTimeout(timer);\n this.timers.delete(key);\n }\n this.store.delete(key);\n }\n\n async has(key: string): Promise<boolean> {\n return this.store.has(key);\n }\n}\n","import type { KVAdapter } from '../types';\nimport Redis from 'ioredis';\nimport type { RedisOptions } from 'ioredis';\n\n/**\n * Single consistent codec shared by every write/read path.\n *\n * `undefined` is guarded explicitly: there is no JSON representation for it,\n * so it is stored as the JSON `null` literal and decoded back to `undefined`\n * on read. Every other value is `JSON.stringify`'d on write and `JSON.parse`'d\n * on read, so the string \"123\" round-trips as the string \"123\" (not the number\n * 123) and \"{}\" round-trips as the string \"{}\" (not an empty object).\n */\nfunction encode<T>(value: T): string {\n if (value === undefined) return 'null';\n return JSON.stringify(value);\n}\n\nfunction decode<T>(raw: string): T | undefined {\n try {\n const parsed = JSON.parse(raw) as T | null;\n return parsed === null ? undefined : (parsed as T);\n } catch {\n // Tolerate values written outside the adapter that are not valid JSON.\n return raw as unknown as T;\n }\n}\n\nexport class RedisAdapter implements KVAdapter {\n id = 'redis';\n private client: Redis | null = null;\n private readonly options: RedisOptions | string;\n\n constructor(options: RedisOptions | string) {\n this.options = options;\n }\n\n connect() {\n this.client = new Redis(this.options as RedisOptions);\n }\n\n disconnect() {\n this.client?.disconnect();\n }\n\n async get<T = unknown>(key: string): Promise<T | undefined> {\n const val = await this.client?.get(key);\n if (val === null || val === undefined) return undefined;\n return decode<T>(val);\n }\n\n async set<T = unknown>(key: string, value: T, ttl?: number): Promise<void> {\n const val = encode(value);\n if (ttl) {\n await this.client?.set(key, val, 'EX', ttl);\n } else {\n await this.client?.set(key, val);\n }\n }\n\n async del(key: string) {\n await this.client?.del(key);\n }\n\n async has(key: string) {\n const exists = await this.client?.exists(key);\n return exists === 1;\n }\n\n // Native batch operations. A single MGET / pipelined MSET / variadic DEL\n // replaces the manager's per-key fan-out (avoids the N+1 round-trips).\n\n async mget<T = unknown>(keys: string[]): Promise<(T | undefined)[]> {\n if (keys.length === 0) return [];\n const raws = (await this.client?.mget(...keys)) ?? [];\n return keys.map((_, i) => {\n const raw = raws[i];\n return raw === null || raw === undefined ? undefined : decode<T>(raw);\n });\n }\n\n async mset<T = unknown>(\n entries: Array<[string, T]>,\n ttl?: number\n ): Promise<void> {\n if (entries.length === 0) return;\n const pipeline = this.client?.pipeline();\n if (!pipeline) return;\n\n for (const [key, value] of entries) {\n const val = encode(value);\n if (ttl) {\n pipeline.set(key, val, 'EX', ttl);\n } else {\n pipeline.set(key, val);\n }\n }\n\n await pipeline.exec();\n }\n\n async mdel(keys: string[]): Promise<void> {\n if (keys.length === 0) return;\n await this.client?.del(...keys);\n }\n}\n","import type { App, Driver } from '@iskra-bun/core';\nimport type { KVAdapter } from './types';\nimport { MemoryAdapter } from './adapters/memory';\nimport { RedisAdapter } from './adapters/redis';\n\nexport interface KVManagerOptions {\n /**\n * Optional namespace prepended to every key as `\"<namespace>:<key>\"`.\n * Defaults to `\"\"` (no prefix) to preserve existing behavior.\n */\n namespace?: string;\n}\n\nexport class KVManager implements Driver, KVAdapter {\n name = 'KVManager';\n id = 'manager';\n private app: App | null = null;\n private adapter: KVAdapter;\n private readonly prefix: string;\n\n constructor(options: KVManagerOptions = {}) {\n // Default to memory until configured\n this.adapter = new MemoryAdapter();\n this.prefix = options.namespace ? `${options.namespace}:` : '';\n }\n\n init(app: App) {\n this.app = app;\n const config = app.config.kv;\n\n if (config?.driver === 'redis') {\n app.logger.info('Initializing KV with Redis');\n this.adapter = new RedisAdapter(config.connection);\n } else {\n app.logger.info('Initializing KV with Memory');\n this.adapter = new MemoryAdapter();\n }\n }\n\n async connect() {\n await this.adapter.connect();\n this.app?.logger.info('KV Store connected');\n }\n\n async disconnect() {\n await this.adapter.disconnect();\n }\n\n // Driver Interface\n async start() {\n await this.connect();\n }\n\n async stop() {\n await this.disconnect();\n }\n\n private prefixed(key: string): string {\n return `${this.prefix}${key}`;\n }\n\n // Proxy methods (namespace-aware)\n get<T = unknown>(key: string): Promise<T | undefined> {\n return this.adapter.get<T>(this.prefixed(key));\n }\n\n set<T = unknown>(key: string, value: T, ttl?: number): Promise<void> {\n return this.adapter.set<T>(this.prefixed(key), value, ttl);\n }\n\n del(key: string): Promise<void> {\n return this.adapter.del(this.prefixed(key));\n }\n\n has(key: string): Promise<boolean> {\n return this.adapter.has(this.prefixed(key));\n }\n\n // Batch operations. When the underlying adapter exposes a native batch\n // method, the manager delegates to it (a single round-trip) after applying\n // the namespace prefix; otherwise it falls back to a per-key loop. This\n // keeps the optional adapter methods out of the hot path for adapters that\n // do not implement them while avoiding the N+1 fan-out for those that do.\n\n async mget<T = unknown>(keys: string[]): Promise<(T | undefined)[]> {\n const prefixed = keys.map(k => this.prefixed(k));\n\n if (this.adapter.mget) {\n return this.adapter.mget<T>(prefixed);\n }\n\n return Promise.all(prefixed.map(k => this.adapter.get<T>(k)));\n }\n\n async mset<T = unknown>(\n entries: Array<[string, T]> | Record<string, T>,\n ttl?: number\n ): Promise<void> {\n const pairs: Array<[string, T]> = Array.isArray(entries)\n ? entries\n : (Object.entries(entries) as Array<[string, T]>);\n\n const prefixed: Array<[string, T]> = pairs.map(\n ([k, v]) => [this.prefixed(k), v] as [string, T]\n );\n\n if (this.adapter.mset) {\n await this.adapter.mset<T>(prefixed, ttl);\n return;\n }\n\n await Promise.all(prefixed.map(([k, v]) => this.adapter.set<T>(k, v, ttl)));\n }\n\n async mdel(keys: string[]): Promise<void> {\n const prefixed = keys.map(k => this.prefixed(k));\n\n if (this.adapter.mdel) {\n await this.adapter.mdel(prefixed);\n return;\n }\n\n await Promise.all(prefixed.map(k => this.adapter.del(k)));\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/manager.ts","../src/ttl.ts","../src/adapters/memory.ts","../src/adapters/redis.ts"],"sourcesContent":["import { isProductionEnv, type App, type Driver } from '@iskra-bun/core';\nimport type { Redis } from 'ioredis';\nimport type { KVAdapter } from './types';\nimport { MemoryAdapter } from './adapters/memory';\nimport { RedisAdapter } from './adapters/redis';\n\nexport interface KVManagerOptions {\n /**\n * Optional namespace prepended to every key as `\"<namespace>:<key>\"`.\n * Defaults to `\"\"` (no prefix) to preserve existing behavior.\n */\n namespace?: string;\n /**\n * Lets `clear()` without a `namespace` empty the whole Redis database\n * (FLUSHDB). Only for a database no other app or service writes to.\n */\n flushDb?: boolean;\n}\n\nexport class KVManager implements Driver, KVAdapter {\n name = 'KVManager';\n id = 'manager';\n private app: App | null = null;\n private adapter: KVAdapter;\n private readonly prefix: string;\n private readonly flushDb: boolean;\n\n constructor(options: KVManagerOptions = {}) {\n // The store is chosen by the App's config (`kv.driver`), not here: an\n // `adapter: 'redis'` copied from an old README was ignored, and the app\n // silently ran on per-process memory.\n const misplaced = ['adapter', 'driver', 'connection'].filter((key) => key in options);\n if (misplaced.length > 0) {\n throw new Error(\n `KVManager does not take ${misplaced.map((k) => `\"${k}\"`).join(', ')}: choose the store in the App config, ` +\n \"e.g. new App({ name, kv: { driver: 'redis', connection: process.env.REDIS_URL } })\",\n );\n }\n // Default to memory until configured\n this.adapter = new MemoryAdapter();\n this.prefix = options.namespace ? `${options.namespace}:` : '';\n this.flushDb = options.flushDb ?? false;\n }\n\n init(app: App) {\n this.app = app;\n // Like db-kit's 'db': services look the store up as app.context.get('kv').\n app.context.set('kv', this);\n const config = app.config.kv;\n\n if (config?.driver === 'redis') {\n app.logger.info('Initializing KV with Redis');\n this.adapter = new RedisAdapter(config.connection ?? {}, {\n onError: (err) => app.logger.warn({ err }, 'KV Redis connection error'),\n flushDb: this.flushDb,\n });\n } else if (!config?.driver || config.driver === 'memory') {\n if (!config?.driver && isProductionEnv()) {\n app.logger.warn(\n 'KV has no driver configured: using the in-memory store, which each process keeps on its own ' +\n \"and loses on restart. Set kv: { driver: 'redis', connection } in the App config, or \" +\n \"kv: { driver: 'memory' } to keep it.\",\n );\n }\n app.logger.info('Initializing KV with Memory');\n this.adapter = new MemoryAdapter();\n } else {\n // Unknown drivers (e.g. 'libsql') used to fall back to memory\n // silently, losing every value on restart.\n throw new Error(`Unsupported KV driver \"${config.driver}\" (supported: \"memory\", \"redis\")`);\n }\n }\n\n async connect() {\n await this.adapter.connect();\n this.app?.logger.info('KV Store connected');\n }\n\n async disconnect() {\n await this.adapter.disconnect();\n }\n\n // Driver Interface\n async start() {\n await this.connect();\n }\n\n async stop() {\n await this.disconnect();\n }\n\n /**\n * The underlying ioredis client with the \"redis\" driver, once started: for\n * commands the KV API does not cover (sets, sorted sets, pipelines). It\n * bypasses `namespace` and the JSON codec. `undefined` with the memory\n * driver or before start().\n */\n get client(): Redis | undefined {\n return this.adapter instanceof RedisAdapter ? (this.adapter.nativeClient ?? undefined) : undefined;\n }\n\n private prefixed(key: string): string {\n return `${this.prefix}${key}`;\n }\n\n // Proxy methods (namespace-aware)\n get<T = unknown>(key: string): Promise<T | undefined> {\n return this.adapter.get<T>(this.prefixed(key));\n }\n\n set<T = unknown>(key: string, value: T, ttl?: number): Promise<void> {\n return this.adapter.set<T>(this.prefixed(key), value, ttl);\n }\n\n del(key: string): Promise<void> {\n return this.adapter.del(this.prefixed(key));\n }\n\n has(key: string): Promise<boolean> {\n return this.adapter.has(this.prefixed(key));\n }\n\n // Batch operations. When the underlying adapter exposes a native batch\n // method, the manager delegates to it (a single round-trip) after applying\n // the namespace prefix; otherwise it falls back to a per-key loop. This\n // keeps the optional adapter methods out of the hot path for adapters that\n // do not implement them while avoiding the N+1 fan-out for those that do.\n\n async mget<T = unknown>(keys: string[]): Promise<(T | undefined)[]> {\n const prefixed = keys.map((k) => this.prefixed(k));\n\n if (this.adapter.mget) {\n return this.adapter.mget<T>(prefixed);\n }\n\n return Promise.all(prefixed.map((k) => this.adapter.get<T>(k)));\n }\n\n async mset<T = unknown>(entries: Array<[string, T]> | Record<string, T>, ttl?: number): Promise<void> {\n const pairs: Array<[string, T]> = Array.isArray(entries)\n ? entries\n : (Object.entries(entries) as Array<[string, T]>);\n\n const prefixed: Array<[string, T]> = pairs.map(([k, v]) => [this.prefixed(k), v] as [string, T]);\n\n if (this.adapter.mset) {\n await this.adapter.mset<T>(prefixed, ttl);\n return;\n }\n\n await Promise.all(prefixed.map(([k, v]) => this.adapter.set<T>(k, v, ttl)));\n }\n\n async mdel(keys: string[]): Promise<void> {\n const prefixed = keys.map((k) => this.prefixed(k));\n\n if (this.adapter.mdel) {\n await this.adapter.mdel(prefixed);\n return;\n }\n\n await Promise.all(prefixed.map((k) => this.adapter.del(k)));\n }\n\n /**\n * Deletes every key under `prefix` in this manager's namespace (the whole\n * namespace without one). With the redis driver and no namespace, only\n * `flushDb: true` lets it empty the database.\n */\n async clear(prefix = ''): Promise<void> {\n if (!this.adapter.clear) throw unsupported(this.adapter, 'clear');\n await this.adapter.clear(this.prefixed(prefix));\n }\n\n /** See {@link KVAdapter.sadd}: the member is kept as is, only `key` is namespaced. */\n async sadd(key: string, member: string, ttl?: number): Promise<void> {\n if (!this.adapter.sadd) throw unsupported(this.adapter, 'sadd');\n await this.adapter.sadd(this.prefixed(key), member, ttl);\n }\n\n async sdrain(key: string): Promise<string[]> {\n if (!this.adapter.sdrain) throw unsupported(this.adapter, 'sdrain');\n return this.adapter.sdrain(this.prefixed(key));\n }\n}\n\n/** Both built-in adapters have the optional methods; an injected one may not. */\nfunction unsupported(adapter: KVAdapter, method: string): Error {\n return new Error(`The \"${adapter.id}\" KV adapter does not support ${method}()`);\n}\n\n// `app.context.get('kv')` is the KVManager registered on the app.\ndeclare module '@iskra-bun/core' {\n interface AppContextRegistry {\n kv: KVManager;\n }\n}\n","/**\n * A TTL in seconds: `undefined`, `null` or `0` mean \"no expiry\". Anything else\n * must be a positive finite number: a negative TTL used to delete the key at\n * once with the memory adapter and fail with Redis.\n */\nexport function checkTtl(ttl: number | null | undefined): number | undefined {\n if (ttl === undefined || ttl === null || ttl === 0) return undefined;\n if (typeof ttl !== 'number' || !Number.isFinite(ttl) || ttl < 0) {\n throw new RangeError(`Invalid TTL ${String(ttl)}: expected a positive number of seconds`);\n }\n return ttl;\n}\n","import type { KVAdapter } from '../types';\nimport { checkTtl } from '../ttl';\n\n/** setTimeout's limit: a longer delay (a TTL over ~24.8 days) fires at once. */\nconst MAX_TIMEOUT_MS = 2 ** 31 - 1;\n\n/** An expiring set: each member's expiry (ms since the epoch, Infinity for none). */\ninterface ExpiringSet {\n members: Map<string, number>;\n expiresAt: number;\n /** Size at which expired members are next dropped. */\n pruneAt: number;\n}\n\nexport class MemoryAdapter implements KVAdapter {\n id = 'memory';\n private store = new Map<string, unknown>();\n private sets = new Map<string, ExpiringSet>();\n private timers = new Map<string, ReturnType<typeof setTimeout>>();\n\n connect() {\n // No-op\n }\n\n disconnect() {\n for (const timer of this.timers.values()) {\n clearTimeout(timer);\n }\n this.timers.clear();\n this.store.clear();\n this.sets.clear();\n }\n\n // Values are copied in and out, as Redis does: a stored object returned by\n // reference let one request's mutation show up in every other one.\n async get<T = unknown>(key: string): Promise<T | undefined> {\n return structuredClone(this.store.get(key)) as T | undefined;\n }\n\n async set<T = unknown>(key: string, value: T, ttl?: number): Promise<void> {\n const seconds = checkTtl(ttl);\n const copy = structuredClone(value);\n // Note: storing `null`/`undefined` is undefined behavior across adapters.\n // This in-memory adapter stores the value verbatim (so `get` returns it\n // as-is), whereas the RedisAdapter normalizes both to \"absent\" because\n // they have no faithful JSON round-trip. Callers should not depend on\n // either form being preserved.\n //\n // Clear any existing expiry timer for this key before setting a new one\n this.clearTimer(key);\n this.sets.delete(key);\n this.store.set(key, copy);\n\n if (seconds !== undefined) this.expireIn(key, seconds * 1000);\n }\n\n /** Arms the expiry timer, in steps when the delay exceeds setTimeout's limit. */\n private expireIn(key: string, ms: number) {\n const step = Math.min(ms, MAX_TIMEOUT_MS);\n const timer = setTimeout(() => {\n if (ms > step) {\n this.expireIn(key, ms - step);\n return;\n }\n this.store.delete(key);\n this.sets.delete(key);\n this.timers.delete(key);\n }, step);\n\n // Avoid keeping the process alive just for expiry timers\n timer.unref?.();\n\n this.timers.set(key, timer);\n }\n\n private clearTimer(key: string) {\n const timer = this.timers.get(key);\n if (timer !== undefined) {\n clearTimeout(timer);\n this.timers.delete(key);\n }\n }\n\n async del(key: string): Promise<void> {\n this.clearTimer(key);\n this.store.delete(key);\n this.sets.delete(key);\n }\n\n async has(key: string): Promise<boolean> {\n return this.store.has(key) || this.sets.has(key);\n }\n\n async clear(prefix = ''): Promise<void> {\n for (const key of [...this.store.keys(), ...this.sets.keys()]) {\n if (key.startsWith(prefix)) await this.del(key);\n }\n }\n\n async sadd(key: string, member: string, ttl?: number): Promise<void> {\n const seconds = checkTtl(ttl);\n const now = Date.now();\n const expiresAt = seconds === undefined ? Infinity : now + seconds * 1000;\n let set = this.sets.get(key);\n if (!set) {\n this.store.delete(key);\n set = { members: new Map(), expiresAt: 0, pruneAt: 64 };\n this.sets.set(key, set);\n }\n // A member keeps its latest expiry: dropped earlier, an entry written\n // with a longer TTL would survive an invalidation of its tag.\n set.members.set(member, Math.max(set.members.get(member) ?? 0, expiresAt));\n // Expired members go each time the set doubles: O(1) per add on average.\n if (set.members.size >= set.pruneAt) {\n for (const [name, at] of set.members) if (at <= now) set.members.delete(name);\n set.pruneAt = Math.max(64, set.members.size * 2);\n }\n if (expiresAt > set.expiresAt) {\n set.expiresAt = expiresAt;\n this.clearTimer(key);\n if (expiresAt !== Infinity) this.expireIn(key, expiresAt - now);\n }\n }\n\n async sdrain(key: string): Promise<string[]> {\n const set = this.sets.get(key);\n if (!set) return [];\n this.sets.delete(key);\n this.clearTimer(key);\n const now = Date.now();\n return [...set.members].filter(([, at]) => at > now).map(([name]) => name);\n }\n}\n","import type { KVAdapter } from '../types';\nimport { checkTtl } from '../ttl';\nimport Redis from 'ioredis';\nimport type { RedisOptions } from 'ioredis';\n\n/**\n * Single consistent codec shared by every write/read path.\n *\n * `undefined` is guarded explicitly: there is no JSON representation for it,\n * so it is stored as the JSON `null` literal and decoded back to `undefined`\n * on read. Every other value is `JSON.stringify`'d on write and `JSON.parse`'d\n * on read, so the string \"123\" round-trips as the string \"123\" (not the number\n * 123) and \"{}\" round-trips as the string \"{}\" (not an empty object).\n */\nfunction encode<T>(value: T): string {\n if (value === undefined) return 'null';\n return JSON.stringify(value);\n}\n\nfunction decode<T>(raw: string): T | undefined {\n try {\n const parsed = JSON.parse(raw) as T | null;\n return parsed === null ? undefined : (parsed as T);\n } catch {\n // Tolerate values written outside the adapter that are not valid JSON.\n return raw as unknown as T;\n }\n}\n\n/**\n * Connection settings: a `redis://` / `rediss://` URL, ioredis options, or\n * ioredis options with a `url` (the form used in app.config and the docs).\n */\nexport type RedisAdapterOptions = string | (RedisOptions & { url?: string });\n\nexport interface RedisAdapterHooks {\n /**\n * Receives the client's `error` events (a dropped connection, each failed\n * reconnect). Without it they are ignored; ioredis printed them to the\n * console, outside the app's logger.\n */\n onError?: (error: Error) => void;\n /**\n * Lets `clear()` with no key prefix at all run FLUSHDB, which empties the\n * whole Redis database, other apps' keys included.\n */\n flushDb?: boolean;\n}\n\n/** `EX` for whole seconds; `PX` for fractional TTLs, which Redis `EX` rejects. */\nfunction ttlArgs(ttl: number): ['EX', number] | ['PX', number] {\n return Number.isInteger(ttl) ? ['EX', ttl] : ['PX', Math.max(1, Math.round(ttl * 1000))];\n}\n\n/** `prefix` as a literal in a SCAN MATCH pattern. */\nconst escapeGlob = (prefix: string): string => prefix.replace(/[*?[\\]\\\\]/g, '\\\\$&');\n\n/** Keys deleted per DEL while clearing. */\nconst CLEAR_BATCH = 1000;\n\n/**\n * An expiring set is a sorted set scored by each member's expiry (Redis time,\n * in ms; FOREVER for none). Adding a member drops the expired ones and keeps\n * the key alive until its last member expires, in one atomic step.\n * KEYS[1]: the set; ARGV[1]: the member; ARGV[2]: its TTL in ms, '' for none.\n */\nconst SADD_SCRIPT = `\nif redis.replicate_commands then redis.replicate_commands() end\nlocal forever = 9007199254740991\nlocal t = redis.call('TIME')\nlocal now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)\nlocal expires = forever\nif ARGV[2] ~= '' then expires = now + tonumber(ARGV[2]) end\nlocal current = tonumber(redis.call('ZSCORE', KEYS[1], ARGV[1]) or '0')\nif expires > current then redis.call('ZADD', KEYS[1], expires, ARGV[1]) end\nredis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', '(' .. now)\nlocal last = redis.call('ZRANGE', KEYS[1], -1, -1, 'WITHSCORES')\nif tonumber(last[2]) >= forever then\n redis.call('PERSIST', KEYS[1])\nelse\n redis.call('PEXPIREAT', KEYS[1], last[2])\nend\n`;\n\n/**\n * Returns the unexpired members of an expiring set (see SADD_SCRIPT) and\n * deletes it, in one atomic step: members past their expiry are left out, as\n * the memory adapter does. KEYS[1]: the set.\n */\nconst SDRAIN_SCRIPT = `\nif redis.replicate_commands then redis.replicate_commands() end\nlocal t = redis.call('TIME')\nlocal now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)\nlocal members = redis.call('ZRANGEBYSCORE', KEYS[1], '(' .. now, '+inf')\nredis.call('DEL', KEYS[1])\nreturn members\n`;\n\n/**\n * ioredis errors carry the command they answer with its arguments: AUTH's\n * password on a refused login, which the app then logged. Only the command\n * name is kept.\n */\nfunction withoutCommandArgs<E>(error: E): E {\n const command = (error as { command?: { name?: unknown; args?: unknown } } | null)?.command;\n if (command && typeof command === 'object' && 'args' in command) {\n (error as { command: unknown }).command = { name: command.name };\n }\n return error;\n}\n\nexport class RedisAdapter implements KVAdapter {\n id = 'redis';\n private client: Redis | null = null;\n private readonly options: RedisAdapterOptions;\n private readonly hooks: RedisAdapterHooks;\n\n constructor(options: RedisAdapterOptions, hooks: RedisAdapterHooks = {}) {\n this.options = options;\n this.hooks = hooks;\n }\n\n /**\n * Connects and waits for Redis to answer: an unreachable server or a wrong\n * password fails here (and so the app's start) instead of on the first\n * command. Once connected, a dropped connection is retried by ioredis.\n */\n async connect() {\n // ioredis only parses a URL passed as its own argument: an object with\n // a `url` key was ignored and it silently connected to localhost:6379.\n let client: Redis;\n if (typeof this.options === 'string') {\n client = new Redis(this.options, { lazyConnect: true });\n } else if (this.options.url) {\n const { url, ...rest } = this.options;\n client = new Redis(url, { ...rest, lazyConnect: true });\n } else {\n client = new Redis({ ...this.options, lazyConnect: true });\n }\n client.on('error', (error: Error) => this.hooks.onError?.(withoutCommandArgs(error)));\n try {\n await client.connect();\n } catch (error) {\n client.disconnect();\n // The message, not the URL: it may carry the password.\n throw new Error(`Could not connect to Redis: ${error instanceof Error ? error.message : String(error)}`, {\n cause: withoutCommandArgs(error),\n });\n }\n this.client = client;\n }\n\n /** Longest wait for QUIT before the connection is closed anyway. */\n protected quitTimeoutMs = 2000;\n\n /**\n * Closes the connection. When connected, QUIT lets pending replies arrive\n * (in-flight writes are not dropped), for at most `quitTimeoutMs`. When the\n * connection is down, ioredis would queue QUIT behind the offline queue and\n * resolve only after its reconnect attempts run out (about 10 s by default,\n * never with `maxRetriesPerRequest: null`), eating the app's shutdown\n * timeout: the client is closed at once instead.\n */\n async disconnect() {\n const client = this.client;\n this.client = null;\n if (!client) return;\n if (typeof client.quit !== 'function' || client.status !== 'ready') {\n client.disconnect();\n return;\n }\n let timer: ReturnType<typeof setTimeout> | undefined;\n const timedOut = new Promise<'timeout'>((resolve) => {\n timer = setTimeout(() => resolve('timeout'), this.quitTimeoutMs);\n });\n const outcome = await Promise.race([\n client.quit().then(\n () => 'quit' as const,\n () => 'error' as const,\n ),\n timedOut,\n ]);\n clearTimeout(timer);\n if (outcome !== 'quit') client.disconnect();\n }\n\n /** The ioredis client once connected (null before connect() and after disconnect()). */\n get nativeClient(): Redis | null {\n return this.client;\n }\n\n /** The live client; operations before connect() fail instead of silently doing nothing. */\n private get redis(): Redis {\n if (!this.client) throw new Error('RedisAdapter is not connected; call connect() first');\n return this.client;\n }\n\n async get<T = unknown>(key: string): Promise<T | undefined> {\n const val = await this.redis.get(key);\n if (val === null || val === undefined) return undefined;\n return decode<T>(val);\n }\n\n async set<T = unknown>(key: string, value: T, ttl?: number): Promise<void> {\n const val = encode(value);\n const seconds = checkTtl(ttl);\n if (seconds !== undefined) {\n await this.redis.set(key, val, ...(ttlArgs(seconds) as ['EX', number]));\n } else {\n await this.redis.set(key, val);\n }\n }\n\n async del(key: string) {\n await this.redis.del(key);\n }\n\n async has(key: string) {\n const exists = await this.redis.exists(key);\n return exists === 1;\n }\n\n // Native batch operations. A single MGET / pipelined MSET / variadic DEL\n // replaces the manager's per-key fan-out (avoids the N+1 round-trips).\n\n async mget<T = unknown>(keys: string[]): Promise<(T | undefined)[]> {\n if (keys.length === 0) return [];\n const raws = (await this.redis.mget(...keys)) ?? [];\n return keys.map((_, i) => {\n const raw = raws[i];\n return raw === null || raw === undefined ? undefined : decode<T>(raw);\n });\n }\n\n async mset<T = unknown>(entries: Array<[string, T]>, ttl?: number): Promise<void> {\n const seconds = checkTtl(ttl);\n if (entries.length === 0) return;\n const pipeline = this.redis.pipeline();\n\n for (const [key, value] of entries) {\n const val = encode(value);\n if (seconds !== undefined) {\n pipeline.set(key, val, ...(ttlArgs(seconds) as ['EX', number]));\n } else {\n pipeline.set(key, val);\n }\n }\n\n // exec() resolves even when individual commands fail; surface them.\n const results = await pipeline.exec();\n const failed = results?.find(([err]) => err);\n if (failed) throw failed[0];\n }\n\n async mdel(keys: string[]): Promise<void> {\n if (keys.length === 0) return;\n await this.redis.del(...keys);\n }\n\n /**\n * Deletes the keys under `prefix` (after ioredis' own `keyPrefix`, if set)\n * with SCAN and DEL. With no prefix at all this would be every key in the\n * database, which only `flushDb: true` allows (as a FLUSHDB).\n */\n async clear(prefix = ''): Promise<void> {\n const redis = this.redis;\n const keyPrefix = redis.options.keyPrefix ?? '';\n if (!keyPrefix && !prefix) {\n if (!this.hooks.flushDb) {\n throw new Error(\n 'RedisAdapter.clear() without a key prefix would empty the whole Redis database: ' +\n 'give the KVManager a namespace, or pass flushDb: true if the database belongs to this app alone',\n );\n }\n await redis.flushdb();\n return;\n }\n const match = `${escapeGlob(keyPrefix + prefix)}*`;\n let cursor = '0';\n do {\n const [next, keys] = await redis.scan(cursor, 'MATCH', match, 'COUNT', CLEAR_BATCH);\n cursor = next;\n // SCAN returns whole keys, and ioredis prepends keyPrefix to DEL's.\n if (keys.length > 0) await redis.del(...keys.map((key) => key.slice(keyPrefix.length)));\n } while (cursor !== '0');\n }\n\n async sadd(key: string, member: string, ttl?: number): Promise<void> {\n const seconds = checkTtl(ttl);\n const ms = seconds === undefined ? '' : String(Math.max(1, Math.ceil(seconds * 1000)));\n await this.redis.eval(SADD_SCRIPT, 1, key, member, ms);\n }\n\n async sdrain(key: string): Promise<string[]> {\n return (await this.redis.eval(SDRAIN_SCRIPT, 1, key)) as string[];\n }\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iskra-bun/kv-kit",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Key-Value store de Iskra con adaptadores de Redis y memoria.",
5
5
  "keywords": [
6
6
  "iskra",
@@ -20,6 +20,9 @@
20
20
  },
21
21
  "homepage": "https://github.com/fearful/iskra/tree/main/packages/kv-kit#readme",
22
22
  "bugs": "https://github.com/fearful/iskra/issues",
23
+ "engines": {
24
+ "bun": ">=1.3.0"
25
+ },
23
26
  "type": "module",
24
27
  "main": "./dist/index.js",
25
28
  "module": "./dist/index.js",
@@ -47,7 +50,7 @@
47
50
  "build": "tsup --config ../../tsup.config.ts"
48
51
  },
49
52
  "dependencies": {
50
- "@iskra-bun/core": "0.1.1",
53
+ "@iskra-bun/core": "^0.2.0",
51
54
  "ioredis": "^5.4.1"
52
55
  },
53
56
  "devDependencies": {
@@ -1,8 +1,21 @@
1
1
  import type { KVAdapter } from '../types';
2
+ import { checkTtl } from '../ttl';
3
+
4
+ /** setTimeout's limit: a longer delay (a TTL over ~24.8 days) fires at once. */
5
+ const MAX_TIMEOUT_MS = 2 ** 31 - 1;
6
+
7
+ /** An expiring set: each member's expiry (ms since the epoch, Infinity for none). */
8
+ interface ExpiringSet {
9
+ members: Map<string, number>;
10
+ expiresAt: number;
11
+ /** Size at which expired members are next dropped. */
12
+ pruneAt: number;
13
+ }
2
14
 
3
15
  export class MemoryAdapter implements KVAdapter {
4
16
  id = 'memory';
5
17
  private store = new Map<string, unknown>();
18
+ private sets = new Map<string, ExpiringSet>();
6
19
  private timers = new Map<string, ReturnType<typeof setTimeout>>();
7
20
 
8
21
  connect() {
@@ -15,13 +28,18 @@ export class MemoryAdapter implements KVAdapter {
15
28
  }
16
29
  this.timers.clear();
17
30
  this.store.clear();
31
+ this.sets.clear();
18
32
  }
19
33
 
34
+ // Values are copied in and out, as Redis does: a stored object returned by
35
+ // reference let one request's mutation show up in every other one.
20
36
  async get<T = unknown>(key: string): Promise<T | undefined> {
21
- return this.store.get(key) as T | undefined;
37
+ return structuredClone(this.store.get(key)) as T | undefined;
22
38
  }
23
39
 
24
40
  async set<T = unknown>(key: string, value: T, ttl?: number): Promise<void> {
41
+ const seconds = checkTtl(ttl);
42
+ const copy = structuredClone(value);
25
43
  // Note: storing `null`/`undefined` is undefined behavior across adapters.
26
44
  // This in-memory adapter stores the value verbatim (so `get` returns it
27
45
  // as-is), whereas the RedisAdapter normalizes both to "absent" because
@@ -29,37 +47,87 @@ export class MemoryAdapter implements KVAdapter {
29
47
  // either form being preserved.
30
48
  //
31
49
  // Clear any existing expiry timer for this key before setting a new one
32
- const existing = this.timers.get(key);
33
- if (existing !== undefined) {
34
- clearTimeout(existing);
35
- this.timers.delete(key);
36
- }
50
+ this.clearTimer(key);
51
+ this.sets.delete(key);
52
+ this.store.set(key, copy);
37
53
 
38
- this.store.set(key, value);
54
+ if (seconds !== undefined) this.expireIn(key, seconds * 1000);
55
+ }
39
56
 
40
- if (ttl) {
41
- const timer = setTimeout(() => {
42
- this.store.delete(key);
43
- this.timers.delete(key);
44
- }, ttl * 1000);
57
+ /** Arms the expiry timer, in steps when the delay exceeds setTimeout's limit. */
58
+ private expireIn(key: string, ms: number) {
59
+ const step = Math.min(ms, MAX_TIMEOUT_MS);
60
+ const timer = setTimeout(() => {
61
+ if (ms > step) {
62
+ this.expireIn(key, ms - step);
63
+ return;
64
+ }
65
+ this.store.delete(key);
66
+ this.sets.delete(key);
67
+ this.timers.delete(key);
68
+ }, step);
45
69
 
46
- // Avoid keeping the process alive just for expiry timers
47
- timer.unref?.();
70
+ // Avoid keeping the process alive just for expiry timers
71
+ timer.unref?.();
48
72
 
49
- this.timers.set(key, timer);
50
- }
73
+ this.timers.set(key, timer);
51
74
  }
52
75
 
53
- async del(key: string): Promise<void> {
76
+ private clearTimer(key: string) {
54
77
  const timer = this.timers.get(key);
55
78
  if (timer !== undefined) {
56
79
  clearTimeout(timer);
57
80
  this.timers.delete(key);
58
81
  }
82
+ }
83
+
84
+ async del(key: string): Promise<void> {
85
+ this.clearTimer(key);
59
86
  this.store.delete(key);
87
+ this.sets.delete(key);
60
88
  }
61
89
 
62
90
  async has(key: string): Promise<boolean> {
63
- return this.store.has(key);
91
+ return this.store.has(key) || this.sets.has(key);
92
+ }
93
+
94
+ async clear(prefix = ''): Promise<void> {
95
+ for (const key of [...this.store.keys(), ...this.sets.keys()]) {
96
+ if (key.startsWith(prefix)) await this.del(key);
97
+ }
98
+ }
99
+
100
+ async sadd(key: string, member: string, ttl?: number): Promise<void> {
101
+ const seconds = checkTtl(ttl);
102
+ const now = Date.now();
103
+ const expiresAt = seconds === undefined ? Infinity : now + seconds * 1000;
104
+ let set = this.sets.get(key);
105
+ if (!set) {
106
+ this.store.delete(key);
107
+ set = { members: new Map(), expiresAt: 0, pruneAt: 64 };
108
+ this.sets.set(key, set);
109
+ }
110
+ // A member keeps its latest expiry: dropped earlier, an entry written
111
+ // with a longer TTL would survive an invalidation of its tag.
112
+ set.members.set(member, Math.max(set.members.get(member) ?? 0, expiresAt));
113
+ // Expired members go each time the set doubles: O(1) per add on average.
114
+ if (set.members.size >= set.pruneAt) {
115
+ for (const [name, at] of set.members) if (at <= now) set.members.delete(name);
116
+ set.pruneAt = Math.max(64, set.members.size * 2);
117
+ }
118
+ if (expiresAt > set.expiresAt) {
119
+ set.expiresAt = expiresAt;
120
+ this.clearTimer(key);
121
+ if (expiresAt !== Infinity) this.expireIn(key, expiresAt - now);
122
+ }
123
+ }
124
+
125
+ async sdrain(key: string): Promise<string[]> {
126
+ const set = this.sets.get(key);
127
+ if (!set) return [];
128
+ this.sets.delete(key);
129
+ this.clearTimer(key);
130
+ const now = Date.now();
131
+ return [...set.members].filter(([, at]) => at > now).map(([name]) => name);
64
132
  }
65
133
  }
@@ -1,4 +1,5 @@
1
1
  import type { KVAdapter } from '../types';
2
+ import { checkTtl } from '../ttl';
2
3
  import Redis from 'ioredis';
3
4
  import type { RedisOptions } from 'ioredis';
4
5
 
@@ -26,44 +27,196 @@ function decode<T>(raw: string): T | undefined {
26
27
  }
27
28
  }
28
29
 
30
+ /**
31
+ * Connection settings: a `redis://` / `rediss://` URL, ioredis options, or
32
+ * ioredis options with a `url` (the form used in app.config and the docs).
33
+ */
34
+ export type RedisAdapterOptions = string | (RedisOptions & { url?: string });
35
+
36
+ export interface RedisAdapterHooks {
37
+ /**
38
+ * Receives the client's `error` events (a dropped connection, each failed
39
+ * reconnect). Without it they are ignored; ioredis printed them to the
40
+ * console, outside the app's logger.
41
+ */
42
+ onError?: (error: Error) => void;
43
+ /**
44
+ * Lets `clear()` with no key prefix at all run FLUSHDB, which empties the
45
+ * whole Redis database, other apps' keys included.
46
+ */
47
+ flushDb?: boolean;
48
+ }
49
+
50
+ /** `EX` for whole seconds; `PX` for fractional TTLs, which Redis `EX` rejects. */
51
+ function ttlArgs(ttl: number): ['EX', number] | ['PX', number] {
52
+ return Number.isInteger(ttl) ? ['EX', ttl] : ['PX', Math.max(1, Math.round(ttl * 1000))];
53
+ }
54
+
55
+ /** `prefix` as a literal in a SCAN MATCH pattern. */
56
+ const escapeGlob = (prefix: string): string => prefix.replace(/[*?[\]\\]/g, '\\$&');
57
+
58
+ /** Keys deleted per DEL while clearing. */
59
+ const CLEAR_BATCH = 1000;
60
+
61
+ /**
62
+ * An expiring set is a sorted set scored by each member's expiry (Redis time,
63
+ * in ms; FOREVER for none). Adding a member drops the expired ones and keeps
64
+ * the key alive until its last member expires, in one atomic step.
65
+ * KEYS[1]: the set; ARGV[1]: the member; ARGV[2]: its TTL in ms, '' for none.
66
+ */
67
+ const SADD_SCRIPT = `
68
+ if redis.replicate_commands then redis.replicate_commands() end
69
+ local forever = 9007199254740991
70
+ local t = redis.call('TIME')
71
+ local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
72
+ local expires = forever
73
+ if ARGV[2] ~= '' then expires = now + tonumber(ARGV[2]) end
74
+ local current = tonumber(redis.call('ZSCORE', KEYS[1], ARGV[1]) or '0')
75
+ if expires > current then redis.call('ZADD', KEYS[1], expires, ARGV[1]) end
76
+ redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', '(' .. now)
77
+ local last = redis.call('ZRANGE', KEYS[1], -1, -1, 'WITHSCORES')
78
+ if tonumber(last[2]) >= forever then
79
+ redis.call('PERSIST', KEYS[1])
80
+ else
81
+ redis.call('PEXPIREAT', KEYS[1], last[2])
82
+ end
83
+ `;
84
+
85
+ /**
86
+ * Returns the unexpired members of an expiring set (see SADD_SCRIPT) and
87
+ * deletes it, in one atomic step: members past their expiry are left out, as
88
+ * the memory adapter does. KEYS[1]: the set.
89
+ */
90
+ const SDRAIN_SCRIPT = `
91
+ if redis.replicate_commands then redis.replicate_commands() end
92
+ local t = redis.call('TIME')
93
+ local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
94
+ local members = redis.call('ZRANGEBYSCORE', KEYS[1], '(' .. now, '+inf')
95
+ redis.call('DEL', KEYS[1])
96
+ return members
97
+ `;
98
+
99
+ /**
100
+ * ioredis errors carry the command they answer with its arguments: AUTH's
101
+ * password on a refused login, which the app then logged. Only the command
102
+ * name is kept.
103
+ */
104
+ function withoutCommandArgs<E>(error: E): E {
105
+ const command = (error as { command?: { name?: unknown; args?: unknown } } | null)?.command;
106
+ if (command && typeof command === 'object' && 'args' in command) {
107
+ (error as { command: unknown }).command = { name: command.name };
108
+ }
109
+ return error;
110
+ }
111
+
29
112
  export class RedisAdapter implements KVAdapter {
30
113
  id = 'redis';
31
114
  private client: Redis | null = null;
32
- private readonly options: RedisOptions | string;
115
+ private readonly options: RedisAdapterOptions;
116
+ private readonly hooks: RedisAdapterHooks;
33
117
 
34
- constructor(options: RedisOptions | string) {
118
+ constructor(options: RedisAdapterOptions, hooks: RedisAdapterHooks = {}) {
35
119
  this.options = options;
120
+ this.hooks = hooks;
121
+ }
122
+
123
+ /**
124
+ * Connects and waits for Redis to answer: an unreachable server or a wrong
125
+ * password fails here (and so the app's start) instead of on the first
126
+ * command. Once connected, a dropped connection is retried by ioredis.
127
+ */
128
+ async connect() {
129
+ // ioredis only parses a URL passed as its own argument: an object with
130
+ // a `url` key was ignored and it silently connected to localhost:6379.
131
+ let client: Redis;
132
+ if (typeof this.options === 'string') {
133
+ client = new Redis(this.options, { lazyConnect: true });
134
+ } else if (this.options.url) {
135
+ const { url, ...rest } = this.options;
136
+ client = new Redis(url, { ...rest, lazyConnect: true });
137
+ } else {
138
+ client = new Redis({ ...this.options, lazyConnect: true });
139
+ }
140
+ client.on('error', (error: Error) => this.hooks.onError?.(withoutCommandArgs(error)));
141
+ try {
142
+ await client.connect();
143
+ } catch (error) {
144
+ client.disconnect();
145
+ // The message, not the URL: it may carry the password.
146
+ throw new Error(`Could not connect to Redis: ${error instanceof Error ? error.message : String(error)}`, {
147
+ cause: withoutCommandArgs(error),
148
+ });
149
+ }
150
+ this.client = client;
36
151
  }
37
152
 
38
- connect() {
39
- this.client = new Redis(this.options as RedisOptions);
153
+ /** Longest wait for QUIT before the connection is closed anyway. */
154
+ protected quitTimeoutMs = 2000;
155
+
156
+ /**
157
+ * Closes the connection. When connected, QUIT lets pending replies arrive
158
+ * (in-flight writes are not dropped), for at most `quitTimeoutMs`. When the
159
+ * connection is down, ioredis would queue QUIT behind the offline queue and
160
+ * resolve only after its reconnect attempts run out (about 10 s by default,
161
+ * never with `maxRetriesPerRequest: null`), eating the app's shutdown
162
+ * timeout: the client is closed at once instead.
163
+ */
164
+ async disconnect() {
165
+ const client = this.client;
166
+ this.client = null;
167
+ if (!client) return;
168
+ if (typeof client.quit !== 'function' || client.status !== 'ready') {
169
+ client.disconnect();
170
+ return;
171
+ }
172
+ let timer: ReturnType<typeof setTimeout> | undefined;
173
+ const timedOut = new Promise<'timeout'>((resolve) => {
174
+ timer = setTimeout(() => resolve('timeout'), this.quitTimeoutMs);
175
+ });
176
+ const outcome = await Promise.race([
177
+ client.quit().then(
178
+ () => 'quit' as const,
179
+ () => 'error' as const,
180
+ ),
181
+ timedOut,
182
+ ]);
183
+ clearTimeout(timer);
184
+ if (outcome !== 'quit') client.disconnect();
40
185
  }
41
186
 
42
- disconnect() {
43
- this.client?.disconnect();
187
+ /** The ioredis client once connected (null before connect() and after disconnect()). */
188
+ get nativeClient(): Redis | null {
189
+ return this.client;
190
+ }
191
+
192
+ /** The live client; operations before connect() fail instead of silently doing nothing. */
193
+ private get redis(): Redis {
194
+ if (!this.client) throw new Error('RedisAdapter is not connected; call connect() first');
195
+ return this.client;
44
196
  }
45
197
 
46
198
  async get<T = unknown>(key: string): Promise<T | undefined> {
47
- const val = await this.client?.get(key);
199
+ const val = await this.redis.get(key);
48
200
  if (val === null || val === undefined) return undefined;
49
201
  return decode<T>(val);
50
202
  }
51
203
 
52
204
  async set<T = unknown>(key: string, value: T, ttl?: number): Promise<void> {
53
205
  const val = encode(value);
54
- if (ttl) {
55
- await this.client?.set(key, val, 'EX', ttl);
206
+ const seconds = checkTtl(ttl);
207
+ if (seconds !== undefined) {
208
+ await this.redis.set(key, val, ...(ttlArgs(seconds) as ['EX', number]));
56
209
  } else {
57
- await this.client?.set(key, val);
210
+ await this.redis.set(key, val);
58
211
  }
59
212
  }
60
213
 
61
214
  async del(key: string) {
62
- await this.client?.del(key);
215
+ await this.redis.del(key);
63
216
  }
64
217
 
65
218
  async has(key: string) {
66
- const exists = await this.client?.exists(key);
219
+ const exists = await this.redis.exists(key);
67
220
  return exists === 1;
68
221
  }
69
222
 
@@ -72,35 +225,73 @@ export class RedisAdapter implements KVAdapter {
72
225
 
73
226
  async mget<T = unknown>(keys: string[]): Promise<(T | undefined)[]> {
74
227
  if (keys.length === 0) return [];
75
- const raws = (await this.client?.mget(...keys)) ?? [];
228
+ const raws = (await this.redis.mget(...keys)) ?? [];
76
229
  return keys.map((_, i) => {
77
230
  const raw = raws[i];
78
231
  return raw === null || raw === undefined ? undefined : decode<T>(raw);
79
232
  });
80
233
  }
81
234
 
82
- async mset<T = unknown>(
83
- entries: Array<[string, T]>,
84
- ttl?: number
85
- ): Promise<void> {
235
+ async mset<T = unknown>(entries: Array<[string, T]>, ttl?: number): Promise<void> {
236
+ const seconds = checkTtl(ttl);
86
237
  if (entries.length === 0) return;
87
- const pipeline = this.client?.pipeline();
88
- if (!pipeline) return;
238
+ const pipeline = this.redis.pipeline();
89
239
 
90
240
  for (const [key, value] of entries) {
91
241
  const val = encode(value);
92
- if (ttl) {
93
- pipeline.set(key, val, 'EX', ttl);
242
+ if (seconds !== undefined) {
243
+ pipeline.set(key, val, ...(ttlArgs(seconds) as ['EX', number]));
94
244
  } else {
95
245
  pipeline.set(key, val);
96
246
  }
97
247
  }
98
248
 
99
- await pipeline.exec();
249
+ // exec() resolves even when individual commands fail; surface them.
250
+ const results = await pipeline.exec();
251
+ const failed = results?.find(([err]) => err);
252
+ if (failed) throw failed[0];
100
253
  }
101
254
 
102
255
  async mdel(keys: string[]): Promise<void> {
103
256
  if (keys.length === 0) return;
104
- await this.client?.del(...keys);
257
+ await this.redis.del(...keys);
258
+ }
259
+
260
+ /**
261
+ * Deletes the keys under `prefix` (after ioredis' own `keyPrefix`, if set)
262
+ * with SCAN and DEL. With no prefix at all this would be every key in the
263
+ * database, which only `flushDb: true` allows (as a FLUSHDB).
264
+ */
265
+ async clear(prefix = ''): Promise<void> {
266
+ const redis = this.redis;
267
+ const keyPrefix = redis.options.keyPrefix ?? '';
268
+ if (!keyPrefix && !prefix) {
269
+ if (!this.hooks.flushDb) {
270
+ throw new Error(
271
+ 'RedisAdapter.clear() without a key prefix would empty the whole Redis database: ' +
272
+ 'give the KVManager a namespace, or pass flushDb: true if the database belongs to this app alone',
273
+ );
274
+ }
275
+ await redis.flushdb();
276
+ return;
277
+ }
278
+ const match = `${escapeGlob(keyPrefix + prefix)}*`;
279
+ let cursor = '0';
280
+ do {
281
+ const [next, keys] = await redis.scan(cursor, 'MATCH', match, 'COUNT', CLEAR_BATCH);
282
+ cursor = next;
283
+ // SCAN returns whole keys, and ioredis prepends keyPrefix to DEL's.
284
+ if (keys.length > 0) await redis.del(...keys.map((key) => key.slice(keyPrefix.length)));
285
+ } while (cursor !== '0');
286
+ }
287
+
288
+ async sadd(key: string, member: string, ttl?: number): Promise<void> {
289
+ const seconds = checkTtl(ttl);
290
+ const ms = seconds === undefined ? '' : String(Math.max(1, Math.ceil(seconds * 1000)));
291
+ await this.redis.eval(SADD_SCRIPT, 1, key, member, ms);
292
+ }
293
+
294
+ async sdrain(key: string): Promise<string[]> {
295
+ return (await this.redis.eval(SDRAIN_SCRIPT, 1, key)) as string[];
105
296
  }
106
297
  }