contextdb is primarily used as an embedded Rust library. The CLI is for exploration, debugging, and scripting against a database file.
Two binaries: contextdb (interactive client) and contextdb-server (sync coordinator).
contextdb <PATH> [OPTIONS]
<PATH> is the database file. Use :memory: for an in-memory (ephemeral) database.
Reading is the default. contextdb <PATH> opens an existing store for bounded, read-only
inspection: it never creates the store, never mutates it, and leaves every byte in the store
folder unchanged. A no-op meta-command like .help does not rewrite anything either. There is
nothing to copy first — reading never requires a peek copy.
store, DML, DDL, transactions, maintenance, sync operations — takes the explicit --write
flag; a read session refuses it before execution with write_requires_flag.
The same command works whether the store is idle or already owned by a live process:
| Situation | Command | Outcome |
|---|---|---|
| Idle existing store | contextdb <path> |
Direct read-only file session; several direct readers may coexist. |
| Missing store | contextdb <path> |
Refused without creating anything (store_not_found); --write creates it. |
| Idle or missing store | contextdb <path> --write |
Full read-write session; the store is created if missing. |
| Live file-backed owner | contextdb <path> |
Reading session over that owner's authenticated local channel. |
| Live file-backed owner | contextdb <path> --write |
Refused with held_by_writer; the refusal says a read session (drop --write) reaches the live owner's channel. |
| Readers hydrating | contextdb <path> --write |
Refused with held_by_readers, listing the hydrating readers when identifiable, else "N direct readers are hydrating this store; retry in a moment". |
| Flag | Default | Description |
|---|---|---|
--write |
off | Authorizes file creation, mutation, transactions, maintenance, and sync. Also starts the local owner-reading service (see Owner Inspection). |
--json |
off | Machine output: stdout is JSON Lines, one document per statement or meta-command; errors and notices are JSON documents on stderr. Changes rendering only — never a ceiling. |
--sync-endpoint <TICKET> |
(none) | Writer-only: server's enrollment ticket to sync with (dial-by-key). |
--tenant-id <ID> |
(none) | Writer-only: sync namespace. Omit for local-only mode. |
--sync-debounce-ms <MS> |
500 | Writer-only: auto-sync batching window. |
--memory-limit <SIZE> |
(unlimited) | Whole-session memory ceiling, read or write mode. Suffixes: K, M, G. |
--disk-limit <SIZE> |
(unlimited) | File-backed session disk ceiling. Never authorizes writes; invalid for :memory:. |
--maintenance <MODE> |
engine-owned |
Maintenance schedule for this process: engine-owned starts the database worker when work is declared; caller-driven starts no worker and leaves scheduling to .maintenance run or the embedding host. Writer-only for file-backed stores. |
--maintenance-poll-ms <MS> |
persisted value, else 60000 |
Positive engine-owned poll interval in milliseconds. A supplied value is persisted for a file-backed database; omitting the flag uses that persisted value on later opens rather than overwriting it. Writer-only for file-backed stores. |
Per-invocation read ceilings and deadlines (see Declared Limits):
| Flag | Default | Governs |
|---|---|---|
--read-result-rows |
500 | Complete ordinary-result rows; also the maximum one cursor fetch may request |
--read-result-bytes |
4 MiB | Canonical bytes of one complete result, metadata page, or metadata object |
--read-work |
50,000 | Items examined by one read or one fetch |
--read-active-ms |
5,000 | Active execution per read or fetch |
--read-memory |
16 MiB | Temporary memory held by one read |
--read-cursor-page-rows |
100 | Fetch size when the row count is omitted |
--read-cursor-page-bytes |
1 MiB | Canonical bytes in one cursor page |
--read-cursor-idle-ms |
300,000 | Time allowed between cursor fetches |
--read-cursor-lifetime-ms |
1,800,000 | Total cursor lifetime |
--read-hydration-notice-ms |
1,000 | Threshold for the loading and statement-progress notices |
--read-owner-connect-ms |
1,000 | Owner-route connect + handshake deadline |
--read-owner-routing-retry-ms |
1,000 | Owner startup/shutdown race retry window |
--read-owner-response-ms |
11,000 | Complete owner reply after admission |
All limit values must be positive; configuration is refused before open (exit 2) unless
cursor_page_rows <= result_rows, cursor_page_bytes <= result_bytes, and
cursor_idle_ms <= cursor_lifetime_ms.
The environment is not a behavior surface. The CLI reads exactly two declared-behavior
environment variables — CONTEXTDB_DB_PATH and CONTEXTDB_OWNER_READ_RUNTIME_DIR, the two things
a process must know before flags exist — and a flag always wins over either. One more variable is
read, but only as a platform location input, never a behavior override: XDG_RUNTIME_DIR locates
the per-user runtime directory the owner channel falls back to when --owner-read-runtime-dir is
not set (see Owner Inspection) — it picks where the channel
lives, never whether it runs or how it behaves. No other environment variable exists to the CLI,
with one observability exception: RUST_LOG changes log verbosity on the diagnostic stream and
nothing else — never routing, limits, results, or exits.
The first store-reading command resolves how this session reads (.help, .trace, .quit,
.exit, .sync status, and .owner status never resolve a route). At route selection the CLI
emits exactly one structured notice on stderr, and the route never changes within the session:
{"notice":{"class":"io","message":"…","detail":{"kind":"read_route","route":"file","snapshot_at":"2026-08-16T09:30:12Z"}}}route is file (a direct read of the committed snapshot) or owner (a live process owns the
store and serves this session over its authenticated local channel — same commands, same
shapes). On the file route snapshot_at states the committed-snapshot moment this session
serves — a long-open read terminal is a snapshot, not a live view; on the owner route it is
null (an owner serves live committed state). If the owner
disappears mid-session the statement fails with owner_disconnected, nothing of that result is
published, and the session never silently falls back to the file; a new invocation routes
afresh.
Two liveness notices share the --read-hydration-notice-ms threshold (default 1 s), both on
stderr so --json stdout stays pure: while the first read is still loading the store, a
hydration notice with bytes loaded so far; once a statement is executing, a
statement_progress notice with elapsed time and the rows and bytes produced so far, refreshed
until the statement completes or refuses — a long deliberate export is never mistaken for a
hang.
Under --json, stdout is JSON Lines: one complete JSON document per statement or
meta-command, and nothing else. Every successful ordinary SELECT is one namespaced,
column-carrying document:
$ echo "SELECT * FROM entities;" | contextdb ./my.db --json
{"result":{"columns":["id","name"],"rows":[{"id":"550e8400-…","name":"sensor-1"}]}}Every meta-command emits a document whose top-level key names its payload:
| Command | Document |
|---|---|
.tables |
{"tables":{"items":[…],"has_more":bool,"continuation":"…"|null}} |
.schema <table> |
{"schema":{"table":…,"columns":[…],"primary_key":[…],"indexes":[…],…,"ddl":…}} — the full declared contract, namespaced |
.events status |
{"events_status":{"items":[…],"has_more":bool,"continuation":"…"|null}} |
.maintenance status |
{"maintenance":{…}} |
.explain <sql> |
`{"explain":{"physical_plan":…,"runtime_trace":true |
.cursor open / .cursor fetch |
{"cursor":{"columns":[…],"rows":[…],"has_more":bool}} |
.cursor close |
{"cursor":{"closed":true}} |
.owner status |
{"owner":{"state":…,…}} (see Owner Inspection) |
.trace on|off |
{"trace":"on"} / {"trace":"off"} |
.sync policy [FOR table] |
{"result":{"columns":[…],"rows":[{…}]}} — persisted tenant policy |
.sync bindings |
{"result":{"columns":[…],"rows":[{…}]}} — persisted authenticated bindings |
.sync outcomes FOR table [WHERE ...] [LIMIT n OFFSET m] |
{"result":{"columns":[…],"rows":[{…}]}} — durable unit outcomes |
.sync status (read session) |
{"sync_status":{"message":"no sync in this session — …"}} |
.sync push / .sync pull / .sync reconnect / .sync destination / .sync auto (write session) |
The shipped sync documents ({"sync":…}, {"sync_push":…}, {"sync_pull":…}, …) |
.help |
{"help":["line", …]}, on stderr |
.schema returns the table's declared contract as data — columns (type, nullability,
key/unique/immutable flags, references and propagation clauses, acl_references naming the
grant table and grant column an access-controlled column is authorized against, vector
quantization, rank policy), primary_key, indexes, state_machine, retain, history, sync_direction,
conflict_policy, dag_edge_types, propagate, and ddl, the exact text the human .schema
prints. Five fields are always present whether or not the table declared them — immutable
(false), dag_edge_types ([]), propagate ([]), indexes ([]), and each column's
default / immutable / unique / expires — so a consumer reads those as values, not as
evidence of a declaration. Every other policy — state_machine, retain, history,
sync_direction, conflict_policy — is absent when the table never declared it, rather than
filled with a default nobody wrote.
Everything that is not a result goes to stderr as a JSON document — errors, notices, traces,
and .help:
- Errors, one document per error:
{"error":{"class":"sql|sync|io|usage","message":"…","detail":{"kind":"…", …}}}.classanddetail.kindare the stable contract to branch on;messageis prose for a person and its wording changes freely.lineis present when the CLI knows which input line the statement started on. See Error Classification for every stable kind. - Notices — the same envelope with
"notice": something worth seeing that is not a failure. The route, hydration, and statement-progress notices above are the notice family's read-surface members. - Execution traces from
.trace on, as{"trace":{…,"rows_examined":N}}. .help, as{"help":["line", …]}— guidance for a person, not a result.
The promise starts once the arguments have parsed: a malformed command line is rejected by the
argument parser with its own human rendering and exit 2; everything after that point is JSON.
Every successful ordinary SELECT publishes its complete result — no renderer truncates, and
there is no row cap to disable (--all does not exist). Under shipped defaults a result
succeeds only when it has at most 500 rows (result_rows) and its canonical encoding is at most
4 MiB (result_bytes). Crossing either ceiling publishes no rows for that statement and
refuses with owner_limit_exceeded, exit 1. The refusal
carries the refused statement verbatim (detail.statement) and teaches both escapes,
route-aware:
-
File route: raise the matching
--read-*ceiling for a deliberate one-shot export (a work refusal names--read-workand does not suggest result paging), or page with the cursor when the result row or byte ceiling is the limit. The detail carries the copy-ready.cursor open <statement>inremedy_command, and human mode prints the whole refusal as one line:Error: the answer went past the rows this read is allowed: 500 rows; .cursor open SELECT * FROM big; raise --read-result-rows / --read-result-bytes for a deliberate one-shot export -
Owner route: the ceiling is owner-imposed — a caller can lower but never raise owner policy. The refusal names the matching writer-side
--owner-read-*setting; a work refusal names--owner-read-workand does not offer cursor paging as a remedy. A writer's own reads use--read-*, including the default--read-workof 50,000;--owner-read-*governs only the read service it exposes to other sessions.
Human mode renders a successful ordinary SELECT as a bordered +---+ table — a rule line, the
column names, a rule line, one row per line, a closing rule line — and ends it with exactly one
footer: (N rows), on its own line. That is the whole rendering (the REPL sample under
REPL is the same table), and there is no built-in pager — pipe to less. Cursor pages
are the one read result that is not boxed: .cursor open / .cursor fetch print one row per
line, values separated by |, with no border, and close the page with its own footer —
(N rows, has_more: <true|false>) — rather than the (N rows) an ordinary SELECT ends with. The same bounds apply to a --write session's
SELECTs — a --write session executes write-classified statements normally, and its reads are
bounded like anyone's; the embedded Database::execute API keeps its uncapped library contract.
The SQL dialect itself (types, functions, graph and vector clauses) is documented in
docs/query-language.md.
One cursor per CLI session (piped sessions included):
.cursor open <SELECT or SHOW VECTOR_PARTITIONS statement>— opens one committed snapshot, returns the first page..cursor fetch [rows]— next page; omittingrowsusescursor_page_rows(default 100); an explicit count above the effectiveresult_rowsrefuses withowner_limit_exceededwithout ending the read — the refusal carries the copy-ready.cursor fetch <effective-limit>that works, and running it pages on from exactly where the cursor stopped..cursor close— releases the cursor.
.cursor open accepts exactly one read-only SELECT or SHOW VECTOR_PARTITIONS statement. A write refuses with write_requires_flag; any other
statement is cursor_invalid_statement and never executes. A second
.cursor open refuses with cursor_already_open until the first closes; independent CLI
sessions have independent cursors.
A page contains only complete rows and stops at the requested row count or cursor_page_bytes;
a single row that cannot fit a page even alone refuses with owner_limit_exceeded naming
cursor_page_bytes and the advice to select fewer columns — no value is cut in half.
closes the cursor automatically. Draining or closing past the end is never an error: a later
.cursor fetch returns one empty success page (rows: [], has_more: false) and a later
.cursor close succeeds as a no-op — so a fetch-until-empty loop always exits clean.
cursor_not_found fires on either route when a cursor never existed, was explicitly closed, or
belongs to another session. On the live-owner route only, if owner shutdown drains a cursor or its
prepared page before delivery, the request instead receives owner_not_serving. A mis-sized
.cursor fetch <n> is the one refusal that does NOT end the read:
it is checked before any row is read, so nothing is consumed and the cursor keeps its place on
both the direct-file and live-owner routes. Everything else that stops a cursor ends it and
releases its snapshot and memory — exhaustion, .cursor close, cursor_expired, losing the
owner, and a single row too wide for cursor_page_bytes.
table far larger than one work budget pages to exhaustion under shipped defaults.
cursor_lifetime_ms refuses with cursor_expired, naming idle or lifetime and pointing at
a fresh .cursor open.
A --write session may use the cursor while no transaction is open; .cursor open during an
active transaction refuses with cursor_transaction_active without disturbing the transaction.
.tables and .events status return as many complete items as fit in result_bytes, plus
continuation state; they never use the SQL cursor:
.tables [--continue <continuation>]
.events status [--continue <continuation>]
Each sink item reports uncertain separately from delivered and
permanent_failures. It counts external attempts whose outcome could not be
confirmed, including an attempt interrupted by a crash. A pending action may
still be retried with the same action identity; this count does not assert that
the recipient saw or did not see it. After an unclean exit, the file route may
require a writable reopen to repair the store before any read-only file
session can open. The owner route then reports the pending uncertainty before
a sink callback is registered or delivery resumes. After a clean close, the
file route reports the same history without a writer.
continuation is a string exactly when has_more is true and null when it is false; a
continuation is accepted only by the command that issued it (anything else refuses with
invalid_continuation). Every human page ends with a has_more: <true|false> line — printed on an
exhausted page too — and when it is true the exact follow-up command comes on the next line.
.schema <table> and .maintenance status are single complete responses. If the complete
response does not fit result_bytes, no partial metadata is published; the refusal is one valid
document whose detail names result_bytes, required_bytes, and required_setting rendered as
effective result_bytes >= <required_bytes>. A single .tables/.events status item that
cannot fit even an empty page refuses the same way — never a partial or oversized page. Both
routes have identical shapes, continuation mechanics, and refusal fields. Consistency across
pages differs by route: reading an idle file, your whole session is one fixed snapshot, so pages
are always mutually consistent; reading through a live owner, each page reads current committed
state in stable item order — an item never appears on two pages, an item that existed unchanged
throughout is never skipped, and an item created or dropped between pages may or may not appear
(as psql's catalog listings behave under concurrent DDL).
Every read threshold is declared configuration. A direct reader spends its own process resources
and uses its requested values; on the owner route the effective value is, field by field, the
stricter of your request and the owner's advertised maximum — you can lower owner policy, never
raise it. The writer/server side declares its policy
with the mirrored --owner-read-* flags plus --owner-read-concurrency (default 4),
--owner-read-request-ms (10,000), --owner-read-shutdown-drain-ms (10,000),
--no-owner-reads, and --owner-read-runtime-dir <absolute-path>.
The simplest way to start — no server, no network:
contextdb ./my.db # read-only inspection of an existing store
contextdb ./my.db --write # create/mutate, persisted to file
contextdb :memory: # ephemeral writable scratch database, lost on exit:memory: is always writable: mutation, transaction control, and sync configuration work
without --write, and an explicit --write is an accepted no-op. It creates no file, no
companion, no owner channel, emits no route notice, and reports owner state not_applicable.
Every --read-* and --owner-read-* flag, and --no-owner-reads, is invalid with :memory:
(exit 2).
Sync is writer capability: --sync-endpoint and --tenant-id configure a --write session's
edge enrollment, exactly as before. A read session's .sync status prints exactly: no sync in this session — this reports the CLI session only, not the store; a live owner's sync state belongs to that owner process.
For a file-backed edge, a bare pasted ticket uses the durable adjacent identity at
<canonical-db-path>.fabric-identity.key; an explicit identity=<key-file> always wins. A
memory-backed edge must name an explicit persisted identity in its endpoint. If the endpoint is
down, sync prints one clear warning line rather than failing hard.
Logs never share a stream with machine output: CLI logs go to stderr so they don't interfere
with query output, and the server keeps its logs on stderr whenever its stdout carries a machine
document (--json). The default level is ERROR; raise it with RUST_LOG=debug — verbosity is
the only thing that variable can change.
This section describes the maintenance subcommands and their operator contracts.
Operational subcommands are part of the one typed command tree and appear in ordinary --help.
contextdb reset <PATH> --force
contextdb diagnose <PATH>
contextdb snapshot export <PATH> <NEW_ARTIFACT> [--json]
contextdb inspect key <SNAPSHOT_OR_DATABASE_PATH> --table <TABLE> --key-json <NATURAL_KEY_JSON> [--column <COLUMN>]... [--json]
contextdb inspect blob <SNAPSHOT_OR_DATABASE_PATH> --hash <64_HEX_CHARS> [--json]
contextdb inspect sync-apply-state <SNAPSHOT_OR_DATABASE_PATH> [--json]
contextdb purge <PATH> --table <TABLE> --force
| Command | What it does |
|---|---|
reset <PATH> --force |
Recreate a wedged or corrupt store from scratch. Destructive. |
diagnose <PATH> |
Report a store's format and schema layout read-only, never modifying it. |
snapshot export <PATH> <NEW_ARTIFACT> |
Publish a transactionally consistent, purge-fenced backup artifact. |
inspect <SUBCOMMAND> <SNAPSHOT_OR_DATABASE_PATH> |
Read durable key and media state from a snapshot artifact or database file. |
purge <PATH> --table <TABLE> --force |
Force-gated whole-table authoritative erasure. |
Destructive: deletes the existing file and creates a fresh, empty current-format store. Requires
the explicit --force flag (exit 2 without it, nothing attempted) — restore anything you need
from a backup or a healthy sync peer FIRST.
Reads the store's format marker and top-level schema layout through a read-only handle and reports whether the store is readable, was written by an earlier build, or is corrupt or truncated, without writing to the path. A store written by an earlier build is not read. Move it aside and create the store again.
Uses the engine's transactionally consistent export path. The artifact path must not already exist. If an authoritative purge wins the race after capture, publication is refused instead of producing a backup that silently resurrects the purged lineage.
inspect never opens the supplied path directly: it copies into a private temporary directory,
opens only the disposable copy, and emits a bounded report with no raw media bytes, tag names,
identity material, or storage paths. inspect key reports a row's retained versions (newest
128, with explicit truncation), lineage, and up to 16 requested columns within a 1 MiB budget;
inspect blob reports an engine-held blob's fences and transfer progress, never the payload;
inspect sync-apply-state emits one opaque deterministic digest plus category counts for
before/after atomic-sync audits. A snapshot export artifact is the guaranteed-consistent
input; a live file gives whatever state exists at copy time.
inspect key's --key-json takes the row's natural key as a JSON object with exactly three
fields — column (the leading key column), value (that column's value), and rest (the
remaining [column, value] pairs of a composite key, [] for a single-column key). value is a
typed value, written as a one-key object naming the type: {"Uuid": "<uuid>"},
{"Text": "<string>"}, {"Integer": 42}. A bare "<uuid>", a {"id": "<uuid>"} map, or a
[["id", …]] pair list are all rejected by the deserializer. One worked call:
contextdb inspect key ./my.db --table decisions \
--key-json '{"column":"id","value":{"Uuid":"550e8400-e29b-41d4-a716-446655440000"},"rest":[]}' \
--column description --json
# {"lineage":null,"retained_versions":[{"created_tx":1,"deleted_tx":null,"lsn":2,"row_id":1,
# "values":{"description":{"kind":"text","omission_reason":null,"omitted":false,
# "source_units":15,"value":{"Text":"adopt contextdb"}}}}],
# "total_retained_versions":1,"versions_truncated":false}A composite key (tenant, id) is {"column":"tenant","value":{"Text":"acme"},"rest":[["id",{"Uuid":"…"}]]}.
contextdb purge <path> --table <table> --force; narrower selection remains SQL
PURGE ... WHERE in a --write session.
An interactive session prints one start banner naming its mode — read sessions
read-only session — pass --write to mutate, write sessions
write session — mutations are enabled — and prompts contextdb(ro)> or contextdb> .
Neither the banner nor the prompt appears when the session is not interactive.
contextdb(ro)> SELECT * FROM entities;
+--------------------------------------+----------+
| id | name |
+--------------------------------------+----------+
| 550e8400-e29b-41d4-a716-446655440000 | sensor-1 |
+--------------------------------------+----------+
(1 rows)
Every statement ends with a ;; pressing Enter before one continues the statement under the
...> prompt. Ctrl-C during a running statement or cursor fetch cancels only that statement and
returns to the prompt with the session and any open cursor intact; Ctrl-C at an idle prompt
clears the typed line; Ctrl-D or .quit ends the session.
Runtime budget control is SQL-driven and classified like everything else: SHOW MEMORY_LIMIT /
SHOW DISK_LIMIT are reads; SET MEMORY_LIMIT '512M' / SET DISK_LIMIT '1G' are writes and
need a --write session. Input that does not parse as any
statement is refused before execution with an ordinary sql-class parse error naming the
offending token, identically in read and write sessions.
Every spelling in this table comes from one place — canonical_help_signatures() in the
command registry, which is also what the REPL's own .help is generated from. There is no
second command table anywhere, so a command cannot appear in one surface and be missing from
the other.
A meta-command consumes exactly one line — it never accumulates across newlines until a ;.
Paste a meta-command's whole invocation, including any SQL argument, on one line.
| Command | Alias | Read/Write | Description |
|---|---|---|---|
.help / .help vector / .help propagate |
\? |
session | Show available commands / specialized grammar. |
.quit / .exit |
\q |
session | Exit the REPL. |
.tables [--continue <c>] |
\dt |
read | List table names; large listings resume via continuation. |
.schema <table> |
\d <table> |
read | Show the table's full declared contract; complete-or-refuse. Per-column IMMUTABLE, vector quantization, and RANK_POLICY clauses render alongside NOT NULL / PRIMARY KEY. |
.explain <sql> |
read | Show the route: ordinary read-only queries execute through the bounded reader, vector similarity is inspected passively, and writes are only planned. | |
.trace on / .trace off |
session | Toggle one-line execution traces. | |
.events status [--continue <c>] |
read | Bounded, resumable event/sink/route/schedule health. | |
.maintenance status |
read | One complete bounded maintenance-state response. | |
.maintenance run / .maintenance compact |
write | Run one synchronous maintenance cycle / explicitly drain file compaction. | |
.cursor open/fetch/close |
read | Bounded traversal beyond an ordinary-result ceiling. | |
.owner status |
status | Owner policy and state; works at capacity. | |
.sync status |
session | Current CLI session's sync state only. | |
.sync push/pull/reconnect/destination/auto |
write | Sync operation and recovery. |
Each conventional alias is one spelling of the command beside it and carries that command's classification exactly:
| Alias | Spells |
|---|---|
\dt |
.tables |
\d |
.schema |
\q |
.quit |
\? |
.help |
\trace and \sync do not exist.
.maintenance run drives one synchronous maintenance cycle on this process: it
expires rows whose table declared RETAIN, reclaims superseded versions on
HISTORY CURRENT ONLY tables, advances vector index work, and may attempt
automatic file compaction. Use it when the next statement must see that cycle
finish — in particular a --write session opened with --maintenance caller-driven, which starts no background worker and leaves scheduling to this
command. An idle file-backed store prints one line of counters:
pruned_rows=0 rows_deferred_for_readers=0 currency_pruned_versions=0 pruned_trigger_audit_rows=0 auto_compact_ran=true vector_built_indexes=0 vector_remaining_indexes=0 vector_nonempty_partitions=0 vector_ready_partitions=0 vector_built_partitions=0 vector_remaining_partitions=0 vector_first_failure=none vector_failure_operation=none vector_failure_requested_bytes=none vector_failure_available_bytes=none vector_failure_current_bytes=none vector_failure_budget_limit_bytes=none vector_failure_message=none vector_failure_recovery_action=none vector_failure_recovery_instruction=none
--json emits one maintenance_cycle document with those same facts as named
fields (pruned_rows, rows_deferred_for_readers, reclaimed_bytes,
file_shrank, currency_pruned_versions, currency_versions_deferred_for_readers,
currency_redb_compacted, pruned_trigger_audit_rows, vector, compaction).
Compaction byte counts and duration depend on the file.
.explain <sql> has three deliberately different paths. An ordinary relational SELECT or WITH
executes through the session's normal bounded read route, under the same row, byte, work, memory,
deadline, and cancellation ceilings. Its explanation is the route that actually ran: human output
shows the physical strategy, chosen index, pushed predicates, rejected candidates, and sort elision;
JSON carries those route facts plus real rows_examined and reports runtime_trace: true.
A vector nearest-neighbour query whose parsed ORDER BY uses <=> is passive. It reads only safe
catalog, authorization, lifecycle, and policy metadata; it does not run the search, load graph or
raw-vector pages, replay or build an index, start repair or compaction, or change residency and
charges. Its complete redacted vector-route disclosure reports runtime_trace: false. A write is
also planned without execution, so .explain DELETE FROM t and .explain INSERT INTO t ... never
change rows and likewise report runtime_trace: false. Use .trace on to print the runtime trace
after a statement you execute directly.
The trace .trace on prints is the value a Rust caller reads from QueryResult.trace: the REPL
renders that QueryTrace field of the executed statement's result and adds nothing to it, so a
scripted session and an API caller describe the same run in the same fields.
.schema <table> reflects the table's full enforced policy — RETAIN, HISTORY,
STATE MACHINE, PROPAGATE, sync direction and conflict clauses, per-column IMMUTABLE,
ACL REFERENCES, vector quantization, RANK_POLICY — and its printed DDL re-parses to a table
with the same policy, so .schema output remains a valid way to snapshot or replay a definition.
Four things the printed DDL does not reproduce literally, and what to read instead:
- Column
DEFAULTclauses are omitted from the rendered DDL. A table declaredstatus TEXT NOT NULL DEFAULT 'active'prints back asstatus TEXT NOT NULL. The default is still declared and still applied onINSERT; it is the printed text that drops it, so replaying that text alone rebuilds the table without its defaults. Read the default from--jsoninstead — each entry ofschema.columnscarries it verbatim indefault("'active'","NOW()",nullwhen there is none). STATE MACHINEfrom-states print in sorted order, not the declared one.STATE MACHINE (status: draft -> [active, rejected], active -> [superseded])prints back asSTATE MACHINE (status: active -> [superseded], draft -> [active, rejected])— the transitions are held as a map keyed by from-state, so the keys come back sorted while each state's target list keeps the order you declared. Both surfaces share that map, so--jsonschema.state_machine.transitionsorders its keys the same way. The graph is identical; compare it as a set of edges rather than diffing the text against what you typed.- The
CONTEXT_IDcolumn marker is omitted from the rendered DDL. A table declaredcontext_id UUID CONTEXT_IDprints back ascontext_id UUID. This marker is not decoration: it is how the row gate finds the column holding each row's context, so a read session that declared a narrowed set of contexts filters by it. Replay the printed text alone and the rebuilt table has noCONTEXT_IDcolumn at all — every row is served to every reader, narrowed or not. There is currently no other surface (printed DDL or--json) that names which column carries this marker; treat a.schemasnapshot of such a table as incomplete for replay and keep the originalCREATE TABLEtext instead. - The
SCOPE_LABELcolumn marker is omitted from the rendered DDL. Same mechanism, same consequence: a table declared with aSCOPE_LABELcolumn prints back without the clause, the row gate has no column to read a scope from on the replayed table, and a read session that declared a narrowed set of scopes gets every row instead of only its own. As withCONTEXT_ID, neither the printed DDL nor--jsonnames this column today, so replaying.schemaoutput alone silently drops the narrowing.
Use SQL SHOW VECTOR_INDEXES to inspect a vector column as a whole. It keeps the familiar
table, column, dimension, quantization, vector_count, and bytes fields, and adds the
declared partition key and effective limit, live and snapshot-retained layout counts, search mode,
base range, pending inserts, tombstones, durable and charged vector/index bytes, query and
maintenance state, work progress, unavailable/stalled counts, and broad-route state. bytes remains
the compatibility total of charged vector bytes plus charged index bytes; do not add those three
figures together.
Use the detail view when one layout needs attention:
SHOW VECTOR_PARTITIONS;
SHOW VECTOR_PARTITIONS LIMIT 50;
SHOW VECTOR_PARTITIONS FOR vector_items.embedding;
SHOW VECTOR_PARTITIONS FOR vector_items.embedding LIMIT 50 OFFSET 100;The grammar is SHOW VECTOR_PARTITIONS [FOR <table>.<column>] [LIMIT <count> [OFFSET <start>]]. LIMIT works with or without FOR, and omitting OFFSET means zero. The
unpaged form is an ordinary result: if it crosses the active result-row or result-byte ceiling,
ContextDB refuses the complete result instead of truncating it. Page that same stable result with
the ordinary cursor:
db="$(mktemp -d)/vector-inspection.db"
contextdb "$db" --write --json --read-cursor-page-rows 1 <<'SQL'
CREATE TABLE vector_items (
id INTEGER PRIMARY KEY,
scope INTEGER NOT NULL,
embedding VECTOR(3) PARTITION_KEY (scope)
);
INSERT INTO vector_items VALUES (1, 10, '[1,0,0]'), (2, 20, '[0,1,0]');
.maintenance run
.cursor open SHOW VECTOR_PARTITIONS FOR vector_items.embedding
.cursor fetch
SQLA populated caller-driven result is immediately actionable; for example, a summary can report
vector_count=120, bytes=44064, charged_vector_bytes=15360,
charged_index_bytes=28704, pending_inserts=3, tombstones=2,
query_state=ready, maintenance_state=idle, broad_route=fanout, and
broad_route_state=ready. Its partition row can report
partition_key={"context_id":"camera-17"}, base_generation=4, base_tx=891, the same pending
counts for that route, maintenance_reason=tombstones, and
recovery_action=run_maintenance_cycle. The compatibility bytes value is exactly
15360 + 28704; durable byte columns are separate file-storage facts and are zero for an
in-memory database.
Rows are in stable (table, column, canonical_partition_key) order. Each reports its key, live and
retained rows, serving base generation/transaction, tail and tombstone counts, durable and charged
bytes, availability, maintenance state and progress checkpoint, and a recovery action. The empty
key {} is the one unpartitioned layout; it appears even before its first vector. An empty
partitioned column has no detail row until a key tuple receives a vector.
broad_route is fanout when a broad filter searches the eligible partitions separately and maintained when it uses the maintained broad route. broad_route_state is ready, partial, or unavailable; partial reports maintained-route build progress and never permits a partial answer.
query_state is empty, ready, or unavailable for a layout; the whole-index view can also say
partial. A layout's maintenance_state is idle, building, replaying, compacting,
repairing, or stalled. The whole-index summary is idle when every layout is idle; it reports
building, replaying, compacting, or repairing only when all non-idle work has that one state;
it reports active when several non-stalled activities coexist, stalled only when every busy
layout is stalled, and mixed when stalled and live work coexist. Per-partition inspection reports
desired topology (desired_hnsw_m, desired_hnsw_ef_construction) separately from the complete
serving graph (serving_hnsw_m, serving_hnsw_ef_construction) and their policy revisions.
desired_hnsw_ef_search is the current query policy resolved for inspection (k = 1);
serving_hnsw_ef_search reports that same current breadth when a serving graph exists, and is
NULL without one. It is not a frozen build setting. An actual query resolves breadth at its own
k: explicit EF_SEARCH is raised only to k, while omission uses at least the profile and
10 * k. Query-only changes therefore need not advance serving_policy_revision, which identifies
the graph's build. A pending topology replacement remains visible in the desired/serving values. The response uses stable
words, not free-form errors:
availability_reasonisnone,initial_build,replay,corrupt_base,corrupt_changes,incompatible_format,resource_stalled, orbuild_failure.maintenance_reasonisnone,initial_build,new_changes,tombstones,corrupt_base,corrupt_changes,incompatible_format,memory_limit,disk_limit, orbuild_failure.recovery_actionisnone,wait_for_automatic_work,run_maintenance_cycle,raise_memory_limit,raise_disk_limit_or_free_space, orinspect_build_failure.
corrupt_base identifies the serving-base artifact; corrupt_changes identifies committed changes
after that base. A healthy, caught-up layout reports maintenance_state=idle, both reasons as
none, and recovery_action=none under either maintenance policy. Real initial-build,
new_changes, tombstone, replay, corruption, or incompatible-format work reports
wait_for_automatic_work under engine-owned maintenance and run_maintenance_cycle under
caller-driven maintenance (the CLI spelling is .maintenance run). A failed reservation or build
overrides that policy advice with the action that can clear it: memory_limit maps to
raise_memory_limit, disk_limit maps to raise_disk_limit_or_free_space, and build_failure
maps to inspect_build_failure. These failure facts remain visible until a successful publication
clears them or a policy change removes the work that failed.
.maintenance run reports the vector counters from that cycle in text and under
maintenance_cycle.vector in JSON: built and remaining indexes, plus nonempty, ready, built, and
remaining partitions, and first_failure (null when every attempt succeeded). A partition-local
failure does not discard successful counts or skip cycle-closing reclamation, retirement, and
automatic-compaction work. Engine-owned maintenance is the default and polls every 60 seconds unless
the database's persisted interval is changed with SET MAINTENANCE_POLL_INTERVAL or
--maintenance-poll-ms. Each wake samples the finite backlog and advances every needy partition
sequentially within the declared memory and work limits, with never-built partitions before
previously built repair or consolidation work. There is no separate per-wake partition-count cap:
the work is finite because the wake uses one fixed candidate sample. Caller-driven mode starts no maintenance thread; each
.maintenance run applies that same all-needy sampled cycle. New writes or a resource refusal can
still leave work for a later cycle.
When first_failure is present, the text receipt and JSON first_failure_details retain a safe
operation, message, recovery instruction, and the requested, available, current, and limit byte
fields that apply. A memory refusal points to SET MEMORY_LIMIT <SIZE> or --memory-limit <SIZE>;
a disk refusal points to SET DISK_LIMIT <SIZE>, --disk-limit <SIZE>, or freeing disk space.
Inspection only reads lifecycle metadata: it never loads an evicted graph, replays a tail, builds a route, or starts repair. It reports current local lifecycle state and recovery advice.
These SHOW VECTOR_* commands require the existing unrestricted admin database handle. A narrowed
handle is refused rather than receiving partial totals. Use .explain for that caller's redacted
route instead. Restricted explanations omit per-partition adaptive HNSW values as well as hidden
keys and populations. Unrestricted explanations pair the snapshot's serving topology/build revision
with the query's current EF_SEARCH; a stale build revision does not imply stale search breadth.
When a partition limit blocks a write, raise it with ALTER TABLE ... ALTER COLUMN ... SET MAX_PARTITIONS ...; lowering below live-plus-retained use is refused until retained
snapshots finish.
SET MEMORY_LIMIT 2G and CLI --memory-limit 2G both resolve to 2147483648 bytes.
SHOW MEMORY_LIMIT reports the resolved limit; it counts charged memory, which is separate from
whole-process RSS. Under pressure, idle vector graphs are reclaimed in least-recently-used order
only until the selected graph's complete load fits. Without SET MEMORY_LIMIT or
--memory-limit, loaded graphs remain resident.
Use SQL to declare or change a vector column's AUTO_INDEX_AT, HNSW, and CONSOLIDATION policy;
CONSOLIDATION controls when maintenance rebuilds a healthy graph, and its rules and defaults are
in Vector Similarity Search. Foreground vector writes
remain searchable while construction runs.
AUTO_INDEX_AT and EF_SEARCH affect newly opened queries without a graph rebuild; changing M
or EF_CONSTRUCTION schedules a replacement while the previous complete graph serves. The
declarations persist with the schema. SHOW VECTOR_INDEXES and SHOW VECTOR_PARTITIONS expose
declared, desired, and serving policy values and revisions. Their declared_consolidation_mode is
NULL for a silent declaration, thresholds for explicit percentages, and none for explicit
CONSOLIDATION NONE; the effective mode remains thresholds or none. See
Vector Similarity Search for canonical declarations,
ALTER ... SET ... forms, default/silence rules, and numeric defaults.
All sync operation requires a --write session with --tenant-id. Without --tenant-id,
.sync status and .sync auto answer Sync not configured and exit 0; the action
subcommands fail (exit 1) so a scripted push && shutdown never reads "not configured" as
"pushed". Sync direction (SYNC PUSH ONLY / PULL ONLY / TWO WAY / OFF) and conflict
policy (KEEP FIRST / KEEP LATEST) are declared in DDL. Auto-sync (.sync auto on) debounces
background pushes (--sync-debounce-ms); on exit the CLI performs a final push regardless.
Every file-backed writer — the --write CLI, contextdb-server, an embedding application —
serves bounded same-machine reads by default over an authenticated local channel that never
accepts a write. That channel is what a plain contextdb <path> session uses when a live owner
holds the store. It is an administrative plane for inspection, not an application database pool:
an application serves its own users in-process through the read kernel, and heavy remote read
serving belongs to a sync-following replica.
.owner status describes the file-backed process owner — never sync health. It resolves no
row-reading route and stays usable when every reader slot is occupied:
| Situation | State | Exit |
|---|---|---|
| No owner | not_running |
0 |
| Owner serving | serving |
0 |
Deliberately disabled (--no-owner-reads) |
serving_disabled + reason |
0 |
| Expected to serve but cannot | not_serving + reason |
1 |
:memory: |
not_applicable |
0 |
| Windows | not_applicable + reason platform_unsupported |
0 |
Windows has no local owner channel: a store held by a live writer cannot be inspected there — close the writer and rerun. Direct file reading of an idle store works everywhere.
The serving JSON reports every limit and timeout as {"value":N,"source":"default|override"},
plus concurrency, active_readers, and database_memory (available_bytes is null when
database-wide memory is unbounded).
An owner serves at most concurrency (default 4) simultaneous readers; the next reader is
refused immediately with owner_at_capacity — retry, close another inspecting session, or raise
the writer's --owner-read-concurrency. No queue forms. An open owner cursor holds one slot for
its lifetime; direct readers have no owner slot ceiling.
The channel lives in the per-user runtime directory ($XDG_RUNTIME_DIR/contextdb, with
/run/user/<uid> as the systemd fallback; --owner-read-runtime-dir overrides — required for
containers and packaged services). That one directory serves BOTH sides: give the same
--owner-read-runtime-dir (or CONTEXTDB_OWNER_READ_RUNTIME_DIR) to a reading session and it
looks for the channel exactly where the writer put it.
Owner-channel startup failure never fails the database open:
the writer keeps running and reports not_serving with one startup warning. Two distinct names
for the two sides of that situation: not_serving is the serving STATE on the owner's own
status surface; owner_not_serving is the refusal KIND an inspecting session receives when it
tries to reach that owner — scripts branch on each in its own place.
Reader breadcrumbs, retained-snapshot registrations, and erasure fences use the platform per-user
directory when it is available; in a container without one, they use the supplied directory.
A participant also inventories the supplied directory when configured, so a reader and writer
using different channel directories still coordinate over the same physical database through the
platform directory when it is available. Purge publication reaches every known candidate
directory. An empty candidate directory alone never
proves that there are no readers. If a candidate directory cannot be authenticated, reclamation
or purge refuses with a typed reader-coordination error naming that directory; channel startup
failure alone leaves the database open and reports not_serving.
In a container without a platform runtime directory, the reader and writer may supply different
channel directories. A direct reader keeps a byte-free advisory presence lock on the database
file after its initial load lock releases. If the writer sees that lock but cannot find the
reader's registration in any runtime directory it knows, ordinary opens and commits remain
available with a conservative retention floor. Explicit storage reclamation and authoritative
purge refuse with ReaderCoordinationUnavailable, naming the missing registration; they resume
after the unknown reader exits. This preserves the channel-directory option without requiring a
shared directory. The container must expose the same physical database file and support Linux
open-file-description advisory locks for this journey. On other Unix systems without a common
platform runtime root, ordinary writes retain a conservative floor and reclamation or purge
refuses because reader inventory completeness cannot be proved.
Coordinates sync between edge clients, and (as a file-backed writer) serves the same owner inspection channel as the writable CLI.
contextdb-server --tenant-id <TENANT_ID> [OPTIONS]
| Flag | Default | Description |
|---|---|---|
--db-path <PATH> |
:memory: |
Database file path (env bootstrap alias: CONTEXTDB_DB_PATH, flag wins). A :memory: server serves sync but never a local owner-read channel. |
--sync-endpoint <SPEC> |
(auto) | iroh:?identity=<key-file>[&port=<u16>][&relay=…][&relay-ca=…][&publish=…][&lookup=…] — transport identity and routing only. |
--response-staging-bytes <N> |
unlimited | Durable unfinished-response storage bound. |
--pre-admission-connections <N> |
128 | Incoming connection/handshake task bound. |
--pre-admission-bytes <N> |
64 MiB | Aggregate request payload reserved before route admission. |
--request-read-idle-ms <MS> |
30,000 | Closes a request making no byte progress. |
--owner-read-* / --no-owner-reads / --owner-read-runtime-dir |
shipped defaults | Owner-service policy, disablement, runtime directory (CONTEXTDB_OWNER_READ_RUNTIME_DIR bootstrap alias, flag wins). |
--ticket-file <PATH> |
(none) | Write the enrollment ticket to a file once bound. Sensitive bearer material. |
--show-ticket |
off | Print the bare ticket and exit. |
--json |
off | One JSON object with enrollment_ticket and dial_command. |
--tenant-id <ID> |
(required) | Tenant identifier. |
The four resource-policy controls are top-level flags; the endpoint string carries only transport identity and routing (identity, port, relay, publish, lookup).
In the default build a hub is reached by the direct addresses carried in its
enrollment ticket. A DHCP reassignment of the hub's IP breaks dialing until the
ticket is re-issued and re-pasted. The sticky port recorded beside the identity
key only survives restarts on the same address; LAN self-healing across IP
changes needs a build with the mdns cargo feature (lookup=mdns).
Everything else about the server — the enrollment ticket contract and its sensitivity, relay and
address-lookup configuration, restart semantics and port stickiness, files on disk
(*.fabric-identity.key, .port, <db-stem>.db.lock — the companion lock appends .lock to
the full file name), exit codes — is unchanged from the shipped behavior.
When stdin is not a terminal, the CLI runs in pipe mode — and a piped invocation is a full session: the same dot-commands, the same single session cursor, the same one route notice, so every instruction the CLI prints (including the cursor advice inside a refusal) is executable by a non-interactive agent.
- No prompt, no banner
- SQL statements may span lines and end at the first
;; meta-commands stay single-line - Results go to stdout; every error, notice, and diagnostic goes to stderr
- An
INSERTis echoed to stdout — its own statement text, on its own line, immediately before itsok (rows_affected=N)— so a scripted load reads as a transcript of what it wrote. OnlyINSERTechoes, and only here:--jsonsuppresses it to keep stdout a clean machine channel, and an interactive session never echoes because the terminal already shows what you typed - The session continues past a refused statement so one run reports all of its errors, and the
process exits
1if any statement suffered a runtime refusal
echo "SELECT 1 + 1;" | contextdb :memory:
contextdb ./my.db --write < schema.sql
echo "SELECT * FROM t;" | contextdb ./my.db && echo "OK" || echo "FAILED"Every binary reports one of four codes:
| Code | Meaning | Raised by |
|---|---|---|
0 |
Success. Every statement the run attempted succeeded. | A clean run, and any interactive session — it showed you each error as it happened, so the code reports the session rather than the statements inside it, exactly as psql and sqlite3 do. |
1 |
Error. The invocation was valid; something in the run failed. | Every runtime refusal on this surface, any SQL or engine error, a failed meta-command, a definitive sync failure, a store that could not be opened or closed. |
2 |
Usage error. The invocation itself was wrong and nothing was attempted. | An unknown flag or missing argument, an unparseable flag value such as --memory-limit 12Q, an invalid limit relationship, or an incomplete combination such as --tenant-id with no --sync-endpoint. |
3 |
A .sync push was interrupted after sending and its outcome is unconfirmed — the hub never said whether it landed, so re-pushing is the safe move. |
An interrupted push, including one that carries schema changes and the automatic final push on exit. |
Precedence: a definitive error (1) dominates an unconfirmed push (3), which dominates
success (0). A usage error (2) is terminal before anything runs, so it never competes. A
non-interactive session exits 1 when any statement was refused and 0 only when everything
succeeded; an unconfirmed push still reports 3 even from an interactive session, because
nobody can act on it once the process is gone.
A script continues after a refused statement, including a memory or disk refusal. Independent
statements still run. SHOW MEMORY_LIMIT / SET MEMORY_LIMIT and SHOW DISK_LIMIT /
SET DISK_LIMIT remain available to inspect and raise an exhausted limit, but successful recovery
does not erase the earlier error: that scripted run still exits 1.
Finer classification lives on the output: each error is one stderr document whose stable
class and detail.kind are what scripts branch on. The read surface's stable kinds, each
teaching its recovery:
detail.kind |
Class | Meaning and next action |
|---|---|---|
write_requires_flag |
sql | A read session got a mutating statement/command; add --write. |
store_not_found |
io | The store does not exist; --write creates it. |
held_by_writer |
io | A writer owns the store; a read session (drop --write) reaches its channel. The refusal names the store in store_path and, when the holder has published a record about itself, the holding process in process_id. |
held_by_readers |
io | Readers hold the file during hydration; the refusal lists them or says to retry in a moment. |
owner_not_running |
io | Rust request_owner was called on a direct-file route. |
owner_not_serving |
io | A writer holds the store but has no usable inspection channel — including a writer that has claimed the store and not published its serving decision inside the caller's declared deadlines, or a draining owner that released the requested cursor/page before delivery. |
owner_user_mismatch |
io | The owner belongs to a different operating-system user. |
owner_mismatch |
io | The responder does not own this database/run. |
owner_at_capacity |
io | Every reader slot is occupied; retry, close a session, or raise --owner-read-concurrency. |
owner_limit_exceeded |
io | A read ceiling was crossed; carries limit, the ceiling in value, the refused statement, and the route-aware remedy. |
owner_timeout |
io | A connected owner missed a configured deadline. |
owner_disconnected |
io | The owner disappeared mid-session; no partial result; start a new invocation. |
invalid_channel_data |
io | A malformed or oversized local frame. |
local_protocol_mismatch |
io | Caller and owner use different local protocol versions. |
invalid_continuation |
usage | A continuation was malformed or given to a command that did not issue it. |
cursor_already_open |
sql | The session's cursor is still open; close it first. |
cursor_transaction_active |
sql | .cursor open during an active write transaction. |
cursor_invalid_statement |
usage | .cursor open received something other than one SELECT or SHOW VECTOR_PARTITIONS statement. |
cursor_expired |
io | Idle or lifetime crossed (detail names which); reopen with .cursor open. |
cursor_not_found |
io | On either route, the cursor never existed, was explicitly closed, or is another session's (an exhausted cursor answers an empty success page instead). On the live-owner route only, a cursor/page drained during owner shutdown is owner_not_serving. |
direct_read_requires_writer |
io | Safe decode found state needing a corrective writable open; close holders, rerun with --write. |
operation_already_completed |
io | The local-channel operation had already produced its outcome before this poll reached it — an internal race guard, not a condition a caller triggers by any documented action. |
owner_route_unsupported |
io | The requested inspection kind is not implemented over the live owner's channel (today: image-state metadata); the refusal names the kind and says a direct file session answers it once no writer holds the store. |
contextdb --json database.db accepts .sync policy, .sync bindings, and
.sync outcomes FOR events in an ordinary read session. Each answer uses the existing
{"result":{"columns":[...],"rows":[{...}]}} JSON Lines envelope. Policies expose effective clauses,
version and digest; bindings add authenticated tenant and peer identities; outcomes expose root
reference, unit digest, verdict, cause, identities, acceptance position, and conflicts. These reads
need no --tenant-id, do not connect to a hub, and change no journal or watermark. A Context-,
scope-label-, or principal-constrained handle receives the typed inspection refusal.
Use --write for DECLARE TENANT TABLE POLICY, DISCARD, or PURGE. The policy and erasure SQL
contracts, including node-local predicate delivery and transaction-local discard, are in
the query language reference.