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 (the version range is in package.json) |
| Query language | json (plain command or JSON command object) |
| Default port | 6379 |
| Connection pooling | None — single lazy connection |
| SSL | Yes — connection.ssl → ioredis tls (§4.3) |
| 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" (the relation kind) | 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 [] |
This provider is what a Valkey connection uses: there is no valkey type id, and choosing Redis in
the connection dialog is the documented way to reach it. ioredis speaks the protocol all five
servers share, and the connection dialog's wire-compatibility hint names the engines this driver has
been measured against, from the same data
(compatibility.ts). The full per-engine table is in
README.md; three behaviours belong here because they are
this provider's code, not the engine's.
The overview names the server when INFO names itself. getOverview() calls
labelServerVersion() (redis.ts, in
getOverview()), the same self-naming-wins rule the MySQL provider's own labelServerVersion()
applies to VERSION() (mysql.md §1.1):
when the reply names a vendor, show that name; redis_version alone is the fallback for when it
does not. Three of the four relatives publish a version field of their own beside
redis_version — Valkey 9.1.1 publishes valkey_version:9.1.1 and server_name:valkey,
DragonflyDB df-v1.40.1 publishes dragonfly_version:df-v1.40.1, and Garnet 2.1.5 publishes
garnet_version:2.1.5 and server_name:garnet — and the overview shows each labeled ahead of the
compat level, e.g. "Garnet 2.1.5 (Redis 7.4.3)". KeyDB 6.3.4 publishes no version field of its
own at all, so redis_version there already is the real version and the overview shows it bare,
same as a stock Redis 6.
maxclients is not universal. maxConnections reads parsed.maxclients, falling back to
parsed.max_clients (Dragonfly's underscored name for the same limit — max_clients:64000
measured live), and 0 when neither is present. Redis, Valkey and KeyDB each publish
maxclients:10000; Dragonfly publishes the limit under the underscored name instead. Garnet's
INFO carries no client-limit field under either name at all, so its panel reads 0 — an absent
limit shown as a zero, not a measured one. Garnet extends that to two more numbers: it publishes no
used_memory, so getOverview().databaseSize and the memory storage panel read 0 B for a
populated server, and neither keyspace_hits nor keyspace_misses, so the cache hit ratio reads
the : 100 fallback and the dashboard rates it Excellent (recorded as D14 in
../BACKLOG.md). Its connected_clients also stays 0 with a client attached,
though CLIENT LIST itself answers correctly - the session list is right while the count above it
is not.
Every CLIENT LIST field is optional, and the absent ones surface as defaults.
getActiveSessions() (redis.ts) splits each
line into key=value pairs and substitutes a default for anything missing: the session's user is
user falling back to default, its pid id falling back to 0, its state flags falling back
to N, its command cmd falling back to idle. A relative that omits a field therefore produces a
plausible-looking row rather than an error.
Three relatives diverge, all measured on the versions above:
- Dragonfly publishes no
user=field at all, so the user column readsdefaultregardless of which ACL user the connection authenticated as — the fallback is always the answer there, not the exception. Its line also carries nocmd=and noflags=at all, so the query column readsidleand the state columnNfor every session, whatever that session is doing. - KeyDB reports a command without its subcommand (
cmd=clientwhere Redis and Valkey both sendcmd=client|list), which the query column shows verbatim. - Garnet answers
CLIENT LISTin full - the session row is complete and correct - so the divergence there is not in this reply but in the counters above it, listed undermaxclients.
Valkey is otherwise the closest of the four: every surface in this document answered against
Valkey 9.1.1, with the version panel the single caveat. Garnet answers every surface too, and its
key browser grouped 71 keys into user:*, session:* and queue:* exactly as Redis does — what
separates it is the three numbers above, not a missing surface. It also keeps nothing on disk: a
restart empties it (measured, dbsize 71 before and 0 after), so a fixture there has to be
re-seeded rather than restarted into.
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() from
base-provider.ts, which fans the individual monitoring methods
out in parallel.
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 by createDatabaseProvider()
(factory.ts):
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 * in
the object surface (redis.ts). 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 KEY_SCAN_LIMIT (1000) keys so
schema introspection stays bounded regardless of keyspace size.
keyGrouping() (redis.ts) takes everything
before the first : and appends :* — so user:123 and user:456 both collapse into the
user:* "table". It is module-level rather than a method because the object surface
(§6.1) groups the same keyspace through it, and two surfaces that
grouped it differently would be two different answers about one server. 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
object list is sorted by path.
That read is BOUNDED and one-shot: it stops at 1,000 keys and discards its cursor, so what it
answers is a floor rather than a total. The keyspace walk below
(§6.2) is the same grouping problem asked a different way — one the
caller drives, one batch at a time, with the cursor kept. Both are bounded alike, which is why the
walk's maxCount is the same 1,000 that KEY_SCAN_LIMIT puts on this one.
Rather than hand-coding a method per Redis command, runCommand()
(redis.ts) funnels everything through ioredis's
low-level client.callBuffer(command, ...args), and decodeReply() turns the raw reply into what
the grid shows (§3.5a).
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.
executeRedisCommand() (redis.ts) dispatches the
query string by its first character:
- 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).
The dispatch happens after leading blank lines and # comment lines are dropped, so the character
that decides the format is the first character of the first runnable line, not of the buffer.
A quoted run makes an argument even when it is empty, the way redis-cli reads one: SET k "" sends
the empty string, and LPUSH l "" x pushes two elements. Before this the tokenizer kept a token only
when it held a character, so measured on Redis 8.10.2 SET k "" answered "ERR wrong number of
arguments for 'set' command" and LPUSH l "" x pushed x alone.
readRedisCommandText()
(redis-command-text.ts) reads the
buffer to the one command a run sends. Each non-empty line is its own command. The rules:
- Every
#comment line is dropped, wherever it sits: leading, between commands, inside a pretty-printed JSON command or trailing. A line is a comment only when it starts with#(after trimming) and no quoted argument is open across it, so a#inside a key or a value is never mistaken for one, including the continuation line of a multi-line quoted value (SET note "line1/#tag"). - A line continues onto the next only in two cases, both of which the tokenizer can see:
- it ends inside an open quoted argument. The line break is then data, and so is a blank or
#-leading line inside the quote. This is how a multi-line value, anEVALscript and aFUNCTION LOADlibrary are written:EVAL "local a = 1/return a" 0is one command whose script keeps its newline. The tokenizer's quote rules are redis-cli's without escapes: a"or'opens a run that only the same character closes, and runs with no whitespace between them make one argument (a"b c"disab cd). A quote that never closes is refused, naming the line it opened on ("Line 1 opens a quoted argument with ' that never closes ..."), and nothing is sent: continuing is right only when the quote closes later. Before this,SET greeting it's/GET greetingstoredits, a newline andGET greeting, andSET k "a\"b"/GET kdid the same, because the open quote took every later line as data, blank-line alternatives included. - it belongs to a JSON command whose braces and brackets are not yet balanced. A command whose
first character is
{is read line by line until{/[and}/]balance, counted outside JSON strings with JSON's own\escape, soJSON.stringify(cmd, null, 2)reads whole and a}inside a string does not end it. A blank line ends an unfinished JSON command, which then failsJSON.parse("Invalid JSON command format") rather than swallowing the next command.
- it ends inside an open quoted argument. The line break is then data, and so is a blank or
- The first blank line ends what a run reads. Leading blank lines are padding. The generated cheatsheet (§5.3) is a list of alternatives separated by blank lines, so running the whole buffer runs its first command, and a command after a blank line is not run.
- A second command before that blank line is refused, and nothing is sent: "Line 2 holds a second command, which begins with "RPUSH": each line is one Redis command, and Studio runs one command per run. Select the line to run it, and the editor sends the selection. ...". The line number counts from the top of the text the editor sent, which is the selection when there is one.
Brackets count only for a JSON command. Outside quotes a plain argument cannot carry a JSON value
or a Lua script across lines anyway, because whitespace splits it and quote characters are stripped,
so in plain form a bracket is ordinary key text: user:{42} is a cluster hash tag, and a key such as
a{b would otherwise hold its line open and glue the next command onto it.
Why the rule changed. Until this rule a newline outside quotes was ordinary whitespace and the
first blank-line-delimited block ran as ONE command, so that a command wrapped over several lines
(HSET k a 1 / b 2) ran whole. The same rule turned two commands on consecutive lines into one,
and the second command's words became data. Measured on Redis 8.10.2 and Valkey 9.1.2 on
2026-10-04: RPUSH mq x / RPUSH mq y answered (integer) 4 and the list held x, RPUSH, mq, y;
SADD s2 a / SADD s2 b left the set a, SADD, s2, b; DEL a / DEL b would also delete a key
literally named DEL. Nothing on screen said so. One command per line is what redis-cli and every
other Redis client reads, so it is the reading a user expects; a command wrapped across lines without
a quote is now refused, naming the line that would have been merged, instead of running as something
else. The refusal rather than running every line, because one run answers one result, and because
the confirmation gate below asks about one command.
The dispatch of §3.4 then looks at the first character of that command. Text after a JSON command
is a second command (refused by rule 4), and text on the JSON command's own last line joins the body
and fails JSON.parse.
Input that is only comments or blank lines raises
QueryError("No command to run (only comments or blank lines)").
This exists so the commented cheatsheet the schema explorer inserts is directly runnable: selecting
one command runs it, and running the whole buffer runs its first one (#427). The embedded LibreDB
provider is line-based too: it runs its first non-comment line (libredb.md §5.1).
Since S8 the execution confirmation gate reads a buffer the same way, and since this rule with the
same function: src/lib/db/destructive-commands.ts reads the text with readRedisCommandText(),
dispatches on a leading { as §3.4 does, and asks before running any command in its Redis destructive
vocabulary - key, expiry, string, hash, list, set, sorted-set and stream writes, the scripting
entry points, and the server and access commands, with container commands such as CONFIG SET
matched on their two-token spelling - while a body it cannot read (broken JSON, a JSON body whose
command is not a string) asks rather than staying silent. The vocabulary names only commands the
provider runs: the blocking list and sorted-set pops are refused before they reach the server
(§5.2b), so their non-blocking
forms are the ones the gate asks about. The Redis row also declares refuse: redisRefusal, so the
editor refuses a second command before any request with the same sentence the provider raises, and
the gate names nothing for such a text, because it cannot run.
Redis replies are heterogeneous (status strings, integers, nil, flat arrays, hash arrays, bulk
INFO text). formatResult() (redis.ts)
normalises each into the standard { rows, fields, rowCount } envelope so the existing
ResultsGrid renders them unchanged. See the reply table below.
By default ioredis parses an integer reply into a JS number, which rounds one past 2^53 with no
error. Measured on redis 8.10.2 through ioredis 5.11.1 on 2026-10-04: INCR on a key holding
9223372036854775806 was shown as (integer) 9223372036854778000, and INCRBY 0 on
9007199254740993 as (integer) 9007199254740992, in the grid, the API and every export. The same
held on every relative in §1, since the rounding is the
driver's.
The connection therefore sets ioredis's stringNumbers: true, which hands every integer reply
over as its digits. Two consequences follow, and both are handled in
redis.ts:
- A command typed in the editor is sent with
callBuffer, notcall. UnderstringNumbers,calldecodes an integer reply and a string reply to the same text, so(integer)could no longer be told apart from a value that happens to be digits.callBufferleaves a bulk or status string as a Buffer and an integer as its digit string, anddecodeReply()decodes the first from UTF-8, ascalldoes, and turns the second into a number when it is exact as one and a bigint when it is not. An integer nested in an array reply is shown as JSON with the unsafe ones as their quoted digits ([2,"9223372036854775807","x"]); an integer in the safe range stays a number. - The counts the provider reads for itself arrive as digit strings too.
DBSIZEanswers"42"rather than42, so the overview's key count and the key-space page'stotalgo throughintegerReply(), which reads one back as a number (and accepts a JS number too). A reply that is not a count is refused in both places rather than shown as 0 keys, a number nobody measured.SLOWLOG GETwas already read throughString()andNumber().
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 |
user |
— | The Redis 6 ACL user, sent as ioredis's username; empty means default (§4.1a) |
password |
— | Sent as password; omit for unauthenticated instances |
database |
— | Logical DB index, a run of digits; defaults to 0 when empty. Anything else is refused at connect with A Redis database is a number, received "<value>" rather than read as 0 |
ssl |
— | SSLConfig; becomes the ioredis tls option (§4.3) |
const connection = {
id: 'redis-1',
name: 'Cache',
type: 'redis',
host: 'localhost',
port: 6379,
user: 'analytics', // optional — the ACL user; omit to authenticate as `default`
password: 'secret', // optional
database: '0', // logical DB index
createdAt: new Date(),
};The modal's Username field is the Redis 6 ACL user. connect() passes it as ioredis's
username (the field is user on the connection, username in RedisOptions), and passes
nothing at all when it is empty — a plain requirepass server has no ACL user to name, and
ioredis only authenticates as default when username is absent. A pasted
redis://analytics:pw@host:6379/0 fills the same field: parseGenericURL
(src/lib/connection-string-parser.ts) decomposes the
userinfo into user / password (§4.2).
A connection saved before the field was writable keeps authenticating as default, and there is
nothing to migrate. connectionFields in src/lib/db/connection-form.ts
decides what a save WRITES, and it omitted user for redis until the settlement round of
2026-08-27 — so the ACL user a person typed was discarded between the box and the driver, and the
value simply was never captured. No migration can restore a credential that was never stored: an
existing connection authenticates as default until someone opens it and types a user name, which
now persists. What the same change also stopped is an EDIT losing one: user is form-owned rather
than preserved (FIELD_OWNERSHIP in src/hooks/use-connection-form.ts),
so a connection that had a user from a pasted URL had it loaded into the box, shown, and then
dropped on save. It is written now.
Measured 2026-08-26 against redis:latest, with default left on nopass ~* &* +@all and a second
user defined on >probepw ~* +@all -info, both arms:
{host, port, password} -> ACL WHOAMI = default | INFO succeeds
{host, port, username, password} -> ACL WHOAMI = probe | INFO refused: NOPERM
The first arm is the defect this replaced: the configured principal was dropped silently, the
session ran as default, and health went green under someone else's permissions. Both arms together
are what make it a measurement — the value was collected by the form and stored on the connection
already; only connect() ignored it.
A restricted user costs the INFO-derived surfaces. ioredis's own ready check calls INFO, and on
NOPERM it logs "Skipping the ready check" and connects anyway — which is the wanted behaviour: a
least-privilege user must still be able to connect and browse keys. But getHealth(),
getOverview() and getPerformanceMetrics() all read INFO (§7), so for a
user without +info they raise the server's own NOPERM sentence rather than answering with
fabricated zeros. POST /api/db/test-connection reports that as a degraded (amber) result, not a
green tick and not a failure: the connection is real, the health read is not. Grant +info,
or expect the monitoring panels to stay empty for that user.
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) / user / 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. A userinfo
name becomes the ACL user (§4.1a).
Because the raw string is dropped, the scheme's TLS intent has to travel as a field: the parser
returns sslMode: 'require' for rediss:// and sslMode: 'disable' for redis://, which the
connection form applies to the SSL panel (§4.3). Both arms are explicit on purpose -
a paste overwrites the form rather than merging into it, so pasting a plaintext URL clears a
require left over from a previous edit.
buildTLSOptions() (redis.ts) maps
connection.ssl onto the single tls option ioredis hands to tls.connect, so the material travels
under Node's own names — the same mapping the PostgreSQL, MySQL and Couchbase adapters use:
ssl.mode |
tls option |
|---|---|
absent / disable |
not present at all — ioredis negotiates TLS whenever tls is set, {} included |
require |
{ rejectUnauthorized: false } |
verify-system |
{ rejectUnauthorized: true }, with no ca — the runtime's own trust store |
verify-ca / verify-full |
{ rejectUnauthorized: true } |
caCert / clientCert / clientKey become ca / cert / key when set, each independently — a
server can demand mutual TLS while presenting a self-signed certificate itself. An explicit
ssl.rejectUnauthorized always wins over the mode. require does not check the chain because a
self-hosted Redis presents a self-signed certificate by default; every other mode does check it, and
verify-system (D26) checks it against the trust store the runtime already has, so a managed Redis
whose certificate a public root signs needs no PEM pasted at all. ioredis exposes no separate
host-name check, so verify-ca and verify-full build the same object.
Measured against a TLS-only server on 2026-08-23 (redis:latest --port 0 --tls-port 6380, so no
plaintext port exists): disable is refused with "Connection is closed." and require connects in
1ms. Both arms matter — before the mode reached the driver, require failed the same way disable
does, so the pair is what distinguishes a wired path from a documented shape.
A pasted
rediss://URL arrives withmode: 'require'and aredis://one withdisable(§4.2), so the scheme picks the mode and the panel is only needed to go further thanrequire- a verifying mode, or certificate material.requirerather thanverify-system/verify-fullbecause that is what the ordinary--tls-portdeployment can satisfy: a paste encrypts, and never silently claims to have checked a chain. That is a claim about the SCHEME, and it is not the rule for a boolean TLS parameter (D26, see postgres.md):rediss://says only "TLS", while?ssl=truesays what a specific driver does with it. The scheme mapping is unchanged, and whether it should follow the same rule is an open question on the backlog rather than a settled one.Measured through the parser and the provider together on 2026-08-23, against
redis:latest --port 0 --tls-port 6390with a self-signed certificate:rediss://localhost:6390 -> sslMode require | connected, PING = [{"result":"PONG"}] | 14ms redis://localhost:6390 -> sslMode disable | FAILED: Failed to connect to Redis: Connection is closed.
ioredis rejects connect() with the same "Connection is closed." whatever went wrong, and emits
the reason as an error event. openClient() attaches an error listener before it connects and
keeps the FIRST event as the reason, so connect(), Test Connection and every per-database object
read raise a typed error that names it (connectFailure() in
redis.ts). Before the listener existed, every case
below showed only "Connection is closed." (HTTP 503, retryable: true even for a bad password),
and the reason reached nothing but the server log, as "[ioredis] Unhandled error event".
Measured 2026-10-04 through ioredis 5.11.1 against redis:latest (8.10.2) and
valkey/valkey:latest (9.1.2), through Test Connection on a production build (Valkey answers the
wrong-password row word for word). The Redis 5 row (redis:5, 5.0.14) and the timeout row were
measured on the driver directly:
| Case | Raised as | Message after "Failed to connect to Redis: " |
|---|---|---|
| Wrong password, or an ACL user that does not exist | AuthenticationError (401) |
WRONGPASS invalid username-password pair or user is disabled. |
No password against requirepass |
AuthenticationError (401) |
NOAUTH Authentication required. |
Wrong password on Redis 5 (5.0.14, requirepass, no ACL) |
AuthenticationError (401) |
ERR invalid password |
The connect never answers (connectTimeout) |
ConnectionError (503) |
connect ETIMEDOUT |
| Nothing listening on the port | ConnectionError (503) |
connect ECONNREFUSED 127.0.0.1:40009 |
| Host name that does not resolve | ConnectionError (503) |
getaddrinfo ENOTFOUND nonexistent.invalid |
| Verifying mode, self-signed server, no CA pasted | ConnectionError (503) |
self-signed certificate in certificate chain |
| Plaintext client on a TLS-only port | ConnectionError (503) |
Connection is closed. (the server closes the socket and ioredis emits no event) |
Only the message is carried, never the driver's error object: a ReplyError to AUTH holds the
command it answered, password included, on its command field (ioredis 5.11.1 DataHandler). A
credential is told apart by how the server's reply begins (WRONGPASS, NOAUTH, or Redis 5's
ERR invalid password, whose first word is the generic ERR); a password sent to a server that
requires none is not refused at all (ioredis warns and connects). Any other
reply during the connect, such as the LOADING a server answers while it reads its dataset, stays a
ConnectionError, which the client may retry. With the CA pasted, the TLS server of the table
connects (verify-full, host localhost).
The listener stays attached after the connect settles, and from then on logs each event as a
warning, [Redis] <message> with provider: "redis", so a dropped socket and the reconnects after
it stay visible in the server log. The message only, for the same reason as above.
The scheme shown for Redis is redis://, and the parser also accepts rediss://.
Its row in CONNECTION_STRING is declared in redis-connection-string.ts.
It has no readTls: the scheme itself is the TLS, so rediss:// sets require and redis:// sets disable.
It runs no seed load check.
An open Redis transaction is a MULTI, and the keyword that keeps it is EXEC, so the rolled-back notice names EXEC where every other engine's names COMMIT.
D74 gave the single-statement route the same finally as the script route, and redis implements the surface, so the notice reaches a reader whose open transaction is a MULTI.
Naming COMMIT would tell them to type a command their engine does not have.
Its row in EXECUTION_UI is declared in redis-execution-ui.ts.
The agent's consent card has no consent deadline chip or sentence for it.
# 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"] }
# Comments: blank lines and lines starting with '#' are skipped (see 3.4a)
# Read every field of the hash
HGETALL user:1
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; past 2^53 the server's exact digits, (integer) 9223372036854775807 (§3.5a) |
nil |
result |
(nil) (rowCount 0) |
| Empty array | result |
(empty list) (rowCount 0) |
Array (KEYS, SMEMBERS, LRANGE, ZRANGE without WITHSCORES) |
index, value |
1 | user:1 |
Hash (HGETALL) |
field, value |
email | a@b.com |
CONFIG GET |
parameter, value |
databases | 16 |
Sorted set with WITHSCORES (ZRANGE, ZREVRANGE, ZRANGEBYSCORE, ZREVRANGEBYSCORE) |
member, score |
alice | 2500 |
Stream (XRANGE, XREVRANGE) |
id plus every field the page used |
1700000000000-0 | signup | alice |
SCAN, SSCAN |
cursor, key / member |
0 | user:1 |
HSCAN |
cursor, field, value |
0 | email | a@b.com |
HSCAN ... NOVALUES |
cursor, field |
0 | email |
ZSCAN |
cursor, member, score |
0 | alice | 2500 |
ZSCAN ... NOSCORES |
cursor, member |
0 | alice |
INFO |
section, key, value |
Server | redis_version | 7.2.4 |
INFO is special-cased: parseInfoResult() (redis.ts)
splits the bulk reply into one row per metric, tagging each with its # Section header.
The pair-shaped and cursor replies are read BY THE COMMAND that asked for them, never by
inspecting the values, because the same array shape means different things (#1454). Three
consequences are worth knowing when reading a grid:
- The option decides the shape, not the command name.
ZRANGE board 0 -1answers a plain member list and keepsindex/value; only the run that passedWITHSCORESgetsmember/score.HSCAN ... NOVALUESandZSCAN ... NOSCORESeach drop the second half of their pairs, so those runs get one element per row oncursor/fieldandcursor/member. - A stream's header is the union of its page's fields, in first-seen order. Redis does not
require the entries of a stream to share fields, so an entry missing one gets an empty cell
rather than the column being dropped for the entries that do have it. The
idcolumn is always first, and a stream field also calledidis shown beside it asid (field), so it cannot overwrite the value that addresses the row. - A cursor reply keeps the cursor on every row, and an empty page still answers one row.
SCANreturns[cursor, keys]; read as a flat array, the cursor became a data row and the keys one JSON string. A page with a non-zero cursor and no keys is a real reply, and the cursor column is the only place the continuation is readable, so that page renders one row carrying the cursor and(empty list)(rowCount0), the same convention an empty array uses.
A bare MULTI sent through query() opens a transaction on the ONE connection this provider holds
(§3.6), and the provider is cached per connection.id for the whole process, so nothing in the
request cycle closes it and it belongs to whoever borrows the handle next.
Measured 2026-09-15 on redis 7.4.11 through ioredis 5.11.1: after a bare MULTI, every later
command on that connection answers the string QUEUED and does nothing, SET, GET and even
CLIENT INFO alike, while a second connection is untouched.
So the next user's command does not fail, it silently does not happen, and the schema explorer's
SCAN is queued with it.
endOpenQueryTransaction() ends it and reports "none" or "rolled-back".
WHICH CALLERS END IT, AND WHY THAT CHANGED. The surface now has TWO callers in the product,
POST /api/db/multi-query and POST /api/db/query, each awaiting it in a finally under a call
scope of its own. The editor never sends a Redis buffer to the first of those.
use-query-execution.ts gates the multi-statement route on dialectIsSql, read from
queryLanguage, and this provider declares json (§9), so an editor run goes to
POST /api/db/query (§12.2).
When this section was first written that was the end of the story, because the single-statement
route ended nothing: it could not, since the ender named one shared client rather than the caller's
own session, and copying the other route's finally there was measured to destroy other callers'
committed work. D87 closed that by binding the ender to a call scope the route mints, and D74 then
gave this route the finally it had been refused. So a MULTI typed into the editor IS discarded
when the response is sent, and the schema explorer's next SCAN is no longer queued behind it.
The ask and the act are TWO commands, and the order is what makes the method safe to call. The
substitution is the reading: inside a MULTI the server answers the status QUEUED instead of the
command's own reply, so PING answers PONG when nothing is open and QUEUED when something is,
and endOpenQueryTransaction() sends DISCARD only after it has seen QUEUED. ioredis itself
publishes no transaction state for a MULTI sent through call(): status stays ready, and the
queueing that Redis.prototype.multi() tracks lives on a pipeline object a raw command never
touches. The server is the only thing that can be asked, and this is how it is asked.
Asking first is not a refinement. Measured on redis 7.4.11 with an ordinary read-only ACL,
ACL SETUSER ro on >pw ~* +@read +ping +info: PING answers PONG and DISCARD is refused with
NOPERM User ro has no permissions to run the 'discard' command. Since the caller awaits this in a
finally, a blind DISCARD made every multi-query request on such a connection answer an error and
threw away the per-statement results it had already earned; with the ask first, no DISCARD is sent
and the answer is "none". That same ACL cannot run MULTI either, so there is never one to end.
The narrower ACL that CAN open a MULTI but not discard it, +@read +ping +multi +set, measured
on the same server, still raises, and must: PING answers QUEUED, the DISCARD is refused, the
next command is still QUEUED, so the transaction is open and this provider cannot end it. The one
DISCARD failure that is read rather than raised is the server's own ERR DISCARD without MULTI
AFTER the reading said QUEUED, which on this shared connection means another caller ended it in
between; the queue is gone either way, which is what "rolled-back" reports.
DISCARD and not EXEC: a script that queued commands and never said EXEC did not ask for them
to run, so the queue is dropped rather than executed on an authority nobody gave.
WHAT IS STILL OPEN: the ender cannot tell whose MULTI it is ending.
endOpenQueryTransaction() takes the caller's call scope on the interface and ignores it here,
because this provider holds one connection and there is no other client to name.
That is true and it is not the same as the transaction being the caller's own: a MULTI is state
of the CONNECTION, and one connection serves every concurrent request on this stored connection.
So POST /api/db/query, which now ends what it opened in a finally (above), PINGs and on QUEUED
DISCARDs whatever MULTI is open there, whoever opened it.
A plain GET typed by one user drops a MULTI another user had just queued commands into, and that
user is told nothing: their next command answers QUEUED from no transaction.
The one DISCARD failure this code reads rather than raises is the same collision seen from the
other side.
This is the D87 shape on a single connection and it is NOT closed.
Closing it means the MULTI owned by a call scope rather than by the connection, which is a design
change: the provider would have to record which scope opened the MULTI it observes, and a MULTI
opened before any scope was recorded still has no owner.
The same argument applies to sqlite and duckdb, which likewise hold one handle for every
concurrent request and ignore the scope for the same reason.
query() refuses three kinds of command before they reach the server.
The reason is the one §5.2a gives for MULTI: the provider is cached per connection for the whole process and runs every statement on ONE client (§3.6), so whatever a statement leaves on that client is what every later request on the connection gets, whoever sends it.
Measured on redis 8.10.2 through ioredis 5.11.1, on a connection configured for database 2: the first table as an ACL user with +@read on 2026-09-25, the rest as default on 2026-09-28.
Commands that change what later requests read, or who they run as.
The refusal reads <COMMAND> is not run here: it would change the shared connection for every later request., and for SELECT it adds where to pick a database instead.
| Command | What it did to the shared client |
|---|---|
SELECT 0 |
Every later GET read database 0 instead of the configured database |
MULTI / SELECT 0 / EXEC |
The same, which is why the refusal is by command name and not by reply |
RESET |
Moved the client to database 0 AND logged it back in as default: the read-only user could SET |
AUTH default <anything> |
The same against a stock default nopass server |
HELLO 3 |
Switched the reply protocol; ioredis then failed with Protocol error, got "%" |
Commands after which the connection stops answering.
The refusal reads <COMMAND> is not run here: the shared connection would stop answering later requests.
| Command | What it did to the shared client |
|---|---|
SUBSCRIBE, PSUBSCRIBE, SSUBSCRIBE |
Never answered; ioredis threw an uncaught TypeError, and every later command failed Connection in subscriber mode. Through POST /api/db/query, one SUBSCRIBE from a user session failed the connection for every user, admin included, until the idle sweep evicted the provider |
QUIT |
Closed the socket for good: every later command failed Connection is closed., while isConnected() still said true, so the provider cache kept serving it |
MONITOR |
Turned the connection into a feed of every command the server runs, which ioredis read as replies to later commands |
CLIENT REPLY OFF, CLIENT REPLY SKIP |
Stopped the server replying, so every later command waited for an answer that never came |
Commands that hold the connection until data or a timeout arrives.
The refusal reads <COMMAND> is not run here: every other request on the shared connection would wait until it returns.
They are BLPOP, BRPOP, BRPOPLPUSH, BLMOVE, BLMPOP, BZPOPMIN, BZPOPMAX, BZMPOP, WAIT, WAITAOF, and XREAD / XREADGROUP with BLOCK.
With a timeout of 0 each one never returned, and neither did any later command.
A GET sent while BLPOP queue 3 waited answered after 3 seconds, so the refusal does not depend on the timeout.
Only a BLOCK before STREAMS is the option, and GROUP's two names are skipped, so a stream key or a group named BLOCK still runs.
The non-blocking forms, such as LPOP, LMOVE, ZPOPMIN and XREAD without BLOCK, run as before.
Every command word is compared up to its first NUL.
The server can match a name that way: it reuses the previous command's lookup when the next name matches it as a C string.
On a raw socket SELECT\0 0 answered OK right after SELECT 2 and unknown command right after PING.
The provider sends SELECT <db> on connect, so a byte-exact check let the first statement on a fresh provider, SELECT\0 0, move it to database 0 through POST /api/db/query.
Measured, and still run.
SWAPDB, MOVE and a script's redis.call('SELECT', n) leave the client's database and user where they were.
UNSUBSCRIBE, PUNSUBSCRIBE and SUNSUBSCRIBE outside subscriber mode answer 0 and change nothing.
CLIENT SETNAME, SETINFO, NO-EVICT, NO-TOUCH and TRACKING set attributes and flags of the connection, not its database, user or replies.
SYNC and PSYNC ended in an ioredis protocol error, and the next request was answered normally in the configured database.
To read another database, pick it in the Keys panel (§6.2): a key opened from there runs under the
per-run database field of POST /api/db/query, on a provider of its own, and the shared one is not
touched. To change the database a connection reads by default, change its Database field (§4.1).
Right-clicking a node in the schema tree (or its ⋮ menu) offers commands generated for that node,
so you do not have to type them from memory. The generation is driven by the queryDialect: 'redis'
capability, which routes the shared client-side query generators
(src/lib/query-generators.ts) to Redis command output.
Before #427 Redis declared no dialect, so those generators fell through to their MongoDB branch on
the strength of queryLanguage: 'json' alone and every action emitted a
{"collection": "user:*", "operation": "find", …} document that this provider answered with
Command is required in JSON format. The dialect is checked before queryLanguage everywhere
for that reason.
Both generators are type-aware: they read the sampled Redis type off the synthetic type
column that the object surface builds (§6). A prefix that sampled a single type resolves; one that
sampled several (string, hash) or none does not, and falls into the unknown bucket.
Scan Keys (generateTableQuery) inserts one runnable command and executes it immediately:
| Node | Sampled type | Command |
|---|---|---|
prefix group user:* |
any | SCAN 0 MATCH user:* COUNT 50 |
bare key counter |
string |
GET counter |
bare key session |
hash |
HGETALL session |
bare key queue |
list |
LRANGE queue 0 -1 |
bare key tags |
set |
SMEMBERS tags |
bare key board |
zset |
ZRANGE board 0 -1 WITHSCORES |
bare key events |
stream |
XRANGE events - + COUNT 100 |
bare key doc |
ReJSON-RL |
JSON.GET doc |
| bare key | unknown or mixed | TYPE <key> |
A prefix group always SCANs and is never used as a key argument: it is a derived grouping
(tablesAreDerivedGroupings), so GET user:* would read a key literally named user:*. TYPE is
the unknown-bucket answer rather than a guessed reader, because a wrong reader (GET on a hash)
returns a WRONGTYPE error instead of an answer.
A key opened from the Keys panel is a key whatever its name ends in.
A string key named cache:* is spelled like the prefix group the tree draws for cache: keys, so the panel tells the generator that the path names a key (GeneratorScope.namesKey), and the key opens on GET cache:* while the tree's cache:* row still scans.
Generate Command (generateSelectQuery) inserts a cheatsheet instead — a use-case comment above
each command, where every command line is runnable on its own via "Run Selected". For a
user:* group whose keys sampled as hash:
# Redis commands for "user:*" — select a line and Run Selected.
# List keys under this prefix — ONE scan iteration, not the whole set.
# 0 is the start cursor; the reply's cursor column holds the next one. Re-run
# with that value in place of 0 until it comes back 0 (a page may be empty).
SCAN 0 MATCH user:* COUNT 50
# Check the key's type
TYPE user:1
# Read every field of the hash
HGETALL user:1
# Create or update one field — this overwrites an existing field
HSET user:1 field example
# Time to live in seconds (-1 no expiry, -2 no such key)
TTL user:1
# Delete the key (DEL takes a literal key name, never a pattern)
DEL user:1
Shape rules:
- The
SCANblock appears only for a prefix group. A bare key starts atTYPE. - The example key for a prefix group is the prefix plus
1(user:→user:1), so every line is concrete rather than a<placeholder>— the same rule the LibreDB cheatsheet follows. - The read/write pair appears only when the type resolved. An unknown or mixed group gets the
TYPE/TTL/DELframe alone. The pairs areGET/SET,HGETALL/HSET,LRANGE/RPUSH,SMEMBERS/SADD,ZRANGE/ZADD,XRANGE/XADD,JSON.GET/JSON.SET. DELis given a literal key, never the group pattern: Redis key arguments are byte strings, soDEL user:*deletes a key nameduser:*or nothing at all.- Glob metacharacters are escaped in the
MATCHhalf of aSCANonly (a keya[b:1groups toa[b:*, whose unescaped[would open a character class), never in a key argument, where escaping would corrupt a literal key that genuinely contains*. - An argument containing whitespace is double-quoted for the tokenizer of §3.4. An argument that the
tokenizer cannot round-trip — one containing
",', a backslash or a newline — makes that line alone switch to the lossless JSON command form ({"command":"DEL","args":["say\"hi\""]}). The two forms mix freely inside one cheatsheet: the provider decides per run, and every line is run on its own. PlainDEL "say"hi""would reach the driver as the keysayhi— a different key. - The node name in the header comment is JSON-quoted, not interpolated raw. A key name is
arbitrary bytes: a name containing a newline used to end the comment and make its own remainder
the buffer's first runnable line, so a key called
a⏎DEL user:1 xproduced a cheatsheet whose first command wasDEL user:1. The per-argument defence above never engaged, because the injection travelled through a comment rather than through a command. The LibreDB cheatsheet header is quoted the same way. For an ordinary name the rendering is unchanged (#427). SCAN 0 MATCH <prefix>* COUNT 50is ONE cursor iteration, not a listing.0is the start cursor and the reply'scursorcolumn holds the next one; re-run with that value in place of0until it comes back0. On a large keyspace an iteration can legitimately return a non-zero cursor and no keys: the reply then renders as one row carrying that cursor and(empty list)(§5.2), so "Scan Keys" may show a cursor and nothing else while the schema tree reports the prefix has keys. The tree's count comes from the object surface, which loops the cursor over up to 1000 keys (§6) rather than stopping at one page. A one-line command cannot loop, so the cheatsheet documents the continuation instead of hiding it.
A Redis tab is typed redis and rendered by a dedicated Monaco language
(src/lib/editor/redis-language.ts) — command verbs as
keywords, argument words such as MATCH / COUNT / WITHSCORES as functions, # line comments.
Before #427 a Redis tab was typed mongodb and highlighted as JSON, which flagged every command as
a syntax error.
Five menu actions are not offered on Redis, all for the same reason: they address the row as an
object, and a user:* row is this server's grouping of a key prefix, not an object any command can
be given.
Profile TableandGenerate Test Dataprofile an object and insert rows into it, and neither is offered on auser:*row rather than left to answer HTTP 400 (#427). Profile is hidden wherevertablesAreDerivedGroupingsis true, and since #1085 by the language gateoffersColumnProfilingas well, because the profile route refuses JSON in a dialect of its own. Generate Test Data is withheld by the row-write rule, which the desktop tree has always asked and the mobile menu asks instead of the flag since #1085 (decision D-M), becausekeyspacedeclares noacceptsRowWritesand the engine declaressupportsTestDataGeneration: false(#1468).Generate Count Queryis withheld byoffersCountQuery, which both row menus and the generator ask (#702). The command grammar has no count statement to write, the JSON carries a dialect of its own, and a derived grouping has nothing to count. The row's badge is absent as well, because the object surface reports norowCountfor a prefix.- Redis offers no per-row maintenance action at all — neither "Key Info" nor "Memory
Doctor". Both items call
onOpenMaintenance("tables", <row>), which opens the admin Operations tab against a named table; there is no such table here, so the item was a dead end even for theanalyzethis provider does declare (#427). Global maintenance is unaffected and still runs from the Operations page (§8).
Generate Code stays — it names the row, it does not address it, and it sanitises the name into an
identifier that is legal in every target language (user:* → User), keeping Unicode letters
intact so a non-ASCII key prefix does not collide with another one.
Browse Keys is offered on the same rows, and it is the only menu item on this engine that opens a
SURFACE rather than a statement: it switches the sidebar to the keys panel with the row's own name as
the MATCH pattern (§6.2). It is gated on keyScan beside the
tablesAreDerivedGroupings the other items above read — a panel to show it in, and rows that are
prefixes rather than objects — so it is offered here and nowhere else: a catalog-backed engine's
orders row is an object, not a glob.
How this engine's statement text is read is declared once, in src/lib/db/providers/keyvalue/redis-sql-grammar.ts, the redis row of the SQL grammar registry in src/lib/db/sql-grammar/.
readsSqlText is false: Redis takes a command line, which never reaches the SQL readers on the query path, since the provider extends BaseDatabaseProvider and never calls prepareQuery; it reaches the confirmation gate because both execution paths ask about whatever is in the editor before running it (#297).
The confirmation gate therefore answers from this engine's row of NON_SQL_DESTRUCTIVE_VOCABULARY in src/lib/db/destructive-commands.ts instead of a SQL reading.
Its grammar is DEFAULT_SQL_GRAMMAR, the compatibility default, since no SQL grammar of its own applies to this text.
Redaction is command, read by the module's own reader over the one command readRedisCommandText() reads for the provider: a redacted statement keeps that command's name, upper-cased as the provider sends it, and one ? for every argument, keys and values alike.
A text that sends nothing (blank, only comments, a second command or an unclosed quote) keeps nothing (?), and so does a JSON command the provider would refuse.
the object surface (redis.ts) returns one
object 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 DISTINCT sampled types: TYPE key → add to prefix.types
(one blocking round-trip per key until the 3rd distinct type — a
uniform prefix pays it for every key the scan cap allows)
until cursor == "0" OR totalScanned >= 1000
3. emit one object per prefix, sorted by rowCount desc
Each synthetic object 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).
the object surface above answers one flat key-prefix list. The object surface answers a lazy, container-aware, kind-tagged tree through four methods, and on this engine they live in the provider class, because there is no statement layer to split out: the catalog here is a command.
Everything below was measured on Redis 8.10.0 against the fixture in
docker/redis-init/ (see §11.5),
plus the four Redis-wire relatives named in §1.
One container level, and there is no second one to add above or below it. A Redis server holds a
fixed number of NUMBERED databases and nothing else: there is no catalog above them, and a key is not
a container. The level's structural id is schema, which is what ContainerLevelSpec calls the
innermost level on every engine; the label is the engine's own word, which is Database.
How many there are is not the constant 16. listContainers() asks the server, because the answer
is a property of the deployment:
$ redis-cli CONFIG GET databases # stock server
databases
16
$ redis-cli CONFIG GET databases # same image, --cluster-enabled yes
databases
1
$ redis-cli SELECT 3 # on that cluster-mode server
ERR SELECT is not allowed in cluster mode
So the reply already reflects the deployment and nothing reads cluster_enabled to work it out. A
refused or unparsable reply raises: 16 would be a number nobody measured, and 1 would hide
fifteen databases that may hold keys. Every database is listed, including the empty ones, because a
Redis database is never created and never dropped — all of them exist at all times, so listing only
the ones INFO keyspace mentions would hide a database a person is about to write to.
| Kind | Role | Catalog |
|---|---|---|
keyspace |
relation |
one bounded SCAN walk of the database, collapsed per key prefix |
function |
routine |
FUNCTION LIST |
function is a real, named, stored object: a Redis 7.0 function library is persisted, replicated,
listed by FUNCTION LIST and addressed by its library_name. It declares hasSource and it is the
only thing in this engine that can — FUNCTION LIST WITHCODE answers the library's Lua source
verbatim, shebang line included, which is Phase 2's to render.
Three candidates are absent rather than declared and zero.
- An
EVALscript is not enumerable. Redis publishesSCRIPT EXISTS <sha>, which answers about a sha the caller already has, and there is noSCRIPT LIST. A tree node is something that can be listed, so a kind here would draw a folder that could never fill. - A keyspace notification is pub/sub, not a stored trigger: nothing is persisted and nothing has a name.
- The separately named "Triggers and Functions" feature is RedisGears-based, ships only in Redis Stack and Enterprise, and is on Redis's own deprecated list. Declaring it would draw a folder on every plain server that has no such concept at all.
Three of the four Redis-wire relatives refuse FUNCTION LIST outright, each in its own words (all
measured 2026-09-11):
| Server | INFO version |
FUNCTION LIST |
|---|---|---|
| Redis 8.10.0 | redis_version:8.10.0 |
Supported |
| Valkey 9.1.1 | valkey_version:9.1.1, redis_version:7.2.4 |
Supported |
| KeyDB 6.3.4 | redis_version:6.3.4 |
ERR unknown command `FUNCTION`, with args beginning with: `LIST`, |
| DragonflyDB df-v1.40.1 | redis_version:7.4.0 |
ERR Unknown subcommand or wrong number of arguments for 'LIST'. Try FUNCTION HELP. |
| Garnet 2.1.5 | garnet_version:2.1.5, redis_version:7.4.3 |
ERR unknown command |
A version-driven declaration, the shape mysql.ts uses, would be wrong here rather than merely
awkward: DragonflyDB reports redis_version:7.4.0 and still has no FUNCTION LIST, so the version
cannot answer the question. countObjects() therefore carries the server's own sentence under
{ unavailable }, which is the state KindCount has for a refused read, and the folder says why it
has no number instead of showing a zero nobody measured. Per KIND and not per read: a relative with
no FUNCTION command still has a keyspace, so its function folder carries the refusal while the
keyspace folder carries a real number. Measured live against KeyDB 6.3.4 holding this fixture:
countObjects(["0"]) -> {"keyspace":{"count":4},
"function":{"unavailable":"ERR unknown command `FUNCTION`, with args beginning with: `LIST`, "}}
Standing ruling 5f says the badge and the folder must read the same predicate over the same source.
On a SQL engine that is a warning about two WHERE clauses. Here the catalog is a command, so the
seam is a method: listIn is the only thing in the provider that reads either catalog, and both
countObjects() and listObjects() call it. The count of a kind is the LENGTH of the listing that kind's
own enumerator returned — there is no second SCAN with a different MATCH for the two to drift
apart in.
KindCount carries four facts, and this engine answers two of them in one record. function is
counted from FUNCTION LIST, which enumerates the whole server, so it is a population. keyspace is
counted from the bounded SCAN walk: the loop stops when the cursor comes back to 0, which means
the server walked everything it holds, or when 1000 keys have been seen, which means it did not.
Only the second case is marked, with the fourth state:
countObjects(["0"]) -> {"keyspace":{"count":3,"sampledFrom":"the first 1,000 keys of one SCAN walk"},
"function":{"count":1}}
The tree badges that folder 3+ and titles it "At least 3: counted from the first 1,000 keys of
one SCAN walk", while the exact count beside it stays 1 with no title
(flatten.ts). A completed walk is NOT marked: 2 and
2+ are different claims, and marking a number this provider measured exactly would teach a reader
to discount every badge. The same bound already governs the object surface, so the flat list and the tree
are consistent about which walk they read; what is new is that the tree can now say what the number
is.
tablesAreDerivedGroupings: true (§9) is a refusal about the keyspace
rows: user:* is a prefix this server derived from a bounded scan, not an object anybody named, and
no command can be given that row. The flat row menu read that flag directly. The object model's menu
is driven by the KIND, so the refusal needed somewhere to live, and it splits into three:
- Row writes.
keyspacedeclares noacceptsRowWrites, so Generate Test Data and the insert action are withheld by the kind, with no engine-wide flag involved. - Maintenance. Redis declares its one maintenance operation as
perEntity: false(§8), somaintenanceControlwithholds the per-row links by the same declaration that already governed them. - Profile has no kind-level declaration behind it, and it is the one that needed a decision.
Profiling needs an ADDRESSABLE object to compute per-column statistics over, while every other
relation action here needs only a pattern. So
rowActionsreads the same engine-widetablesAreDerivedGroupingsflag the flat menu read, androw-actions.tssays so at the top of the file.
Generate Query is NOT withheld, and that is deliberate rather than an oversight. The generator
answers SCAN 0 MATCH user:* COUNT 50 for a prefix group, a runnable command against exactly the
keys the row summarises (§5.3), and the row click that opens data
runs the same thing. The flat menu did not withhold it either.
A keyspace object's last path segment is the PATTERN (user:*), which is unique within its
database because it is a map key of the scan walk. A function object's last segment is the
library_name, which Redis enforces as unique per server: a second FUNCTION LOAD of the same name
is refused unless REPLACE is given, and FUNCTION LIST LIBRARYNAME <name> addresses exactly one.
A function library is SERVER-scoped while its folder is PER DATABASE, so the same library is listed
under every database. On a stock server answering CONFIG GET databases with 16, one
libredb_probe appears in all sixteen Function Libraries folders, at sixteen different paths, and
countObjects for a database holding no keys at all still answers a function count of 1. Measured
on Redis 8.10.1: FUNCTION LIST takes no database argument and answers identically after SELECT 0
and after SELECT 7, where DBSIZE is 0. This is deliberate. The engine declares one container
level, the numbered database, so there is no server level to hang the folder on, and showing the
libraries under one chosen database would invent a home the engine does not have while making the
other fifteen lie about what the server holds.
A key grouping describes to the same three columns the object surface emits, from one shared helper, so the flat model and the object model cannot describe the same grouping differently while both surfaces are live. A grouping the CURRENT scan no longer holds raises rather than answering an empty shape: on this engine a prefix disappears the moment its last key is deleted.
A function library answers three empty arrays with no round trip. That is a true fact about the kind rather than a failed read — a library has no columns, no indexes and no foreign keys.
keyspace is the only kind here that declares hasColumns, so it is the only object row the
desktop object tree gives a twisty to; function declares nothing, describeObject() answers it
columns: [], and it stays a leaf. A keyspace row's three columns are SYNTHETIC, derived from the
types sampled by the scan walk rather than read from a catalog, and they are declared because that
grouping is what this provider models a key pattern as.
Expanding a key pattern re-walks the keyspace, and above the scan bound it can fail.
describeObject() does not reuse the walk the listing ran: it calls scanKeyGroups() itself, which
is its own bounded SCAN capped at KEY_SCAN_LIMIT (1,000 keys), and raises when the grouping the
listing produced is absent from that second walk. SCAN guarantees no order, so on a database
holding more than 1,000 keys the two walks are different samples, a listed key pattern can fail to
expand with No key under "session:*" was found in the 1000-key SCAN of database 0, and collapsing
and re-expanding the row may or may not repair it: it is a fresh sample each time. This is a measured
limit of a bounded walk over an unordered keyspace, not a bug with a fix pending here. The refusal
itself is deliberate and is argued two paragraphs down: a grouping ceases to exist when its last key
is deleted, so an empty shape would claim a grouping that is gone.
The two kinds are therefore asymmetric about EXISTENCE, on purpose. describeObject() answers a
valid detail for ANY function name, a library that was never loaded included, because nothing there
reads the catalog; a keyspace whose grouping the current scan no longer holds raises. Existence is
not the same question on the two kinds: a key grouping is derived from a scan and ceases to exist the
moment its last key is deleted, so an empty shape would claim a grouping that is gone, while a
library's detail at this depth is a property of the KIND rather than of the object and is correct
without asking. Paying a FUNCTION LIST round trip only to raise would buy a check Phase 1 never
shows a person. Phase 2's Source tab is where the two must agree, and it needs care: measured on Redis
8.10.1, FUNCTION LIST LIBRARYNAME no_such_library answers an empty array rather than an error,
so the reader has to treat emptiness as absence itself, and the miss then belongs in
describeObject() rather than in the tab.
describeObjects(container, kind, limit?) is the bulk column read (#789): the columns of
every object of one kind in one database, from ONE walk.
One walk for a whole folder. Nothing here sends a statement, so the N+1 the inventory
route removed does not come back as round trips: describeObject() runs a full
scanKeyGroups walk of its own, and a body looping it would walk the keyspace once per
grouping. The suite counts the driver's SCAN calls - one for the folder, against two for
two single reads - because that is the only observable difference between the two, and a
timing comparison over the loopback would pass either way. Measured live on redis:latest at
port 16379 with the committed fixture, 2 ms for one describeObjects(["0"], "keyspace")
against 6 ms for the four describeObject() calls it replaces.
A function library folder answers { details: [] } and sends nothing at all - not even
the FUNCTION LIST the listing needs. A library has no columns, no indexes and no foreign
keys, and its source, the one thing it does have, is Phase 2's through
FUNCTION LIST WITHCODE. That is a true statement about the KIND rather than a refused
read, which is why it is an empty batch and not a throw. A kind this provider declares and
has no command for is a different fact and RAISES, with the same sentence listObjects()
refuses with: "this kind has no columns" and "this file has no reader for this kind" must
not arrive as the same empty answer.
One mapper, shared with the single read. keyspaceDetail() builds both, over
keyGroupColumns(), which is also what the object surface builds its rows from. Every column set
the bulk read answers is byte-identical to describeObject() for the same path, checked for
every grouping in the fixture and re-checked live.
Two bounds, and the answer names whichever bit.
| Bound | Applies to | truncated.limit |
truncated.reason |
|---|---|---|---|
The caller's limit |
keyspace |
the caller's own number | the bulk column read was bounded at N objects by its caller |
| The 1,000-key walk | keyspace |
the number of objects answered | the key walk stopped at the first 1,000 keys of one SCAN walk |
| Both | keyspace |
the caller's own number | the two joined with , and |
The second is a bound this provider did not choose on the call, so it is reported on an
unbounded read as readily as on a bounded one: a cap nobody can see is the defect
truncated exists to prevent, and it is the same fact countObjects() already puts on the
badge through KindCount.sampledFrom. It cannot bite on the function folder, which sends
nothing, so the marking is per KIND here exactly as it is in the count. Measured live
against a database holding 1,200 keys under one prefix: an unbounded read answers one
column set and reports the key walk stopped at the first 1,000 keys of one SCAN walk.
A limit that is not a positive whole number raises rather than being clamped, and the guard runs after the declaration check and after the container check.
The cut is ours, because this engine offers nothing to cut under. Every other engine in
#789 pushes a LIMIT down and re-sorts in code, so a bounded read's MEMBERSHIP is the
server's and its ORDER is ours. There is no such split here. SCAN publishes no order at
all, not even a stable one between two walks of an unchanged keyspace, and its bound is on
KEYS rather than on groupings, so there is no limit + 1 to push down: a walk cannot know
how many groupings it will produce until it has finished. Both the membership and the order
of a bounded read are comparePaths', and this document says so rather than implying an
order the server does not have.
The UTF-8-byte versus UTF-16-code-unit divergence Task 26a-2 measured on five SQL engines has nothing to bite on here for the same reason: there is no server-side sort to disagree with.
readObjectSource(path, kind, limit?) answers ONE kind and the declaration says which. function
declares hasSource and sourceLanguage: "lua"; keyspace declares neither, because a key prefix is
a grouping this server derived from a bounded SCAN and nobody wrote a definition for it. That is the
tablesAreDerivedGroupings refusal carried into the object model rather than left behind with the
flag's old reader. The refusal is read off the declaration and never off the kind id, so a kind this
engine does not declare at all takes the same path and raises with the same sentence.
The command is FUNCTION LIST LIBRARYNAME <name> WITHCODE, sent once. It is server-scoped and takes
no database: measured, one FUNCTION LOAD is visible from every numbered database and SELECT does
not change what it answers.
The path SHAPE is checked here, by the same function and the same sentence describeObject uses.
assertObjectPathShape derives the accepted length and the labels in its message from
declaredLevels, so a path is refused with A Redis "function" path is [database, name], received []
rather than reaching the command. Both methods need it because neither is reached only through the
HTTP route: they are published through @libredb/studio and called by the embedded host seam and by
the conformance helper, and none of those sees the route's own bound. Measured before the check
existed: an empty path made the name undefined and ioredis threw
undefined is not an object (evaluating 'arg.toUpperCase') out of its command encoder, which is this
provider's defect arriving as the driver's.
A kind declaring hasSource and no sourceLanguage RAISES with
Redis declares readable source for the kind "function" and no sourceLanguage to render it with,
before the round trip. There is no fallback to a literal lua: an unregistered or absent Monaco id
degrades to plain text with no throw and nothing observable, so a fallback would hide a deleted
declaration behind a Source tab that had quietly stopped highlighting. The isolated census
(tests/isolated/object-source-declarations.test.ts) pins every declared language, so the only way to
reach this arm is a declaration somebody removed.
| Field | Value | Why |
|---|---|---|
id |
definition |
one part, always: a library has one Lua text |
label |
Definition |
rendered as-is |
language |
the kind's declared sourceLanguage, which is lua |
lua IS a Monaco language id the installed 0.57.0 bundle registers, unlike plsql, tsql and cql |
form |
complete |
the text runs as given: it is what FUNCTION LOAD was handed |
origin |
stored |
the author's own bytes. Measured on Redis 8.10.0: WITHCODE answers the shebang line and the body exactly as they were loaded, with no reformatting |
The selection is BYTE-EQUAL, and that is the whole of the parser's reason to exist. Measured on Redis 8.10.0 against the committed fixture:
$ redis-cli FUNCTION LIST # the dictionary is CASE-SENSITIVE
library_name
libredb_probe
library_name
LIBREDB_PROBE
$ redis-cli FUNCTION LIST LIBRARYNAME libredb_probe # the argument is a CASE-INSENSITIVE glob
library_name
libredb_probe
library_name
LIBREDB_PROBE
One lookup for either name answers BOTH, so a reader taking reply[0] would hand back the other
library's Lua as this object's definition. The entry whose library_name is byte-equal to the last
path segment is the one read, and its library_code is found by walking the key/value pairs rather
than by position, the same rule the listing's parser records: the nested functions value is itself a
list of key/value lists, and RESP3 answers a map where there is no order at all.
An absent library RAISES. Measured on Redis 8.10.0,
FUNCTION LIST LIBRARYNAME no_such_library WITHCODE answers an empty array and not an error, so
emptiness is absence here and a provider that returned a document would invent one. An entry that
matches by name and carries no library_code, or an empty one, takes the same arm: an empty text
would put an empty editor over a definition that was never read.
A refused read is the server's own sentence, unprefixed, carried as the part's unavailable and
never as a text. Measured as the ACL user the fixture creates:
$ redis-cli --user libredb_nofunction --pass nofunction FUNCTION LIST LIBRARYNAME libredb_probe WITHCODE
NOPERM User libredb_nofunction has no permissions to run the 'function|list' command
KeyDB, DragonflyDB and Garnet have no FUNCTION command at all and each refuses in its own words (the
table in §6.1 has them), so this path is reachable on three of the four
Redis-wire relatives this type id serves.
Only the server's own error reply is a refusal. A transport failure RAISES.
Measured against ioredis 5.11.1 and Redis 8.10.0: an ACL denial and an unknown command both reject with
a redis-errors ReplyError, whose name is ReplyError, while a dropped socket rejects with a plain
Error named Error, reading Connection is closed. with the offline queue on and
Stream isn't writeable and enableOfflineQueue options is false with it off.
A read that catches both would show Connection is closed. in the Source pane as this object's own
refusal, with no raise, no retry affordance and nothing in the document telling it apart from a real
NOPERM, so the provider raises a ConnectionError naming the library instead.
A caller's bound cuts one part's text and reports itself through the one sentence every engine
uses, the source read was bounded at <n> characters by its caller. An exact answer is never marked.
The bound counts UTF-16 code units, so a bound landing between the two halves of a surrogate pair drops
the pair rather than emitting a lone surrogate; the mark still names the number the caller asked for.
buildObjectEdit(request) and applyObjectEdit(plan) write an edited definition back. Every claim in
this section was measured on Redis 8.10.0 in a container created for it, holding
the committed fixture applied the way §11.5 says.
ONE kind is editable and the declaration says which. function declares
acceptsSourceEdits: true; keyspace declares nothing, which is the same refusal it already makes
for hasSource and for the same reason: a key prefix is a grouping this server derived from a
bounded SCAN, nobody wrote a definition for it, so there is nothing to edit and no folder that
could offer one. Every readable function part therefore carries edit: { offered: true } and a
keyspace part carries no edit field at all, which is a different fact from an offered: false.
The editable unit is the LIBRARY and never one registered function. Redis publishes no surface
that writes one function of a library, and the Phase 2 declaration already says so: the kind's
objects are libraries and FUNCTION LIST WITHCODE answers a library's whole Lua source.
The strategy is replace-in-place-command, and the previewable unit is a COMMAND. It is the only
day-one producer of that arm of ObjectEditUnit, and the arm exists because of it:
| Field | Value |
|---|---|
medium |
command |
name |
FUNCTION |
arguments |
["LOAD", "REPLACE"] |
payload |
the reader's own bytes, one user segment covering all of them |
Rendering that as pseudo-SQL would be a lie about what runs. There is no splice anywhere: the payload is the submitted text verbatim, so the coordinate arithmetic between the sent text and the reader's text is the identity.
Ruling 1b, both axes, measured. A FAILURE cannot lose the object: four refusal shapes were
measured and after every one of them FCALL libredb_ping 0 still answered pong.
| Submitted | The server's answer |
|---|---|
| a syntax error | ERR Error compiling function: user_function:5: '=' expected near 'local' |
| a body registering nothing | ERR No functions registered |
| no shebang | ERR Missing library metadata |
#!moon |
ERR Engine 'moon' not found |
A SUCCESS can destroy something, which is the second axis: REPLACE replaces the WHOLE LIBRARY. A
body carrying only libredb_ping loaded successfully, answered "libredb_probe", and
FCALL libredb_echo_key then answered ERR Function not found. The sibling was deleted and success
was reported.
THE SHEBANG IS THE IDENTITY, and the build refuses an edited one. FUNCTION LOAD takes no name
argument: the name comes out of the #!lua name=<library> line and the command's reply IS that name.
Three measured consequences follow from one line of text, and only the third is harmless:
- A CONSISTENT rename of the library and its functions SUCCEEDS, answers the NEW name, creates a SECOND library and leaves the original answering. Without the refusal the reader sees a success, the pane re-reads the original address and shows the original text, and their edit has vanished.
- A shebang naming a DIFFERENT library that ALREADY EXISTS replaces THAT library, wholesale. Measured
by doing it: a body whose shebang said
name=LIBREDB_PROBE, submitted while addressed atlibredb_probe, replacedLIBREDB_PROBE(FCALL LIBREDB_UPPER_PINGthen answered the new body's value) and leftlibredb_probeansweringpong. So this check also protects an object the reader was never shown. The two libraries differ only in case, which is the same fixture pair the source read's byte-equal selection needs. - A ONE-CHARACTER typo in the name alone is refused loudly by the engine,
ERR Function libredb_ping already exists, because function names are GLOBAL to the server rather than scoped to their library.
A first line this provider cannot parse as a shebang is refused the same way, with identity. That
is the safe direction: the cost is a false refusal, and measured, a body with no shebang is refused
by the server too. The ENGINE token is deliberately not checked, because an unknown one is refused
loudly and harmlessly and Redis is free to add a second engine.
The library is selected BYTE-EQUAL, for the reason the source read gives: LIBRARYNAME is a
case-INSENSITIVE glob over a case-SENSITIVE dictionary, so one lookup answers both fixture libraries
and a build taking reply[0] would show one library's Lua and plan a write against the other's name.
The revision is a content hash, and it is SOUND on this engine and only on this engine. Measured:
FUNCTION LIST WITHCODE answers the bytes AS LOADED, with no reformatting of any kind, so two reads
of an unchanged library are byte-identical and a SHA-256 over them is a usable token. On an engine
that renders a definition from a parse tree, as Trino's SHOW CREATE FUNCTION does, the same hash
would move on a server upgrade and refuse every edit. The check is compared and not guarded:
the apply re-reads and compares in its OWN round trip, which NARROWS the window and does not close
it, because Redis has no transaction spanning the read and the write. basis is
FUNCTION LIST LIBRARYNAME <name> WITHCODE.
The collateral warning is a catalog fact and it names EVERY function the library registers. The build reads the library's registered functions out of the SAME reply that carried the code, unconditionally, and any library plans with one consequence:
loses: replaces-whole-container
fact: { source: "FUNCTION LIST LIBRARYNAME libredb_probe",
observed: "libredb_echo_key, libredb_ping" }
A library registering exactly ONE function is warned about too, and this is a CORRECTION of an
earlier claim in this file that it planned consequences: [].
That claim rested on the premise "a library IS its one function, so re-registering it loses
nothing", and the premise is about the SUBMITTED text, which nothing on this path reads: the only
identity check is the shebang library name and no Lua parser is involved anywhere.
MEASURED on a Redis 8.10.0 container on 2026-09-14: libredb_probe registering only libredb_ping,
loaded again with FUNCTION LOAD REPLACE over a body registering libredb_other under the same
shebang, answered libredb_probe, FUNCTION LIST LIBRARYNAME libredb_probe then answered
libredb_other alone, and FCALL libredb_ping 0 answered ERR Function not found.
Through this provider that edit built consequences: [] and applied applied-with-collateral
naming libredb_ping, so the build promised a loss could not happen while the apply reported one
that had, which is a success destroying something the reader was never shown.
The cost of the correction is one warning and one acknowledgement tick on every single-function
edit, including the ones that re-register the same name.
consequences: [] survives for one population and it is not a library shape: a FUNCTION LIST
reply the provider can read a library out of and no function names out of.
MEASURED on 8.10.0, FUNCTION LOAD over a body registering nothing answers
ERR No functions registered, so no live library reaches it.
The names are SORTED, because FUNCTION LIST answers them in an internal order and a
warning that reworded itself between two identical reads would show a reader a difference that is not
one. No Lua parser is involved anywhere: the fact is what the library registers TODAY, never a
prediction about what the submitted text will register.
Three build refusals, in this order: the read bound (guard), byte-identical text
(definition), then the shebang (identity). The read bound has a live population, which is what
libredb_bulk is in the fixture for: measured, FUNCTION LOAD ACCEPTS a library over the 1,000,000
character source bound and FUNCTION LIST LIBRARYNAME libredb_bulk WITHCODE reports a library_code
of 1,000,243 characters for the one the fixture loads. Submitting the pane's bounded text back
would delete the 243 characters past the bound and report success.
The apply sends the PLAN'S OWN unit, verb, literal arguments and payload, and never rebuilds the
command from constants of its own: the dialog renders plan.unit and the apply sends plan.unit, so
byte-identity between the preview and the write is structural. Three round trips at most:
FUNCTION LIST LIBRARYNAME <name> WITHCODE, the re-read, FIRST, so nothing sits between the comparison and the write. A library whose bytes moved isconflict/object-changedcarrying the server's current text for the diff, and NOTHING is executed. A library that was deleted answers the empty string, which is what is there.FUNCTION LOAD REPLACE <the payload>.- the same re-read again, which supplies the NEW revision token and answers the collateral question.
lostis what disappeared between round trip 1 and round trip 3, a fact read AFTER the write: the plan said what WOULD be lost, this says what WAS.
The reply is checked against the addressed name. The reply of FUNCTION LOAD REPLACE IS the
library name the server read from the shebang, so a reply naming another library is
applied-elsewhere, carrying the server's own reply in wrote and undone: false, because Redis
offers this design nothing to take it back with and it will not issue a FUNCTION DELETE of its own.
It is a CONTROL rather than the primary guard: the build's shebang check already refused that text, so
what this catches is a shebang-extraction bug in the provider. Round trip 3 is skipped on that arm,
because the addressed library was not written.
The comparison is BYTE-EQUAL and case-sensitive, for the same reason the library selection is: a reply
differing from the addressed name only in case names a DIFFERENT library on this engine, and measured
on 8.10.0 a load addressed at libredb_probe whose shebang said name=LIBREDB_PROBE replaced
LIBREDB_PROBE wholesale and left libredb_probe untouched. A case-folding comparison would report
that write as applied against an object the reader was never shown.
A plan whose revision is not compared RAISES, and it does so before the first round trip. This
provider issues compared on every plan it builds, so any other revision arrived from somewhere else,
and reporting "no revision was available" as conflict / object-changed would assert that the object
moved when nothing observed it moving. A statement unit and an undeclared kind raise for the same
reason and in the same place.
Refusal classes, from the server's own first word, which on Redis is the error code:
| Reply | Class | at |
|---|---|---|
READONLY You can't write against a read only replica. |
privilege |
none |
NOPERM User libredb_nofunction has no permissions to run the 'function|load' command |
privilege |
none |
ERR Error compiling function: user_function:4: ... |
definition |
user, line 4, column 1 |
every other ERR |
definition |
none |
A replica refusal is privilege although it is not about permissions, because the refusal class is a
statement about what the reader does next and "use a different connection" is the same answer. The
compile error's line number needs no correction: measured with two points, a @@@ on physical line 2
answered user_function:2 and the same error on physical line 4 answered user_function:4, so the
count is 1-based, includes the shebang and is already the reader's own line. It is still converted
through core's coordinate map rather than returned as a number, so a line the reader's text does not
have answers outside instead of a coordinate Monaco would silently clamp.
A transport failure is interrupted, never a refusal, with committed: "unknown". The
distinction is isServerErrorReply's, the same one the source read makes, and it carries the same
measurement: nobody answering is not the server answering no. rolled-back is never claimed here,
because no transaction was opened.
A read the server REFUSES raises rather than becoming a refusal, which is the one asymmetry with
readObjectSource. A NOPERM on FUNCTION LIST leaves the build knowing nothing about the object,
so there is no plan to issue and no fact a refusal could carry; the pane's own read has already shown
that sentence.
No session state is pinned. FUNCTION LOAD is server-scoped: measured, one load is visible from
every numbered database and SELECT does not change what FUNCTION LIST answers, so nothing this
apply does depends on a session another borrower of the connection can move. plan.session is [].
Driven through POST /api/db/objects/source, POST /api/db/objects/edit-plan and POST /api/db/objects/edit-apply against a running Studio, on a container created for the run.
INFO server answered redis_version:8.10.0.
The fixture was applied by hand, because this image has no init-script directory: docker exec -i <container> redis-cli --no-raw < docker/redis-init/01-object-fixture.redis, one connection for the whole file.
FUNCTION LIST afterwards answered three libraries, libredb_probe registering libredb_echo_key and libredb_ping, LIBREDB_PROBE registering LIBREDB_UPPER_PING, and libredb_bulk registering libredb_bulk_ping.
THE COLLATERAL RUNS IN BOTH DIRECTIONS.
libredb_probe registers two functions, so a body registering only libredb_echo_key builds a plan carrying one consequence, replaces-whole-container, whose fact is { source: "FUNCTION LIST LIBRARYNAME libredb_probe", observed: "libredb_echo_key, libredb_ping" }.
Applying it answers applied-with-collateral whose lost entry names libredb_ping alone, which is the function that actually went, and FUNCTION LIST afterwards shows libredb_probe registering libredb_echo_key only.
LIBREDB_PROBE registers one, so an edit that keeps that one built consequences: [] and the apply answered plain applied ON THE RUN RECORDED HERE.
That build answer is no longer what this provider gives: a single-function library now carries the same warning, for the measured reason recorded above, and the apply's answer for that edit is unchanged.
The acknowledgement is enforced by the SERVER and not by the dialog: the identical collateral apply with an empty acknowledged answers HTTP 400, this apply destroys something the plan warned about and the request did not acknowledge: replaces-whole-container, and the library is untouched.
A FAILED APPLY LEAVES THE LIBRARY BYTE IDENTICAL.
This engine has no transaction that can roll a failed apply back, so the assertion is that FUNCTION LIST WITHCODE is byte identical across the apply and that the library-to-function map is unchanged.
Five failures were driven against libredb_probe and all five left both readings identical.
| What was sent | Where it was refused, and what it answered |
|---|---|
| A Lua compile error | the engine: refused, definition, ERR Error compiling function: user_function:6: unexpected symbol near '=', at line 6 of the reader's own text |
| A body registering nothing | the engine: refused, definition, ERR No functions registered |
A shebang naming LIBREDB_PROBE, which EXISTS |
the build: built: false, identity, before anything is sent |
| No shebang at all | the build: built: false, identity |
| A second writer replaced the library between the build and the apply | the apply's re-read: conflict, object-changed, carrying the server's CURRENT text |
The third row is the one the shebang check exists for.
FUNCTION LOAD takes no name argument, so the shebang IS the address: sending that body would have replaced LIBREDB_PROBE wholesale and left libredb_probe untouched, and the reader would have destroyed an object they were not looking at.
A TRUNCATED PART CANNOT BE EDITED, and this engine HAS the population.
libredb_bulk reads as truncated: { limit: 1000000, ... } with NO edit key and 1,000,000 characters of text, and an edit of it inside the route's bound is refused by the build with the guard class naming the server's own 1,000,243 characters.
The CONTROL, libredb_probe at 252 characters, carries no truncated, carries edit: { offered: true }, and builds.
THE AUDIT.
One successful apply added exactly two object_edit events under one correlationId: an action: "PLAN" decision event and an action: "replace-in-place-command" outcome event, both result: "success".
A sentinel planted in the submitted Lua appears in neither event and nowhere in the process's stdout.
Every object read opens its own short-lived connection and selects the database on it, rather than
issuing SELECT on the shared client: a SELECT there would decide which database a concurrent query
in the same session ran against. Nothing in the object surface ever sends SELECT on the shared client.
A DATABASE THE SERVER DOES NOT HAVE IS REFUSED, NOT READ AS 0. The SELECT is sent by the provider
rather than handed to ioredis as its db option, because ioredis does not fail a connect on that option.
Measured 2026-09-24 on redis 8.10.0 through ioredis 5.11.1 with db: 99: connect() resolved,
"ERR DB index is out of range" arrived only as an unhandled error event, and every later command ran
in database 0. Now the session connection, each object read, the key walk and a run carrying
database all fail with Redis refused database 99: ERR DB index is out of range, a 400.
The object surface above answers "what is here" in one bounded read, and a catalog-backed engine can
answer that way because a stored definition is enumerable in full. A key space cannot be: there is no
prefix index to enumerate from, so the only way to learn what exists is SCAN, and SCAN answers a
cursor rather than a listing. That is a different contract, so it is a different surface — a
capability, an optional provider method, one route and a panel — rather than a flag on the object
routes. (Flag-shaped options on those routes are how a whole-database eager read got built once
before; see the includeColumns history in §6.1.)
| Piece | Where |
|---|---|
keyScan |
the declaration; absent on the catalog-backed engines (§9) |
scanKeysPage() |
DatabaseProvider's optional method, implemented by this provider (redis.ts) |
POST /api/db/keys/scan |
the route, gated on the declaration |
KeyBrowser |
the sidebar panel, offered only where the declaration is present |
One page carries four fields in and four out:
- The caller owns the cursor. Nothing is held between two calls, so a page is a round trip rather than a session — and a cursor that arrives after a reconnect is still valid, because it is a position in a hash table and not a handle.
DBSIZEtravels with every page astotal. It is O(1) on the server, and it is the only denominator a progress indicator can divide by: aSCANcursor says nothing about how much is left, so no total can be computed from the batches a caller has already seen. It counts every key in the database, so with apatternin hand it is a denominator for the database rather than for the walk, and the panel's wording says which.- The deployment's shape travels with that count as
clustered, read from the server's ownINFO clusterin the same pipeline round trip asDBSIZErather than in a call of its own. It is present andtrueonly when the reply sayscluster_enabled:1, which is what lets the panel say the keys and the count are one node's; a plain server sayscluster_enabled:0, and a reply that is missing or cannot be read leaves the field absent rather than guessed (see the clustered-deployment limitation). - Each key's type travels with the page as
types, keyed by name.TYPEtakes one key and Redis publishes no batch form, so the provider pipelines one call per key of the batch: a page costs one extra round trip whatever it holds, never one per key. A key absent from the map is one no page could describe, and the panel draws nothing for it rather than guessing; a key that vanished between the walk and that read keeps the server's ownnone. databaseis the numbered database to walk, and absent means the session's own. The field is optional rather than defaulted to0for exactly that reason: the engine answers with the database the connection is already in, which is a different fact from "database zero".- No deduplication and no ordering.
SCANpromises neither — a key present for the whole walk is returned at least once and may be returned twice while the table rehashes, and the order is the hash table's. The panel merges what it receives into a tree by key name, whilescannedcounts what the walk was handed, repeats included, because that is the number the progress line measures. - A failure does not advance the cursor. The position already held is the last one the server acknowledged, so a retry re-asks the batch that failed rather than silently skipping it.
- The separator is fixed at
:, inkeyGrouping()'s own sense. A configurable one would be a choice this provider made and then had to keep consistent across a tree, aMATCHpattern and this document, for a setting nothing else in the product has a use for.
-
Database N— the tree's ROOT ROW, carrying the server's ownDBSIZEfor the database the walk is reading. A key space belongs to one numbered database, so the tree is drawn under it rather than beginning atapp:*and leaving the reader to guess which. Folding it hides the rows and asks the server nothing. -
A database dropdown, populated from
listContainers()— the engine's own container level, the same read the object tree's top level comes from, never a hardcoded 16 (§6.1). Choosing one re-walks that database from cursor"0"; nothing is sent while the session's own database is the one being read, because the engine already answers with it and a request naming it would be a second question with the same answer. A refused list is a banner, not a dead panel: the walk continues in the session's database. -
Scanned n/m, orMatched n of monce a pattern is in hand. The two numbers are of different kinds:nis what the walk was HANDED (withMATCH, only the keys that passed it) andmis every key the database holds. A finisheduser:*walk reporting137/1531is not a stuck walk, so the word follows the question rather than inviting the reader to wait for a walk that is already over.nis held to the panel's 10,000-key budget, because a page is counted whole even when that budget truncates the tail of the last one: printed raw, the numerator would read a few keys above the limit the panel states two lines below it, and invite a question whose answer is no. -
Rescan — the same question asked again from cursor
"0". A key space changes under a sample, so this is an ordinary gesture rather than a recovery: it is the only way to see a key somebody else wrote. A page still in the air is dropped rather than mixed into the new walk. -
Scan more — one more batch from wherever the walk stands, refused while a page is in flight so that two answers cannot advance from the position only one of them earned.
-
Scan all — pages until the cursor comes back
"0", capped at 10,000 keys and cancellable between pages. The cap is a client budget and the panel says so in its own words when it ends a walk:SCANis O(N) over the whole keyspace, so an unbounded "all" against a key space of millions is a request that never returns and a server that is busy while it does not. -
A second bound: the keys the tree HOLDS. That budget is per press, and a reader can press more than once — so the panel also refuses past 10,000 keys held, across every page and every press of every prefix, with its own sentence (
Holding 10,000 keys, which is this panel's limit…). When it is reached Scan more and Scan all are disabled and noClick to load morerow is offered, because a page bought for a tree that cannot take it is work thrown away. Both bounds are the same number on purpose: one budget, stated once, and the two sentences say which one was met. A full tree is not a spent one — the cursor is still live — so narrowing the pattern starts a fresh walk from cursor"0"and the keys come back. -
The tree is WINDOWED. Rows are drawn at a fixed height and only the ones the scroll box can show are mounted, the way
ObjectTreealready does it: a prefix holding six thousand keys costs a few dozen DOM nodes, not six thousand, andaria-setsize/aria-posinsetare taken from the FULL level so a screen reader is told how long the list really is while the window hides most of it. A row the reader has focus on is kept in the window rather than unmounted, because focus cannot move to a node that is not in the DOM. The database row scrolls with the rest of them — a row pinned over a scrolling slice of prefixes would disagree with it about where the list starts. -
The ancestors of the first visible row stay pinned above the rows, root first, so a reader who has scrolled deep into one prefix keeps the database and the folders in sight: at most five rows or 35% of the panel, and one ellipsis when the chain is longer than that. An ancestor leaves the strip the moment its own row is visible again, and pressing a pinned row scrolls back to that row and selects it. The same strip is on the object tree (
FEATURES.md). -
Click to load more, under an open folder — one page of a walk scoped to that prefix (§6.3). It is the one control that can find keys the global sample never happened to include. The row carries how many keys are loaded under that prefix in the same right-hand column every other row keeps its number in, and reports what the last press DID (
+12 new, ornothing new in that page) — because a scoped page is filtered by the server and then deduplicated here, so it can legitimately add nothing, and that true answer is otherwise indistinguishable from a dead button. -
A key's type, beside its name:
string,hash,list,set,zset,streamor theReJSON-RLa RedisJSON key reports, in the same right-hand column a folder uses for its key count, so one edge answers "what is this row" for every row. It arrives ON THE PAGE (see the contract above). A key no page described draws an empty cell rather than a guess, and a key that vanished between the walk and that read draws the server's ownnone. -
Activating a key opens the statement that reads it:
GET,HGETALL,LRANGE 0 -1,SMEMBERS,ZRANGE 0 -1 WITHSCORES,XRANGE - + COUNT 100orJSON.GET, chosen by the type the page carried, in a query tab and runs it, so the value lands in the results grid the reader already uses. There is no separate detail panel: the statement is generated by the same type-aware generator the object tree's rows go through (§5.3, #427), which is why ahashkey opens onHGETALL <key>and not on a probe. Because the type is already in hand, an activation costs no request of its own. A key no page described opensTYPE <key>instead: that reports what the key is, which is the honest answer when nobody knows, rather than running a read nobody chose. The type carrier is handed to the generator rather than looked up, because a KEY IS NOT A SCHEMA NODE — the cache the generator's other callers read types from holds prefix groups, and no key is in it.THE READ RUNS IN THE DATABASE THE WALK WAS READING, not the connection's own. A key lives in one numbered database and
GET <key>cannot name it, so the number travels with the run as its own field beside the connection — which is what makes it reach a managed connection too, where anything attached to the connection object is discarded on the way in (resolveConnection, and with it the session's database while the panel claims a different one). Every run of that tab follows the number — the initial read, a re-run, a selection, an inline edit and the next page — and a restored tab keeps it after a reload. -
Browse Keys, in the row menu of a key-pattern row in the OBJECT tree — the one action that opens this panel instead of acting on the object. It switches the sidebar to Keys and hands the row's name over as the
MATCHpattern, escaped in its prefix half by the same helper the Load more row uses:a[b:*arrives asa\[b:*, because a key prefix is data that may contain a glob metacharacter while the*the row is advertised with is the one the pattern exists for. The item is offered where the engine declares BOTHkeyScanandtablesAreDerivedGroupings— a surface to show it in, and rows that are prefixes rather than objects — and asksrole === "relation"as well, so a routine row is never offered a walk of a prefix it does not name. The row's own DATABASE travels with it: the item is offered on the key-pattern rows of every database the object tree lists, and an object's path starts with its container's path, so the panel is pointed at the database the row was under rather than whichever one it happened to be in — and it waits for the container list before walking, because a page taken before the list answered is a page thrown away. The panel applies the pattern once and then owns it: the reader can edit or clear it, and the same row asked for twice is not re-imposed on the box. -
Filter — narrows the tree the walk has ALREADY collected, client-side and without a request. The box takes two kinds of answer, because a reader has two: a word (
cache,123,HEALTH) names a segment and keeps that segment's WHOLE subtree — somebody who typed a branch name is asking for everything under it — and the rest of a name (queue:jobs:failed:2026:09:23,123:k) names a path, matched anywhere inside the full key name. A segment-only test answered "no match" for the most specific input there is, which is the one a person types when they already know what they are looking for. The tree's own advertised form is accepted too: a folder is drawnapp:*, so that one trailing:*is dropped from the term (a*anywhere else stays literal, because a real key segment may contain one), and the folders that lead to a match are opened, because a match left collapsed looks like no match at all. -
Pattern — forwarded as
MATCH, and a new pattern starts a NEW walk rather than appending to the old one, whose keys are not answers to the question now being asked. The page in the air when the question changes is dropped with it, so a sample can never hold two questions' keys.
The panel is MOUNTED FOR THE CONNECTION, not for the tab: switching the sidebar to Objects and back
finds the walk where it was left, because a SCAN sample costs round trips over the whole key space
and losing it to a glance at the tree is the difference between a panel and a toy. Changing the
connection drops it, because a hidden panel still walks and that walk would be of a server nobody is
looking at.
A FOLDER'S BADGE IS THE KEYS LOADED UNDER IT, and a leaf carries no number at all. It is the number
that MOVES as pages arrive — the global walk's and one press of that prefix's own Load more — which is
why it is the number a reader in front of a folder is asking about: how much is in here. It is a
SAMPLE's count and says so on its tooltip, which also carries the other half of the fact: the number of
ROWS the folder opens to, which is smaller. That number used to be the badge, and reading it as the
amount of data is how a prefix holding 670 keys came to wear a 2.
A LEAF DRAWS ITS FULL NAME. app:env rather than env, since the name is what a key is identified
by and the depth already says where the tree put it. Folders keep the prefix:* form they are
advertised under, because a prefix is not a key and has no name of its own.
Every refusal is 400 and in the route's own words: a count outside [1, keyScan.maxCount]
(refused rather than clamped, because a silent clamp answers a request for 10,000 with 1,000 and says
nothing), a count that is not a positive integer, a non-decimal cursor, an empty pattern, a
negative database, a database the server does not have (the server's own sentence, see above), and
any engine declaring no keyScan at all. A provider that declares the
capability and implements no method is a distinct 500 — the state an external implementer of the
published interface can genuinely be in — rather than a TypeError that reads as a crash.
A FAILED PAGE IS A BANNER AND NOT A REPLACEMENT. The keys already in the tree are answers the server
really gave, so a page that could not be taken does not take them away — which matters most for a
scoped walk, where one prefix refusing is no reason to blank the panel. It also does not end a
Scan all in progress: the loop's failure flag belongs to the walk that started at the database
level, and a prefix that refused is not a reason to end it. The empty state, which is a claim about
the DATABASE rather than about one read, is the one thing the banner suppresses.
The global walk is a sample, so a prefix's contents in the tree are whatever that sample happened to include — and a deep prefix can be entirely absent from a thousand keys out of a million. The Load more row asks the server about ONE prefix directly, which is the only honest way to answer "is there more under here".
- It is the same route, with
patternbuilt from the prefix (app:cache→app:cache:*) and a cursor that belongs to that prefix. No second endpoint, no second contract. - It asks for the largest batch the engine declares,
maxCountrather than the global walk'sdefaultCount.COUNTis a hint about how many hash buckets the server visits, andMATCHis not indexed, so a smaller batch does not make a press cheaper — it makes MORE presses for the same answer, which is the thing a reader is already waiting on. The global walk keeps the default becauseScan morethere is a deliberate step somebody is watching. - Each prefix keeps its own cursor, so pressing the row twice continues that prefix rather than
restarting it, and
"0"from a prefix proves there is nothing more under it — which is what retires the row. - It reports what the press did. The row shows how many keys are loaded under that prefix, and
the last page's own contribution: deduplication happens on this side, so a page can come back
holding only keys the tree already had.
+12 newandnothing new in that pageare both answers, and the second one is the reason this exists — without it a true answer reads as a dead button. - It costs what a page of the main walk costs, and answering it completely costs a pass.
MATCHis applied per batch server-side and is not indexed, so one press visits one batch of hash buckets and returns the keys under the prefix that happened to fall in them — the same O(1)-per-page workScan moredoes, and a full pass over the keyspace only once the prefix's own cursor comes back"0". That is why it is a row a person presses and not something the panel does for every open folder. - Its keys do not touch the walk's progress.
scannedandtotalare the global walk's numbers; a scoped page hands back keys the global walk may already have counted, and adding them would push the progress line past its own denominator. - Its answer is filtered client-side.
MATCHis a glob with no escape and a real key segment can contain*,?or[, so the server can return keys that are not under the prefix asked about.isUnderPrefix()keeps only the ones that are, compared segment by segment —app:envelopeis not underapp:env, whatever a character comparison of the joined names would say. - The row is not offered when the answer is already known. A spent cursor at the DATABASE level
means the sample IS the key space, so every prefix in it is complete; a prefix whose own walk came
back
"0"is complete too; and while a filter is on the view is of what is held, so a row that pulled more into it would make the visible set depend on clicks the filter's term does not explain. A fourth reason is the tree's own limit: at 10,000 keys held the panel takes no more pages of any kind, and a row that could not deliver one is not an offer (§6.2, the held bound).
SCAN walks one node's slots and DBSIZE counts one node's keys, and neither has a cluster-wide
form. On --cluster-enabled yes the panel therefore walks and reports the node the connection
reached, which is also why its database selector offers only database 0 there — cluster mode has
no others (§4.1). A cluster-wide walk would need a cursor per node, which
is deliberately not attempted: a partial answer labelled as partial beats a total nobody can compute.
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 by calculateHitRatio()
(redis.ts) as
keyspace_hits / (keyspace_hits + keyspace_misses) * 100, defaulting to 100.0 when there has
been no traffic.
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 returns one row per key:value metric (section, key, value), parsed by parseInfoResult. The count in the message is that row count: section headers and blank lines are not metrics. The Operations tab renders the rows. Non-destructive. |
| anything else | Throws QueryError (Unsupported maintenance type for Redis) |
This is reflected in getCapabilities().maintenanceOperations = ['analyze']. The admin Operations
tab has gated each card on that list since #282, so it renders the analyze card only and never
offered Redis a vacuum action; #427 changed the wording on that card, not the gate (§9). The
schema explorer's per-row menu offers neither "Key Info" nor "Memory Doctor", because a
per-row action needs an addressable row and these rows are derived groupings (§5.3).
No Terminate button renders on the session list either. The CLIENT LIST clients that
getActiveSessions() returns were each offered one until #1424, and confirming answered 400 Operation 'kill' not supported for this database. Supported: analyze with the client still listed.
Both session lists now draw it only where kill is declared.
The admin Operations tab also renders this provider's own wording for the analyze card — "Run
Info" / "Server Info" / "Get Redis server information and statistics." — instead of Postgres's
query-planner copy. Those analyzeGlobal* fields had been declared and set for a long time and read
by no component (#427).
Declaring that an operation EXISTS is not enough to put a button on it: two engines that
declare the same MaintenanceType take different kinds of target, so each provider also
declares what its own operations may be pointed at. The monitoring Tables tab renders a
per-row control only where perEntity is true, the admin Operations tab a whole-database
card only where global is true, and both take the wording from label (#496).
POST /api/db/maintenance reads the same declaration since #U20, and it is the one reader that
REFUSES rather than hides: it takes the placement from whether the request carries a target
(absent or empty means whole-database) and answers 400 when this provider marks that
placement unavailable while the other one is available - {type:"analyze", target:"session:"}
is that request here, and it is the only one this provider can refuse.
| Operation | Control label | Per-row | Global | Why |
|---|---|---|---|---|
analyze |
Server Info | no | yes | runMaintenance(type) takes no target parameter at all - the operation is INFO, which reports on the server and cannot be pointed at a key prefix |
A per-row control here answered with server-wide metrics for one grouping, which is the dead end #427 reported for "Key Info". The Operations tab's global card carries Redis's own "Run Info" / "Server Info" wording. "Memory Doctor" names no declared operation, so no control offers it.
getCapabilities() (redis.ts)
| Capability | Value |
|---|---|
queryLanguage |
json |
queryDialect |
redis — routes the client-side query generators to Redis command output and types the editor tab redis (see 5.3). Checked before queryLanguage, which says only "not SQL" and by itself meant MongoDB (#427) |
supportsExplain |
false |
supportsExternalQueryLimiting |
false |
createTable |
absent |
supportsInlineRowEdit |
false — Redis commands are not SQL, so there is no UPDATE ... SET for the results grid's inline editor to emit |
supportsTestDataGeneration |
false - no kind here declares a row write, and Redis commands have no multi-row insert for the Generate Test Data dialog to emit |
supportsResultPagination |
false — prepareQuery pins both limit and offset: a Redis read is a command, not a statement whose bound this layer can advance (#816) |
supportsTransactions |
false — MULTI/EXEC exists in Redis and is not exposed through this provider, so the transaction trio and SANDBOX are not offered (#464). A MULTI a script sends anyway is ended by endOpenQueryTransaction(), which BOTH query routes now call in a finally, POST /api/db/multi-query and POST /api/db/query, so an editor run ends its own too (D74, D87) (§5.2a) |
declaresForeignKeys |
false — Redis has no constraints at all, and the "tables" here are key prefixes this provider grouped rather than objects anyone declared |
tablesAreDerivedGroupings |
true — the object surface SCANs a bounded slice of the keyspace and groups the real key names it found by their prefix, so a user:* row is this server's own summary and not a key any command can be given. The agent layer states this to a plan run, in one sentence, so a grounded run does not draft a command against a grouping. In the object tree it is what withholds Profile from a keyspace row (§6.1) |
containerLevels |
one level, schema, labelled Database (§6.1) |
containerPathShapes |
exact: only [database] addresses a container, so a shorter or a longer path is refused, by the object routes over HTTP and by this provider for a caller that reaches it directly (#1147) |
objectKinds |
keyspace (relation) and function (routine, hasSource, sourceLanguage: "lua"). function is the only kind in this engine with a definition text, read through FUNCTION LIST ... WITHCODE (§6.1). Three further candidates are absent rather than declared and zero |
keyScan |
{ defaultCount: 500, maxCount: 1000 } — the batch sizes this provider forwards for a resumable walk of the keyspace, and the declaration that gates the Keys panel (§6.2). Declared here rather than defaulted at the call site so a panel and its provider cannot disagree about what a batch is |
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)
The label map relabels the generic schema-explorer UI for key-value semantics: entity → "Key
Pattern", row → "key", select → "Scan Keys", generate → "Generate Command", analyze → "Key
Info", search placeholder → "Search keys…", etc. analyzeAction ("Key Info") is declared but
no longer reaches the schema explorer's per-row menu, which offers no maintenance here at all
(§5.3); it stays because it is the correct wording the moment a per-row target exists.
The labels rename actions that behave differently here, not generic ones wearing Redis names:
"Scan Keys" really emits SCAN, and "Generate Command" really emits Redis commands (§5.3).
That was not true before #427, when both emitted MongoDB documents under these labels.
statementLanguage is the one label no person sees: the agent's plan contract states it verbatim to
the model. Unlike MongoDB's, it is not about the language — a plan run on 2026-08-22 wrote real Redis
commands — but about the shape they were packaged in:
1) KEYS session:*
2) GET session:1
executeRedisCommand reads the whole body as one command (§5), so the server answered
ERR unknown command '1)'. The label therefore names the two things that made it unrunnable — the
list numbering and the second command — alongside the two accepted forms (plain and the lossless
{"command": …, "args": […]}), and repeats in words what tablesAreDerivedGroupings says in a flag:
a prefix:* row is this server's grouping, not a key, so a prefix is reached with SCAN … MATCH.
slowQueriesEmptyState ("Redis lists what SLOWLOG holds, and nothing has yet run slower than
slowlog-log-slower-than.") is the monitoring Queries panel's empty state. It exists for the same
reason the analyzeGlobal* triad had to be read rather than merely declared (#427): that panel's
sentence was hardcoded to PostgreSQL's pg_stat_statements advice on every engine
(#463), while what is empty here is the SLOWLOG (§7).
analyzeGlobalLabel / analyzeGlobalTitle / analyzeGlobalDesc ("Run Info", "Server Info",
"Get Redis server information and statistics.") are rendered by the admin Operations tab. The
vacuumAction / vacuumGlobal* fields ("Memory Doctor", "Memory Analysis") are still declared
but reach no screen: the admin Operations tab's vacuum card and per-table button are gated on
maintenanceOperations containing vacuum, which this provider does not list (§8 — that gate is
#282 and unchanged here), and the schema explorer's row item is hidden because the rows are derived
groupings (§5.3). They stay so the map is complete if a vacuum-shaped operation is ever added.
keyvalue/redis-sql-writing.ts declares how a generated statement spells a literal, a name and a bind for this engine, and it is this engine's row of SQL_WRITING (src/lib/db/sql-writing/index.ts), which the readers in src/lib/sql/values.ts and src/lib/sql/identifier.ts look up.
A literal is written by literalStandard and read back by unquoteStandard: the quote is doubled and a backslash is data.
A name is quoted by identifierDoubleQuoted, the SQL standard form: a double-quoted name with an inner double quote doubled.
There is no placeholder (placeholder: null), so a generator quotes the value with quoteLiteral instead of emitting a bind.
This engine declares queryLanguage: "json", so no statement is ever built for it to read; what a generator emits for such a connection is portable SQL meant to run elsewhere, and the standard form is the only thing it can claim.
Inline row editing reads a key column's kind through the shared INSTANT_TYPE_NAMES, FLOAT64_TYPE_NAMES and EXACT_DECIMAL_TYPE_NAMES of the module's inlineEdit, and binds every edit as typed.
The module's export field writes a result export's CREATE TABLE and INSERT from the shared building blocks alone: STANDARD_DDL_TYPES for the column types, NOTHING_STANDS_ALONE and NO_BARE_SPELLING, so every bare declared name is re-spelled from its family, NULL_NON_FINITE for NaN and the infinities, and binaryStandardHex for a binary cell (X'0102').
The engine declares queryLanguage: "json", so no SQL statement is ever built for it to read, and a file exported from it is meant to run somewhere else: every bare name in it is re-spelled portably rather than kept as one engine's private word.
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() refused for a credential (WRONGPASS, NOAUTH, ERR invalid password) |
AuthenticationError, "Failed to connect to Redis: WRONGPASS …", not retryable (§4.4) |
connect() fails any other way |
ConnectionError, "Failed to connect to Redis: <reason>" (§4.4) |
| Malformed JSON command | QueryError — "Invalid JSON command format" |
JSON without command |
QueryError — "Command is required…" |
| Only comments or blank lines | QueryError - "No command to run (only comments or blank lines)" |
| A quoted argument that never closes (§3.4a) | QueryError - "Line N opens a quoted argument with ... that never closes ..." |
| A second command before the first blank line (§3.4a) | QueryError - "Line N holds a second command, which begins with ..." |
| 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()/callBuffer() responses), which exercises the same code paths as a real
Redis 6.0+ instance. The mock also honours stringNumbers the way ioredis does, answering an integer
reply as its digit string when the option is set, so a count read with typeof x === "number"
fails in the suite as it would against a live server.
⚠️ Mock isolation:bun'smock.module()is process-wide. Run the suite withbun run test, which gives every test file its own bun process, 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), the line reading of §3.4a (the second-command
refusal and the empty driver call behind it, multi-line quoted values, EVAL scripts and
FUNCTION LOAD libraries, a JSON command read until its brackets balance, quoted empty arguments),
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, a
battery of common commands (KEYS, SET, DEL, PING, DBSIZE), integer replies past 2^53
(INCR, INCRBY, DECRBY and a nested array, §3.5a), the whole object surface
(§6.1) through assertObjectSurface plus per-method assertions, and
every ssl.mode branch
asserted against the options object the Redis constructor received. The same captured options carry
the ACL user assertions (§4.1a) — username present for a named user,
absent for both an empty string and an unset field — and a refused INFO is asserted to raise the
server's own NOPERM sentence out of getHealth(). The mock's connect() can emit the measured
error events of §4.4 before it rejects, which pins the typed error and
the message each one becomes.
# 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`To reproduce the ACL rig of §4.1a — a user that can browse keys but not read
INFO:
docker run --rm -d --name redis-acl -p 6389:6379 redis:latest
docker exec redis-acl redis-cli ACL SETUSER probe on '>probepw' '~*' +@all -info
# Username `probe`, password `probepw`: keys browse, and health reports degraded (amber).
# Leave Username empty and the same password authenticates as `default`, whose INFO succeeds.The object surface's fixture is committed, not typed into a shell while measuring and then lost
with the container: docker/redis-init/01-object-fixture.redis.
The redis image has no init-script directory. There is no /docker-entrypoint-initdb.d
convention and no entrypoint hook of any kind — the image runs redis-server and nothing else — so
unlike the PostgreSQL and MongoDB services this fixture is not mounted and applied for you. It is a
file of redis-cli commands, and the exact command that applies it is:
docker exec -i libredb-redis redis-cli --no-raw < docker/redis-init/01-object-fixture.redisredis-cli reading from stdin uses ONE connection for the whole file, which is what makes the
SELECT 3 in the middle of it work; a per-line redis-cli loop would silently write every key into
database 0. The file is idempotent: it DELs the keys it is about to write and loads the function
libraries with FUNCTION LOAD REPLACE.
The file carries no comments, and that is a constraint rather than a style. Measured on
Redis 8.10.0: redis-cli reading from stdin does NOT skip a # line, it sends it as a command, and
the server answers ERR unknown command '#'. Every explanation of what the fixture holds therefore
lives in the table below rather than beside the line.
What it builds, and why each part is there:
| In | What | Why |
|---|---|---|
| db 0 | user:1 user:2 user:3, session:abc session:def, queue:jobs, standalone |
three prefixes plus a key with NO colon, which is its own grouping |
| db 0 | mixed value types under one prefix (string, hash, list) | the sampled type column is string/hash-shaped rather than uniform |
| db 3 | report:daily |
a key that exists in ONE database and nowhere else, so a provider reading the SESSION's database instead of the CONTAINER's is distinguishable from a correct one |
| db 0 | function library libredb_probe, two registered functions |
the function kind has an object, and FUNCTION LIST WITHCODE has source to answer |
| db 0 | a SECOND library LIBREDB_PROBE, differing from the first ONLY in case |
LIBRARYNAME is a case-INSENSITIVE glob over a case-SENSITIVE dictionary, so one lookup answers both and a source read taking reply[0] shows the wrong library (§6.1); the same pair is what makes the edit path's shebang check testable, since a rename onto the other name would REPLACE it |
| db 0 | a THIRD library libredb_bulk, whose library_code is 1,000,243 characters |
the only object in this fixture OVER the 1,000,000-character source bound, so "a truncated part is never editable" has a population here rather than an argument (#789 Phase 3) |
| server | ACL user libredb_nofunction, password nofunction, -function |
a live principal for the source read's refusal pane |
libredb_bulk is one line of about 1,000,270 bytes, and it cannot be generated on the server.
Measured on Redis 8.10.0: EVAL running FUNCTION LOAD answers
ERR This Redis command is not allowed from script, and so does FUNCTION LIST, so there is no
server-side way to build a large library out of a short fixture line. The literal is committed, the
same disposition the equally large Trino fixture blob has. Regenerate it with:
python3 - <<'PY' >> docker/redis-init/01-object-fixture.redis
pad = "-" * 1_000_100
print('FUNCTION LOAD REPLACE "#!lua name=libredb_bulk\\n'
"--[[" + pad + "]]\\n"
"local function bulk_ping(keys, args)\\n return 'bulk'\\nend\\n"
"redis.register_function('libredb_bulk_ping', bulk_ping)\"")
PYTo measure a cluster-mode container, which is the only deployment where the database count is not 16:
docker run --rm -d --name libredb-redis-cluster -p 6400:6379 redis:latest redis-server --cluster-enabled yes
docker exec libredb-redis-cluster redis-cli CONFIG GET databases # -> 1Six files, and each one covers a seam the others cannot:
| File | Covers |
|---|---|
tests/integration/db/redis-provider.test.ts |
scanKeysPage() against the driver double: the cursor it sends, MATCH forwarded and omitted, the database it opens, the refusal it passes through, and the pairing of the keyScan declaration with the method that implements it |
tests/api/db-keys-scan.test.ts |
the route: the capability gate, the declaration-without-a-method 500, and every parameter refusal |
tests/hooks/use-key-scan.test.ts |
the walk: paging, the in-flight guard, the cap, Stop, a failure that must not be re-asked forever, a page landing after unmount, and a page whose walk was thrown away mid-flight |
tests/hooks/use-key-databases.test.ts |
the container list the walk can be pointed at: the request, the session default, a refusal, a body that cannot be rendered, and an answer that lands after the connection moved |
tests/unit/components/key-browser-tree.test.ts + tests/components/key-browser/KeyBrowser.test.tsx |
the tree (buildKeyTree(), flattenKeyTree(), filterKeyTree() and their edge cases) and the panel that draws it: the database root, the choice that restarts the walk, Rescan, Load more, the type column and the pattern a row menu hands over |
tests/components/sidebar/Sidebar.test.tsx + tests/unit/components/object-tree-row-actions.test.ts |
the two halves of the handover: the sidebar keeps the panel mounted for the connection and owns the Browse Keys handler, and the row menu decides which rows may offer it |
The tree's own suite pins the cases a naive builder gets wrong: a key that is also a prefix of
another (app beside app:env), a key returned twice across two batches, empty segments (:foo,
foo: and a::b are three distinct keys), and the numeric collation that puts user:2 before
user:10.
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 objects = await provider.listObjects(container, 'table');
const { details } = await provider.describeObjects(container, 'table');
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.
- A pasted
rediss://URL selectsrequire, not a verifying mode. The parser carries the scheme assslMode(§4.2), so the paste is encrypted, but nothing in arediss://URL says whose certificate to trust - and the ordinary self-hosted--tls-portnode presents a self-signed one, which a verifying mode would refuse. Verification therefore stays an explicit choice in the SSL panel; the URL alone never turns it on. - No Cluster / Sentinel support. Only a single standalone node is supported.
- No pub/sub,
MONITORor blocking reads in the editor. Each would change or hold the one client every request on the connection shares, soquery()refuses them (§5.2b). 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. The object surface shares the same walk and the same bound, so on a keyspace larger than it thekeyspacefolder's badge and its rows are the groupings of a SAMPLE. They are consistent with each other, because the count is the length of that listing (§6.1), and neither is a total. The BADGE now says so rather than leaving it here: a walk the budget cut short answers{ count, sampledFrom }and the folder reads3+. The flat key-prefix list of §6 has no badge and still says nothing.- An
EVALscript is not an object here, because Redis publishes noSCRIPT LIST— onlySCRIPT EXISTS <sha>, which answers about a sha the caller already has (§6.1). - 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. - The plain-command tokenizer has no escape syntax. Quotes group an argument but cannot be
escaped inside one, so a key or value containing a literal
"or'is not expressible in plain form; use the JSON command object for those. The schema-explorer generators detect this and emit the JSON form for the affected line automatically (§5.3), so only hand-typed plain commands are exposed to it. - One command per run. Each line is a command (§3.4a) and a second one before the first blank line is refused, so a script of several commands runs one selection at a time. A command can no longer be wrapped across lines outside a quoted argument; such a text is refused, naming the line. A command after a blank line is not run and not refused, because that is how the cheatsheet's alternatives are separated.
- No column modification in a generated migration. Since
#269 the schema-diff migration generator
answers a modified column per dialect; keys are not tables and carry no column definitions, so it
emits
-- Redis: Cannot alter column "<name>". ...where it previously emitted PostgreSQLALTER TABLE ... ALTER COLUMNDDL that means nothing here.
REDIS_MIGRATION_DDL is what the schema-diff migration generator writes for redis, read through migrationDdlFor in src/lib/db/migration-ddl/.
It declines the whole diff: refusal is the one line the migration prints after its header, in place of any DDL, and transactionWrapper is null.
redis writes no SQL text at all (readsSqlText: false), so there is no table DDL to wrap (#284).
The refusal reason it prints, verbatim:
- "Keys are not tables and have no column definitions to change."
- 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, the five object methods,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.