Key-value store support for LibreDB Studio, built on
ioredis. This document is the single reference point for the Redis provider: design, architecture, usage, and tests. If you are reading the code, extending Redis support, or authoring a new provider, start here.
| Status | ✅ Implemented & shipped |
| Database type id | redis |
| Family | Key-Value |
| Driver | ioredis (^5.9.2) |
| Query language | json (plain command or JSON command object) |
| Default port | 6379 |
| Connection pooling | None — single lazy connection |
| Source | src/lib/db/providers/keyvalue/redis.ts |
| Tests | tests/integration/db/redis-provider.test.ts |
| Tracking issue | #7 — Implement Redis Provider |
Redis is an in-memory key-value store. It has no tables, no rows, no SQL, and no relational schema. LibreDB Studio is a SQL-oriented IDE, so the central design problem is:
How do you present a key-value store through the same
DatabaseProviderinterface that PostgreSQL, MySQL, and the rest implement — without emulating SQL and without leaking Redis-specific concepts into the shared UI?
The answer is mapping by convention, not emulation. The provider does not pretend Redis is
relational. Instead it maps Redis concepts onto the slots the interface already exposes, and
relabels the UI through the provider-metadata hooks (getCapabilities() / getLabels()) so the
generic components render Redis-appropriate wording.
DatabaseProvider slot |
Redis realisation | Redis primitive used |
|---|---|---|
"Table" (TableSchema) |
A key prefix (e.g. user:*) |
SCAN + prefix grouping |
| "Row" | A key | — |
query(sql) |
A Redis command (plain text or JSON) | generic client.call() |
getHealth() / getOverview() |
Server stats | INFO |
getSlowQueries() |
Slow command log | SLOWLOG GET |
getActiveSessions() |
Connected clients | CLIENT LIST |
getStorageStats() |
Memory usage | INFO memory |
runMaintenance('analyze') |
Server info snapshot | INFO |
| Indexes / table stats | Not applicable | returns [] |
The database layer uses the Strategy Pattern. Every provider implements the
DatabaseProvider interface, and most of the shared mechanics live
in the abstract BaseDatabaseProvider. Providers are grouped
by family on disk:
src/lib/db/
├── base-provider.ts # abstract base: state, helpers, default metadata, getMonitoringData()
├── types.ts # DatabaseProvider interface + all DTOs
├── errors.ts # DatabaseError hierarchy + mapDatabaseError()
├── factory.ts # createDatabaseProvider() — dynamic import per type + provider cache
└── providers/
├── sql/ # postgres, mysql, sqlite, oracle, mssql (extend SQLBaseProvider)
├── document/ # mongodb
└── keyvalue/
└── redis.ts # ← RedisProvider (this document)
DatabaseProvider (interface, types.ts)
▲
│ implements
BaseDatabaseProvider (abstract, base-provider.ts)
▲
│ extends
RedisProvider (redis.ts)
RedisProvider extends BaseDatabaseProvider directly (unlike the SQL providers, which extend an
intermediate SQLBaseProvider). It overrides every abstract method plus the three metadata hooks
(getCapabilities, getLabels, prepareQuery). It inherits getMonitoringData(), which fans the
individual monitoring methods out in parallel — see base-provider.ts:99.
RedisProvider reuses these inherited members rather than reimplementing them:
- State machine —
setConnected(),setError(),isConnected(),ensureConnected(). - Instrumentation —
trackQuery()(active-query counter) andmeasureExecution()(wall-clock timing). - Helpers —
formatDuration(),getSafeConfig()(password-stripped logging),logError(). - Default
getMonitoringData()— orchestratesgetOverview+getPerformanceMetrics+getSlowQueries+getActiveSessions(+ optional tables/indexes/storage) concurrently.
The factory wires Redis in via a dynamic import so the ioredis driver is only loaded when a Redis
connection is actually opened (factory.ts:94):
case 'redis': {
const { RedisProvider } = await import('./providers/keyvalue/redis');
return new RedisProvider(connection, options);
}API routes use getOrCreateProvider(), which caches the connected provider per connection.id and
evicts it after 30 minutes idle. disconnect() is called on eviction and on graceful shutdown
(SIGTERM/SIGINT).
These are the non-obvious choices. Read this section before changing the provider.
Schema discovery uses cursor-based SCAN with COUNT 100, not KEYS *
(redis.ts:327). KEYS * is O(N) and blocks the
entire Redis server until it completes — catastrophic on a production instance with millions of
keys. SCAN is incremental and non-blocking. The scan is also capped at maxScan = 1000 keys so
schema introspection stays bounded regardless of keyspace size.
getKeyPrefix() (redis.ts:375) takes everything
before the first : and appends :* — so user:123 and user:456 both collapse into the
user:* "table". Keys without a colon are their own group. For each prefix the provider probes
keys with TYPE until it has observed up to 3 distinct value-types — it may inspect more than
3 keys when they share a type — to populate the synthetic column metadata. The resulting
TableSchema list is sorted by descending key count so the busiest prefixes surface first.
Rather than hand-coding a method per Redis command, the provider funnels everything through
ioredis's low-level client.call(command, ...args) (redis.ts:230).
This means any Redis command works without code changes — GET, LPUSH, XADD, JSON.GET,
module commands, etc. The trade-off is that there is no per-command validation; an unknown or
mis-arity command surfaces as a Redis-side error wrapped in QueryError.
The query string is dispatched by its first character (redis.ts:165):
- Starts with
{→ parsed as a JSON command object{ "command": "GET", "args": ["k"] }. - Anything else → parsed as a plain command with a small quote-aware tokenizer that preserves
single/double-quoted arguments (so
SET k "hello world"is two args, not three).
Redis replies are heterogeneous (status strings, integers, nil, flat arrays, hash arrays, bulk
INFO text). formatResult() (redis.ts:237)
normalises each into the standard { rows, fields, rowCount } envelope so the existing
ResultsGrid renders them unchanged. See the reply table below.
Redis is single-threaded and a single multiplexed connection is the idiomatic client model, so the
provider holds one ioredis client and ignores the PoolConfig. connect() uses
lazyConnect: true and then calls connect() explicitly so connection failures surface
deterministically at connect() time rather than on first command.
Redis uses the discrete-field form of DatabaseConnection (not connectionString):
| Field | Required | Notes |
|---|---|---|
host |
✅ | Validated in validate() — throws DatabaseConfigError if missing |
port |
— | Defaults to 6379 |
password |
— | Sent as password; omit for unauthenticated instances |
database |
— | Logical DB index, parsed as int; defaults to 0 |
const connection = {
id: 'redis-1',
name: 'Cache',
type: 'redis',
host: 'localhost',
port: 6379,
password: 'secret', // optional
database: '0', // logical DB index
createdAt: new Date(),
};getCapabilities().supportsConnectionString is false — the provider itself only consumes
discrete fields. However, the UI connection-string parser
(src/lib/connection-string-parser.ts) does
recognise redis:// and rediss:// URLs and decomposes them into host / port (default
6379) / password / database before they reach the provider. So a user can paste a
redis://:pw@host:6379/0 URL into the modal, but the provider never sees the raw string.
# Plain command (quote-aware)
HGETALL user:1
SET greeting "hello world"
KEYS user:*
# JSON command object
{ "command": "HGETALL", "args": ["user:1"] }
{ "command": "SET", "args": ["greeting", "hello world"] }
formatResult() maps each Redis reply type onto grid columns:
| Redis reply | fields |
Example cell |
|---|---|---|
Simple string / status (GET, PING, SET) |
result |
OK, PONG, hello-world |
Integer (DEL, DBSIZE, INCR) |
result |
(integer) 42 |
nil |
result |
(nil) (rowCount 0) |
| Empty array | result |
(empty list) (rowCount 0) |
Array (KEYS, SMEMBERS, LRANGE) |
index, value |
1 | user:1 |
Hash (HGETALL) |
field, value |
email | a@b.com |
INFO |
section, key, value |
Server | redis_version | 7.2.4 |
INFO is special-cased: parseInfoResult() splits the bulk reply into one row per metric, tagging
each with its # Section header (redis.ts:288).
getSchema() (redis.ts:316) returns one
TableSchema per key prefix:
1. cursor = "0"
2. loop:
[cursor, keys] = SCAN cursor COUNT 100
for each key:
prefix = substring before first ':' + ':*' (or the whole key)
increment prefix.count
if prefix has < 3 sampled types: TYPE key → add to prefix.types
until cursor == "0" OR totalScanned >= 1000
3. emit TableSchema per prefix, sorted by rowCount desc
Each synthetic TableSchema has three columns: key (string, primary), value (typed by the
sampled Redis types, e.g. string/hash), and type. indexes is always empty (getIndexStats()
and getTableStats() return [] — Redis has no indexes or table statistics).
All monitoring derives from Redis introspection commands. parseRedisInfo() turns the INFO bulk
string into a flat key → value map that the methods below read from.
| Method | Source command | Returns |
|---|---|---|
getHealth() |
INFO |
connected_clients, used_memory_human, hit ratio |
getOverview() |
INFO + DBSIZE |
version, uptime, clients, maxclients, memory, key count (tableCount) |
getPerformanceMetrics() |
INFO |
cache hit ratio, instantaneous_ops_per_sec → queriesPerSecond |
getSlowQueries() |
SLOWLOG GET 10 |
per-entry id, command text, duration (µs → ms) |
getActiveSessions() |
CLIENT LIST |
one session per client (id, addr, db, flags, cmd, idle) |
getStorageStats() |
INFO memory |
used_memory_human, optional usagePercent vs maxmemory |
getTableStats() |
— | [] (N/A) |
getIndexStats() |
— | [] (N/A) |
Cache hit ratio is computed as keyspace_hits / (keyspace_hits + keyspace_misses) * 100,
defaulting to 100.0 when there has been no traffic (redis.ts:557).
The monitoring methods that depend on optional Redis features (SLOWLOG, CLIENT LIST) are wrapped
in try/catch and degrade to [] rather than throwing — a restricted ACL that forbids those commands
won't break the monitoring dashboard.
Redis exposes a single maintenance operation:
| Type | Behaviour |
|---|---|
analyze |
Runs INFO and reports the number of lines in the output as a snapshot. Non-destructive. |
| anything else | Throws QueryError (Unsupported maintenance type for Redis) |
This is reflected in getCapabilities().maintenanceOperations = ['analyze']. The UI relabels these
actions for Redis via getLabels() (e.g. "Memory Doctor", "Run Info").
getCapabilities() (redis.ts:56)
| Capability | Value |
|---|---|
queryLanguage |
json |
supportsExplain |
false |
supportsExternalQueryLimiting |
false |
supportsCreateTable |
false |
supportsMaintenance |
true |
maintenanceOperations |
['analyze'] |
supportsConnectionString |
false |
defaultPort |
6379 |
schemaRefreshPattern |
(DEL|FLUSHDB|FLUSHALL|RENAME)\b |
schemaRefreshPattern tells the UI which executed commands should trigger a schema (key-pattern)
refresh — i.e. commands that add or remove keys.
getLabels() (redis.ts:70)
The label map relabels the generic schema-explorer UI for key-value semantics: entity → "Key Pattern", row → "key", select → "Scan Keys", analyze → "Key Info", vacuum → "Memory Doctor", search placeholder → "Search keys…", etc.
The provider raises the shared error classes from
src/lib/db/errors.ts:
| Situation | Error |
|---|---|
Missing host at construction |
DatabaseConfigError |
Operation before connect() |
DatabaseConfigError (via ensureConnected()) |
connect() fails |
ConnectionError |
| Malformed JSON command | QueryError — "Invalid JSON command format" |
JSON without command |
QueryError — "Command is required…" |
| Empty command | QueryError — "Empty command" |
| Redis-side command failure | QueryError — "Redis error: …" |
All QueryErrors carry the QUERY_ERROR API code and surface to the client as 400 Bad Request.
Integration tests live in
tests/integration/db/redis-provider.test.ts.
In keeping with the project's test architecture, the ioredis driver is replaced with an in-process
mock via mock.module('ioredis', …) before the provider is imported — there is no live Redis
container in the suite. The mock simulates a Redis 7.2.x server (redis_version:7.2.4,
INFO/SCAN/CLIENT LIST/call() responses), which exercises the same code paths as a real
Redis 6.0+ instance.
⚠️ Mock isolation:bun'smock.module()is process-wide. Run the suite withbun run test(which isolates execution groups), never barebun testacross multiple files — see the note inCLAUDE.md. The Redis file mocksioredis, which would otherwise leak into any other test sharing the process.
The suite covers: validation, connect/disconnect, capabilities, labels, prepareQuery, all query
formats (JSON, plain, empty, HGETALL, INFO, nil), error handling (malformed JSON, missing
command, Redis-side error, disconnected provider), schema scanning, health, overview, performance,
slow queries, active sessions, table/index/storage stats, getMonitoringData, maintenance, and a
battery of common commands (KEYS, SET, DEL, PING, DBSIZE).
# Just this file
bun test tests/integration/db/redis-provider.test.ts
# Full isolated suite (CI-equivalent)
bun run testThe committed tests are mock-based by design. To smoke-test against a real server during development:
docker run --rm -p 6379:6379 redis:7-alpine
# then point a connection at localhost:6379 in the Studio UI and run e.g. `INFO`, `SCAN 0`import { createDatabaseProvider } from '@/lib/db/factory';
const provider = await createDatabaseProvider({
id: 'r1', name: 'Cache', type: 'redis',
host: 'localhost', port: 6379, createdAt: new Date(),
});
await provider.connect();
await provider.query('SET greeting "hello"'); // → OK
await provider.query('GET greeting'); // → hello
await provider.query('{ "command": "HGETALL", "args": ["user:1"] }');
const schema = await provider.getSchema(); // → key prefixes as "tables"
await provider.disconnect();POST /api/db/query with the Redis command in the sql field — see the
Redis Query Format section of API_DOCS.md for the full
request/response contract.
- TLS (
rediss://) is parsed but not connected. The connection-string parser recognisesrediss://, but it does not preserve the secure scheme, andconnect()neither passes atlsoption toioredisnor readsconfig.ssl. The provider always attempts a plaintext connection, so a TLS-only endpoint will fail to connect rather than negotiate TLS. Future: threadconfig.sslinto theioredisconstructor. - No Cluster / Sentinel support. Only a single standalone node is supported.
SCANis capped at 1000 keys for schema discovery — prefixes that only appear beyond the cap won't show as "tables". This is a deliberate bound, not a bug.- No read-only guard. The generic
call()dispatch executes write/destructive commands (SET,DEL,FLUSHALL, …) the same as reads. Access control is expected to be enforced by the Redis ACL / user role, not the provider. - Binary values are stringified via
String(...); non-UTF8 binary payloads may not render faithfully in the grid.
- Tracking issue: #7 — Implement Redis Provider
- Driver:
ioredis - Source:
src/lib/db/providers/keyvalue/redis.ts - Base class:
src/lib/db/base-provider.ts - Interface & DTOs:
src/lib/db/types.ts - Errors:
src/lib/db/errors.ts - Tests:
tests/integration/db/redis-provider.test.ts - API contract:
docs/API_DOCS.md
This Redis provider is a good template for a non-relational backend. To add another provider:
- Create
src/lib/db/providers/<family>/<name>.tsextendingBaseDatabaseProvider. - Implement the abstract methods (
connect,disconnect,query,getSchema,getHealth,runMaintenance, and the seven monitoring methods). Return[]from the ones that don't apply. - Override
getCapabilities(),getLabels(), andprepareQuery()so the shared UI renders the right wording and feature flags. - Register the type in the
factory.tsswitch (dynamic import) and add it to theDatabaseTypeunion insrc/lib/types.ts. - Add the driver dependency to
package.json. - Map native driver errors onto the
errors.tsclasses (ConnectionError,QueryError, …). - Test with a
mock.module()-based integration test mirroring the structure above. - Document the provider in
docs/providers/<name>.mdusing this file as the template, and add the query format todocs/API_DOCS.md.