// ZDBVIEW โ€” RKYV + SQLITE TERMINAL EDITOR ๐Ÿ—„๏ธ

zdbview v0.11.1 ยท whole-disk index of shards and databases, kept until the disk changes ยท ratatui + crossterm TUI ยท generic SQLite CRUD by rowid or primary key ยท any cell editable as bytes ยท 10 recognized rkyv cache formats with structural fallback ยท full rkyv CRUD write-back (atomic, byte-identical re-serialization) ยท whole-table search with per-column filters ยท the sqlite3 shell's dot-commands and output modes ยท .dbinfo/.intck/.lint/.expert reports ยท off-thread paging, counting and search with per-core row counts ยท off-thread column statistics and frequency ยท foreign-key navigation ยท CSV import ยท VACUUM INTO backup ยท page-level .recover ยท live per-table write attribution and a WAL history walker ยท OSC 52 clipboard

GitHub Issues
// Color scheme

>_ZDBVIEW

One binary for both halves of the cache. A SQLite database gets full generic CRUD โ€” browse tables, edit any cell, insert and delete rows, run raw SQL โ€” because SQLite is self-describing. An rkyv archive is not self-describing, so it gets decoded to real key/value records when its format is one zdbview recognizes, and a structural view โ€” info, embedded strings, hex โ€” when it is not. Which backend you get is decided by the file's header bytes, never by its name.

Quickstart

# install (binary + both man pages + zsh completion)
brew tap MenkeTechnologies/menketech
brew install zdbview

# or from crates.io (binary only)
cargo install zdbview

# or build from source
git clone https://github.com/MenkeTechnologies/zdbview
cd zdbview && cargo build --release

# open a database โ€” kind is detected from the header magic
zdbview app.sqlite               # SQLite  โ†’ full CRUD
zdbview ~/.cache/zshrs/scripts.db  # rkyv   โ†’ key/value records (despite the .db name)

# force a backend
zdbview --sqlite file
zdbview --rkyv   file

# no argument โ†’ pick from recently opened files
zdbview

File type detection

The first 16 bytes are compared against the SQLite header magic SQLite format 3\0. When a full header is readable the magic decides alone โ€” its absence means the file opens in the binary inspector, whatever the extension says.

SituationResult
Header readable, magic presentSQLite backend
Header readable, magic absentrkyv / binary inspector โ€” extension ignored
File shorter than 16 bytesExtension tie-breaker: .db / .sqlite / .sqlite3 โ†’ SQLite, else inspector
--sqlite / --rkyvShort-circuits both rules
Magic present but SQLite refuses the fileFalls back to the inspector instead of failing

Trusting the magic over the name is the whole point: rkyv cache shards are routinely written under a .db name, and an extension-first reader hands those to SQLite, which then reports a corrupt database for a perfectly valid archive.

SQLite โ€” generic CRUD

The left pane lists tables and views from sqlite_master; the right pane shows one 500-row page of the selection. Every edit and delete addresses its row by rowid, selected alongside the real columns as SELECT rowid, * FROM <table> โ€” which is what makes the CRUD generic: no schema knowledge, no primary-key guessing, no per-database configuration.

KeyOperation
eEdit the selected cell. Enter runs UPDATE "<table>" SET "<col>" = ?1 WHERE rowid = ?2 with the text bound as a parameter, then reloads the page.
aInsert one row with INSERT INTO <table> DEFAULT VALUES.
dDelete the selected row after a y confirmation.
:Run an arbitrary SQL statement; the affected-row count lands in the status line and the page reloads.

Identifiers are double-quoted with internal quotes doubled, so table and column names containing spaces or keywords work. A WITHOUT ROWID table has no generic row handle, so its rows are listed read-only. Values display by type: integers and reals verbatim, text as UTF-8, NULL as NULL, blobs as <blob N bytes>.

Large databases

Nothing the grid does waits on a scan of the whole table. Measured on a 23 GB SQLite file with a 300 MB WAL, 6.5M rows in the table being browsed and a filter matching 270k of them โ€” development build on both sides, so the ratios are what to read:

OperationBeforeNow
Open, first page drawnโ€”4 ms
First page under a filter4.16 s28 ms
Step to the next / previous pagegrows with the page number21 / 36 ms
Last page (G)9.6 s1 ms
Exact row count behind a filter9.2 s, blocking1.6 s, in the background

Pages, exact counts and n/N searches each run on a worker with its own read-only connection, so a scan cannot block the display โ€” and browsing a database another process is writing can never make zdbview checkpoint its WAL. Every request carries a generation, and each connection runs a progress handler that abandons its statement once that generation is stale, so typing a filter costs one query rather than one per keystroke.

The total is never scanned for: a page fetches one row more than it shows, which is all that is needed to know whether another page exists, and the title reads 501+ until the background count lands. Paging steps by cursor rather than by offset โ€” LIMIT n OFFSET k makes SQLite walk and discard k matching rows, 6.2 s at offset 269000 against 26 ms at the start โ€” and the last page is read backwards from the end. The exact count is taken on every core by cutting the table into rowid ranges; the cuts are arithmetic, because finding balanced ones means walking the whole index, which cost 5.8 s of cold reads on that file.

rkyv โ€” recognized key/value, or structural fallback

An rkyv archive is a memory image of a Rust value plus a root pointer. The format stores no field names, no type tags and no embedded schema โ€” the originating Rust type is the schema. zdbview closes that gap with a format registry: for archives it recognizes it carries a faithful, byte-compatible copy of the producer's archive type, detects the format by its magic header, validates with rkyv::check_archived_root, and decodes real key/value records. Anything it does not recognize falls back to the raw structural views.

ViewShows
0 โ€” RecordsKey/value table for a recognized archive: keys on the left, the selected value's decoded scalar fields plus a hex dump of its blob on the right. Searchable by key. Selected automatically whenever a format is recognized.
1 โ€” InfoPath and byte size, plus the detected format name and decoded header fields when recognized.
2 โ€” StringsEvery run of โ‰ฅ 4 printable ASCII bytes with its hex offset โ€” in practice the keys, interned identifiers and string fields, in archive order.
3 โ€” Hexxxd-style dump: 16 bytes per line as offset  hex  |ascii|.

Recognized formats โ€” every magic the registry knows, with the little-endian u32 as it appears in the header. The magic sits wherever rkyv placed the root, which is near the end of the file, not at offset 0:

FormatMagicu32 (LE)Detected byRecord key
zshrs script cacheZRSC0x5A525343magicscript path
zshrs autoload cacheZRAL0x5A52414Cmagicfunction name
zshrs canonical shardZSHS0x5A534853magic, or a validated unstamped header (the recorder writes magic = 0)section/key, section[i], extras/<sub>/<key>
zshrs system shardZSHS0x5A534853magic โ€” second layout under the same magic, a flat blob mapentry key
strykelang script cacheSTRY0x53545259magic (native v4 + compat layout)script path
awkrs script cacheAWKR0x41574B52magicscript path
vimlrs script cacheVIML0x56494D4Cmagicscript path
elisprs heap-image cacheELSP0x454C5350magicscript path
pythonrs bytecode cachenoneโ€”validated try-decodesource path
rubylang / arb script cachenoneโ€”validated try-decodeu64 content hash

Magic-bearing formats are matched by header; the header-less hash-keyed shards are attempted last and gated by rkyv validation, so an unrelated archive falls through to the structural view instead of mis-decoding. Adding a format is a single registry entry: copy the producer's archive type โ€” same rkyv version (0.7) and features (validation, archive_le, size_32), same field order and types โ€” and map its magic, or add a validated try-decode for a header-less one.

The Records view is editable: a add, e edit, r rename, d delete. Each change deserializes the shard, mutates the owned value, re-serializes the whole archive and swaps it in atomically (temp + rename). Because the registry types are byte-compatible with the producer, the re-serialized archive is byte-identical to what the producing host would have written, so it keeps reading the shard normally โ€” verified by round-tripping every real cache. Unrecognized archives stay structural and read-only, because guessing field boundaries in an untyped image would produce plausible, wrong output.

Keys

KeyAction
TabSwitch focus between the table list and the row grid
j k / arrowsMove; / move the selected column in the row grid
gg / GFirst / last โ€” across pages, not just the visible rows
Ctrl-f / Ctrl-bPage forward / back (also PageDown / PageUp)
/Filter the list as you type โ€” Enter keeps it, Esc clears it
n / NNext / previous match โ€” in the displayed order, so sorting and the active filter are both respected
e a dEdit cell / insert row / delete row (SQLite) โ€” a blob cell opens in the hex editor, since bytes have no text form; rows are addressed by rowid or, in a WITHOUT ROWID table, by primary key
EEdit the cell as bytes whatever it holds โ€” the only way to put binary into a cell that is not already a blob (SQLite)
:SQL editor โ€” multi-line, Tab completion, transcript, history, per-statement timing, Alt-e/F5 for the query plan, and the shell's dot-commands (SQLite)
sSort by the column under the cursor: ascending → descending → off (SQLite)
< / >Move the sort to the previous / next column, keeping the direction
SSchema view for the selected table (SQLite)
DDatabase report โ€” .dbinfo pragmas and object counts, then i integrity check, Q quick check, f foreign-key lint, v/z/r VACUUM/ANALYZE/REINDEX (SQLite)
FFollow the foreign key under the cursor to the row it references (SQLite)
AColumn statistics โ€” rows, nulls, distinct, numeric cells, longest, min/max, mean; Enter for a column's frequency table (SQLite)
YCopy the selected row as an INSERT statement (SQLite)
0 1 2 3Records / Info / Strings / Hex (rkyv โ€” 0 only when the format was recognized)
a e r dCreate / hex-edit value / rename key / delete record (rkyv Records)
i Tab o x ^sInside the hex editor: EDIT mode / hex↔ascii column / insert / delete / save
EnterOpen the detail screen for the selected row or record; from the table list, focus the rows; in the picker, open the file
vCycle how a value renders on the detail screen: auto / hex / text / disasm
yCopy the cell, value or record key to the clipboard (OSC 52 โ€” works over SSH)
xExport the current table as CSV, or all records as JSON, to a file
wWrite monitor โ€” live writes to every known shard and database, with the selected database's WAL tail in a second frame (also from the picker); t swaps that frame for bytes written per table, F walks the log
oBack to the file list โ€” open another file without restarting
cColor-scheme chooser โ€” live preview, Enter saves, Esc restores
CPalette editor for the six base colors
h / ?Help overlay; any key closes it
q / EscLeave the current screen, or quit โ€” inside a modal, Esc cancels the modal only

Search

/ takes a plain substring (not a regex); n and N then walk the matches. What is scanned depends on the mode and, in SQLite, on which pane has focus.

ModeScanned
SQLite โ€” tablesTable names, case-insensitive, wrapping
SQLite โ€” rowsThe whole table, not just the loaded page: a SELECT rowid โ€ฆ WHERE <any column> LIKE ? that walks from the current row and wraps from the far edge, then pages to the hit
rkyv โ€” RecordsRecord keys, case-insensitive, wrapping
rkyv โ€” StringsThe extracted runs, case-insensitive, wrapping
rkyv โ€” HexRaw bytes, case-sensitive, does not wrap; the view scrolls to the hit and the offset is reported
PickerRecorded file paths, case-insensitive, wrapping

Detail view

Enter on a row or record opens a full-screen detail: every field untruncated at the top, and a scrollable value pane below. v cycles how the value bytes render โ€” auto (text when it looks textual, else hex), hex (xxd style), or text (UTF-8). y copies the value. This is how you read a cell or record blob too wide for the grid.

Export

Interactive x writes the current table (CSV) or all decoded records (JSON) to a file in the working directory. Non-interactively, --export takes the sqlite3 shell's output modes and --table restricts it to one table, which is what makes zdbview scriptable:

zdbview data.db --export json               # { table: [ {col: val, โ€ฆ}, โ€ฆ ] } for every table
zdbview data.db --export csv                # the first table as RFC-4180 CSV
zdbview data.db --export tsv                # .mode tabs โ€” tabs and newlines escaped, never raw
zdbview data.db --export markdown           # .mode markdown โ€” padded GitHub table
zdbview data.db --export line               # .mode line โ€” one column per line
zdbview data.db --export insert             # .mode insert โ€” one INSERT per row
zdbview data.db --export sql                # .dump โ€” schema plus data, replayable
zdbview data.db --export csv --table users  # just that table
zdbview cache.rkyv --export json            # decoded records; each value blob as lowercase hex

CSV and JSON are emitted by a dependency-free writer in export.rs โ€” quoting and escaping are covered by unit tests. --export never starts the TUI, so it is safe in a pipeline.

--export sql is .dump, and it โ€” like insert mode โ€” reads values from the database rather than from the grid, so a blob comes out as x'โ€ฆ' and a real keeps its decimal point. The display string for a blob is a description of it (<blob 2 bytes>), which is useless in SQL, so everything that writes SQL uses the exact form. Virtual tables are the case that makes a naive dump unreplayable: CREATE VIRTUAL TABLE runs the module's constructor and builds the shadow tables, which the dump would then create a second time. zdbview does what the shell does โ€” writes the sqlite_schema row directly under PRAGMA writable_schema=ON and emits the shadow tables with CREATE TABLE IF NOT EXISTS โ€” so an FTS5 index survives the round trip and still answers MATCH. Checked line-for-line against sqlite3 .dump: identical content, differing only in the order sqlite_master is walked.

--backup FILE copies a database with VACUUM INTO (the shell's .backup), the one consistent way to copy a file that may have writers. An existing target is an error, not an overwrite.

Which table is being written, live

In the write monitor, t swaps the bottom frame for a per-table breakdown of the bytes written since it opened. This is the thing no other SQLite tool shows: not how big a table is, but which one is being written to right now.

It works because a WAL frame header carries the page number it rewrote, and a page can be traced to the b-tree that owns it. Every table's and index's root page is walked straight out of the file to build a page โ†’ owner map. The file alone is the past in WAL mode โ€” a table created but not yet checkpointed is not even in its schema โ€” so the log's newest image of each page is applied over the file before the walk; without that, every page reads as unmapped. Each sample reads only the frame headers written since the last one, so the cost does not grow with the log's length, and the map is re-read when a checkpoint restarts the log or a frame names a page it does not cover.

written is the total since the monitor opened; rate is what the last sample attributed, so a table that has stopped reads as idle while its total stays โ€” that pair is what answers "which table is being written fastest right now".

Indexes are named as indexes, and a page the map cannot place is shown as page N (unmapped) rather than being charged to a table it might not belong to. A database in rollback-journal mode gets no breakdown: a journal records the pages it is about to change, not the ones it did.

Walking the log

F in the monitor opens the log as a history. A frame does not merely say that a page changed โ€” it carries the whole page image, so the rows that write put there are decoded and shown beside the frame list, labelled with the table's column names. j/k step one frame (down is back in time), [ and ] jump a whole transaction, g/G reach the newest and oldest frame, and / filters the log by table, page number or commit โ€” stepping then walks only what is listed, which is what makes a log of tens of thousands of frames usable.

A value that continues onto an overflow page says so rather than showing a truncated string โ€” the rest is in a different frame โ€” and a page that is not a table leaf (interior node, index page, overflow) is named as what it is instead of pretending to hold rows. This is the only view that reads frame payloads, so it decodes one frame at a time on a keypress: a 190 MB log holds tens of thousands of frames and each payload is a whole page.

Dot-commands in the editor

A line starting with . is a dot-command, as in sqlite3; Tab completes them and .help lists what is implemented: .tables, .schema, .indexes, .dump, .databases, .attach/.detach, .mode, .headers, .output/.once, .timer, .eqp, .import, .read, .backup, .expert, .vacuum/.analyze/.reindex, .quit. .mode renders a result set through the same writers --export uses; list is the grid the editor draws.

.expert is not sqlite3_expert โ€” rusqlite exposes no binding for it. It reads the plan the planner actually produced plus the columns the statement compares on, and names an index for each table chosen for a full scan. A column is attributed to a table only when no other table in the statement has one by that name, so an ambiguous join reports the scan without guessing.

Following a foreign key

F on a foreign-key column jumps to the row it references โ€” the navigation a schema gives you for free, and what DB Browser and Datasette both link. The parent column comes from PRAGMA foreign_key_list, resolved to the parent's primary key when the pragma leaves it empty; a dangling key, a NULL key and a non-key column are each reported rather than followed.

Column statistics and frequency

A describes every column of the current table โ€” rows, nulls, distinct values, how many cells SQLite actually stores as numbers, the longest value, the extremes, and the mean of the numeric cells. That numeric count is the only place a column declared INTEGER but holding text shows up, because a declared type in SQLite is an affinity hint rather than a constraint. Enter on a column adds its most common values with bars, so a glance says whether the column is skewed.

Every column costs one pass over the table, and a wide blob column costs more than a second of it on a real database โ€” 1.6s for count(DISTINCT body) across 46,716 rows of a 196 MB cache. The pass therefore runs on its own connection on a background thread: the screen opens saying analyzing โ€ฆ and fills in when the work lands, so the UI never blocks.

Import

--import rows.csv --table people loads a CSV (or TSV, chosen by extension) into an existing table โ€” the shell's .import. The header row names the columns, so a file ordered differently from the table still lands correctly, and a header naming a column the table does not have is an error rather than a silent drop. The whole file is one transaction, so a row with the wrong field count rolls all of it back. The reader is RFC 4180 by hand, like the writers: quoted fields may hold the separator, newlines and doubled quotes, and CRLF or a missing final newline are both accepted.

Recovery

--recover (and .recover in the editor) salvages what a file still holds by reading pages โ€” it never opens the database, so it works on a file SQLite refuses, and it walks every page rather than only those reachable from sqlite_master, which is what brings back rows a corrupt b-tree root has orphaned.

Measured against a 400-row database with its table root zeroed โ€” SQLite answers database disk image is malformed โ€” sqlite3 .recover emits 400 inserts and so does this, and the replayed script holds data identical to the original. Truncated to 20 of 35 pages, both recover the same 226 rows. A 20 KB value spanning overflow pages comes back whole.

Rows on pages that cannot be attributed to a table go to a lost_and_found table carrying the page each came from, and every assumption the pass made is written into the script as a comment. Indexes are not recovered โ€” the script replays their CREATE INDEX statements โ€” and the file is never modified.

A WITHOUT ROWID table keeps its rows in an index b-tree, so three page kinds are read, not one: table leaves, index leaves (where the key record is the row) and index interiors, which unlike table interiors carry key payloads of their own. Index cells also keep far less of their payload on the page than table cells, and the two size formulas are not interchangeable. Measured on a 300-row keyed table with its root zeroed: sqlite3 .recover brings back 298 rows and so does this, replaying to key-0000โ€ฆkey-0299.

Clipboard

y copies through the OSC 52 terminal escape, written straight to /dev/tty. That means no clipboard library, no X/Wayland/pbcopy dependency, and it works over SSH โ€” the terminal emulator on your side sets the clipboard. It is best-effort: with no controlling terminal (output redirected), the copy silently does nothing.

Recent files

Every successful open is recorded to $XDG_CACHE_HOME/zdbview/recent (or ~/.cache/zdbview/recent), one tab-separated line per entry: unix-seconds kind absolute-path. Paths are canonicalized, so the same file reached by different relative paths or symlinks dedupes to a single entry that moves back to the front on re-open. The list is capped at 50 and written via temp-file-plus-rename, so a concurrent reader never sees a half-written list. The recorded kind is the one actually used โ€” a .db file that turned out to be an archive is remembered as rkyv.

CLI reference

FlagDescription
[FILE]File to open; with none, the recent-files picker opens
    --sqliteForce the SQLite backend
    --rkyvForce the rkyv / binary inspector (conflicts with --sqlite)
    --export FORMATDump the file to stdout and exit โ€” json, csv, tsv, markdown, insert, line or sql; requires a file argument
    --table TABLERestrict --export to one table
    --backup FILECopy the database with VACUUM INTO and exit
    --import FILELoad a CSV (or TSV, by extension) into --table and exit
    --recoverSalvage rows page by page to stdout as SQL, without opening the database
-h, --helpPrint usage
-V, --versionPrint the version

Man pages ship in the repo: man/man1/zdbview.1 and the all-in-one man/man1/zdbviewall.1. The zsh completion is completions/_zdbview.

Repository & links