Five verbs over an ordered keyspace, with a catalog on top
The whole grammar is get, put, delete, prefix and range, and yet the tree still shows declared columns for the namespaces that have them.
A .libredb file is ordered key-value bytes on disk. There is no server, no wire
protocol and no port: the @libredb/libredb package opens the file in-process,
synchronously, and a connection is an absolute path in the database field. So the
commands you can type against it in the query editor are not a dialect of anything.
There are five of them, they are the entire language, and what follows is what an IDE
built for SQL does with a language that small.
The grammar, all five verbs of it
The grammar in docs/providers/libredb.md section 5.1 is complete in one code block.
get <key>
put <key> <value>
delete <key>
prefix <prefix>
range <start> <end>
Verb matching is case-insensitive. Arguments split on whitespace, and consecutive
whitespace outside quotes collapses, so put key hello world stores hello world
with one space, not two. Quotes preserve whitespace inside a token, and an unmatched
quote is rejected immediately with a QueryError, as is an empty command. An unknown
verb raises the same error with the five supported verbs listed in the message, so
nothing has to be guessed.
Each verb has a fixed result shape, which is what lets a generic results grid render the output of a store that has no rows:
| Command | Fields | Example row |
|---|---|---|
get (found) |
key, value |
{ key: 'user:1', value: 'Ada' } |
get (missing) |
key, value |
zero rows |
put |
changed |
{ changed: 1 } |
delete |
changed |
{ changed: 1 }, or 0 if the key was absent |
prefix |
key, value |
one row per matching key |
range |
key, value |
one row per key in the interval |
Blank lines and lines that start with # are skipped, and the first remaining line
is the one that runs. That rule exists so the schema explorer’s generated cheatsheet
is directly runnable: every line in it is a concrete command rather than a template
with placeholders, so selecting one line and running it works as written.
That list is the whole language, which means the two things missing from it are
missing on purpose. There is no explain and no transaction control here: the engine
publishes no plan and the command grammar has no transaction verb, so both are
withheld rather than offered and then failing. In practice that means
supportsExplain is false and the Explain button and tab are absent rather than
degraded into something that is not a plan, and the BEGIN/COMMIT/ROLLBACK trio and
the sandbox toggle are not offered on this connection. The kernel does expose a
transact() method for atomic multi-key writes; v1 does not surface it through the
grammar.
Half-open ranges, stated once
range is half-open: [start, end). The start key is included and the end key is
excluded.
This is the sentence that has to be read once and remembered, because the failure it
prevents is silent. range user:1 user:2 returns user:1 and not user:2. Nothing
errors. A grid with one row in it looks exactly like a correct answer, and a person
who assumed both ends were inclusive will conclude that user:2 does not exist.
The convention is not only the editor’s. The same interval reads the entire keyspace
for the schema tree: kv.range('', '\u{10FFFF}') covers everything because the upper
bound sits above every key rather than equal to the last one. Half-open intervals
compose - the end of one is the start of the next, no key counted twice and none
skipped - which is why the scan and the editor use the identical form.
Writing a JSON value without breaking the tokenizer
A JSON value in a put must be wrapped in single quotes.
put user:3 '{"name":"Grace","age":45}'
Not double quotes. The tokenizer treats a bare double quote as token quoting, the way a shell does, and would strip the ones around the object - storing a string that is no longer valid JSON, with no error, because a key-value store has no opinion about what a value contains. The single-quote wrapper preserves the inner double quotes verbatim. This is section 12.2 of the provider doc, and it is written down there for the same reason it is written down here: the corruption is invisible until something downstream tries to parse the value back.
Read it back and the value comes pretty-printed: renderValue() attempts
JSON.parse on every value string and, on success, re-serializes it at two-space
indentation for the grid, leaving non-JSON strings as-is. That is presentation and
nothing more. The value is still one opaque string, and the indentation does not make
it a typed column or make the object’s fields addressable by a command.
What the catalog adds to a key-value tree
Since @libredb/libredb 0.0.2 the file carries a persisted catalog, and getSchema()
reads it through catalog(db) rather than guessing from key names alone. What the
tree shows depends on what the catalog declares about each namespace:
| Kind | Columns shown |
|---|---|
| Relational, cataloged | the table’s real declared columns and types, primary key marked isPrimary, nullable: false because v1 relational columns are required |
| Document, cataloged | generic id (string, primary) and document (object) - documents are schemaless, so there are no declared per-field columns |
| Uncataloged raw kv | key (string, primary) and value (string, nullable) |
So a relational namespace written through the table() lens shows its actual column
names, not a key/value pseudo-table. TableSchema has no dedicated kind field, so
the kind is signalled by the columns themselves. The reconciliation is plain: a
relational table stores rows under <table>:<pk>, the scan groups those as N:*, the
provider strips the trailing :* and looks N up in the registry, and a match
upgrades the group to its catalog-aware columns. A cataloged namespace with no rows
yet is still emitted, with rowCount: 0.
The catalog also shapes the generated put example: a JSON object built from a
relational table’s declared columns, a small JSON object for a document collection,
a plain string for raw kv. It does not add a verb. The grammar is unchanged by the
catalog work - get, put, delete, prefix and range still operate on the raw
kv keyspace exactly as before. One behavioural refinement came with it: prefix and
range results now filter out keys in the reserved internal namespace, using the
package’s own isReservedKey predicate rather than a hardcoded string, so a
full-keyspace range no longer returns catalog metadata alongside your data.
Namespaces are still groupings from a bounded scan
The tree looks like a table list. It is not one, and the difference is published rather than smoothed over.
The scan stops at LIBREDB_MAX_KEY_SCAN, which is 10,000 keys. A prefix group that
appears only past the cap does not show as a table, and the row counts are capped
with it. tablesAreDerivedGroupings is true on this provider - one of only two
engines that declare it - and where it is true, the agent’s plan rules state in one
sentence that the rows are groupings this server derived from a bounded scan, and
that the list is one reading’s reach rather than the database’s contents. Plan mode
opens on a LibreDB connection and is grounded by exactly that read; Agent AUTO mode
does not run here at all, because it requires a database-native read-only profile
this provider does not implement, and a run ends engine-unsupported.
The same reasoning removes controls. Profile Table and Generate Test Data address
an object and insert rows into it, and a users:* row is not an object any command
can be given, so both are hidden rather than left to answer HTTP 400. Above the cap,
the monitoring Tables panel refuses with
LIBREDB_TABLE_STATS_TRUNCATED instead of publishing counts that are short by an
unknown amount - while the schema tree still lists the namespaces it reached, because
a list of namespaces is not a count.