feat(cli): add an export command that dumps the kv layer as import-co… - #85
Merged
Merged
Conversation
cevheri
approved these changes
Sep 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What and why
Closes #60.
The CLI could bulk-load a JSON object with
importbut had no way to produce one, sothe workflow was a one-way door.
docs/CLI.mdcarried a placeholder admitting the gap("A first-class
exportcommand is on the roadmap"), and the workaround it offered wasnot usable in practice:
scanrequires a prefix, so there was no way to dump a wholedatabase from the CLI at all — you had to know every namespace prefix up front and
reassemble the pieces by hand.
This adds
libredb export <path> <file.json>, the counterpart toimport. It writes thekey-value layer as a flat JSON object of string values — the exact shape
importconsumes — so
export->importround-trips without a second backup format.How it works
This does not touch
src/core.ts. The change is confined to the CLI edge(
src/cli/run.ts) plus docs and tests; no kernel, storage, lens, or public API surfaceis modified, and the
importformat is unchanged.exportis a read command and reuses the existing read path verbatim:withReadDbopensthrough
readonlyFileSystem(), which implements nolock()at all (the seam'slock?isoptional), so a dump takes no lock, creates no
<path>.lock, and leaves the databasebyte-identical — it can read a file a live writer holds open. The whole dump is read in
one kernel transaction, mirroring import's one-transaction write, so the output is a
single consistent snapshot.
Three details are load-bearing and worth a reviewer's eye:
prefix("")throws, andrange()takes string bounds, so no argument can encode an upper bound above the wholekeyspace. Export therefore reads
tx.getRangedirectly — the same kernel APIimportalready writes through. The bound is
[<empty>, 0xF5): no valid UTF-8 encoding beginsabove
0xF4(the lead byte of U+10FFFF), so0xF5is above every key a lens or CLIcommand can write. The start is the empty key, which is itself legal and sorts *b
the reserved namespace — which is why reserved keys are excluded by predicate, not by
lower bound.
importrefuses reserved keys, so emitting them would produce afile its own counterpart rejects. Export filters with the published
isReservedKe contract rather than a hardcoded prefix, so it stays correct if the reserved namespace grows. Consequence, documented: a restored file holds every row but no catalog eninspect` lists nothing until a lens re-registers. A byte-exact copy is still a file copy.well-formedness itself; export reads through the kernel directly and carries the same
obligation. It decodes with a fatal
TextDecoder, so a database holding raw non-bytes (only reachable through the kernel API directly) is refused rather than dumped with
replacement characters that would import back as different data. All escaping is
JSON.stringify's — no JSON text is built by hand.The dump object is built with
Object.fromEntries, notobject[key] = value: assignmenthits the inherited
__proto__setter and defines no own property, so a legallibredb set app.libredb __proto__ vkey would silently vanish from the dump. There is atest for it.
Behaviour left to convention, since the issue does not specify it: the output file
overwritten like a shell redirect, parent directories are not created (clean
ENOENT,exit 1), and output is 2-space indented with a trailing newline because a dump is a
humans read and diff. No new flags.
Checklist
bun run gatepasses locally (typecheck, format, lint, knip, build, size, test).bun run changeset).Two points a reviewer will want flagged, which I would add as a PR comment rather than bury in the description:
The changeset is minor, per CONTRIBUTING.md:76 ("new runtime capabilities are minor"). The counter-evidence is that the entire CLI shipped as a patch in 0.1.3 — I read that as pre-dating the written policy, but it is a one-word edit if you disagree.
The fatal TextDecoder slightly exceeds the issue's letter. The scope bullet only asoduce lone surrogates, which non-fatal decoding plus JSON.stringify alreadysatisfies. It is also imperfect by necessity: a key whose first byte is at or above 0xF5 falls outside the scanned range and is silently omitted rather than refused, because a half-open range cannot reach "all keys". Removing decodeText is a self-contained revert if you'd ra.