CLI reference#
$ h5i-db <command> <db> [args…] [--format table|json|jsonl|csv|arrow]
The h5i-db binary is non-interactive by design: no prompts, no pager, SQL
from an argument or stdin, results on stdout, diagnostics on stderr. The
database path is always the first positional argument of a command (there is
no global --db flag).
Global behavior#
Output formats#
--format is global and defaults to table.
| Format | Behavior |
|---|---|
table |
Human-readable aligned columns (buffered; metadata commands render pretty JSON) |
json |
One JSON array of row objects; explicit nulls; empty result is [] |
jsonl |
One compact JSON object per row per line |
csv |
With header row; empty result still emits the header |
arrow |
Arrow IPC stream on stdout; lossless, pipes into other tools |
Errors and exit codes#
Errors are a single JSON envelope on stderr:
{
"schema_version": 2,
"code": "table_not_found",
"message": "table \"trade\" not found",
"retryable": false,
"hint": "run `h5i-db tables <db>` to list tables",
"did_you_mean": "trades",
"next_actions": [
{"cmd": "h5i-db schema market.db trades", "why": "\"trade\" does not exist; \"trades\" is the closest name"},
{"cmd": "h5i-db tables market.db", "why": "list the tables that do exist"}
]
}
| Field | Use |
|---|---|
code |
Stable machine-readable identifier; branch on this, not on message |
retryable |
Whether re-running the same call can plausibly succeed |
hint |
One line of prose for a human |
did_you_mean |
Closest existing identifier when the failure looks like a typo; absent otherwise |
next_actions |
Commands you can run verbatim, best first. <db> is already substituted with the database you invoked |
schema_version |
Bumped on any breaking change to this shape |
Exit codes are stable and branchable:
| Code | Meaning |
|---|---|
0 |
Success (including broken pipe from … \| head) |
2 |
User error: bad arguments, bad SQL, missing table |
3 |
Conflict: another writer moved the head; usually retryable |
4 |
Limit exceeded: --max-rows, --max-bytes, memory budget, timeout |
5 |
Internal error |
Diagnostics volume is controlled with RUST_LOG (default warn).
Shared write flags#
Commands that commit a version (ingest, restore, replace-range,
delete-range, compact) accept:
| Flag | Meaning |
|---|---|
--expected-version <N> |
Require the table head to be exactly version N (optimistic guard); mismatch exits 3 |
--note <text> |
Free-text note recorded in the version manifest |
--idempotency-key <token> |
Make the mutation replayable exactly once |
--idempotency-key is what makes an unattended ingest loop safe to retry. A
repeat carrying the same key finds the commit it already produced and returns
it with "segments_added": 0 instead of writing the rows a second time, which
matters because a duplicated append does not error: it just leaves the data
wrong from then on. The key is recorded in the commit's own manifest, and
retries are deduplicated against the last 64 commits.
h5i-db ingest market.db trades day.parquet --idempotency-key load-2026-07-01
Database & tables#
h5i-db context#
Everything needed to orient, in one call: every table's columns, size, segment
count, time range and head version, which operations the policy gates, the
snapshots that exist, and any plan already staged and waiting for review. It
replaces a tables → schema → sample → versions walk repeated per table.
$ h5i-db context market.db --format json
$ h5i-db context market.db --budget 2000 # cap the answer in tokens
$ h5i-db context market.db --stale-after 15m # flag tables whose head is old
| Flag | Meaning |
|---|---|
--budget <tokens> |
Approximate ceiling. Detail is shed in a fixed order (columns, snapshots, notes, then whole tables smallest-first) and whatever went is counted under omitted, with the command that recovers it |
--stale-after <dur> |
Report per-table commit age and flag anything older than this |
Output is deterministic for a given database state, so it can be cached as a
preamble; --stale-after is the only flag that makes it depend on the clock.
h5i-db init#
Create a new database directory.
$ h5i-db init market.db
h5i-db create-table#
Create a table. The schema comes from --schema JSON or --like a data
file (exactly one required).
$ h5i-db create-table market.db trades --like ticks.parquet --time-column ts
$ h5i-db create-table market.db bars \
--schema '[{"name":"ts","type":"timestamp_us","nullable":false},
{"name":"symbol","type":"utf8"},{"name":"close","type":"float64"}]' \
--time-column ts --sort-key ts,symbol
| Flag | Meaning |
|---|---|
--schema <json> |
Explicit schema. Types: int8..int64, uint8..uint64, float32/float64, utf8, bool, timestamp_s/ms/us/ns (UTC), date32, date64. Aliases accepted: int→int32, long/bigint→int64, float→float32, double→float64, string/str/text→utf8, boolean→bool, date→date32, timestamp→timestamp_ns. nullable defaults to true. |
--like <file> |
Infer the schema from a Parquet/CSV/Arrow file |
--time-column <col> |
Time index column; strongly recommended for time-series tables, and forced non-nullable |
--sort-key <cols> |
Comma-separated sort key (defaults to the time column) |
--target-segment-mb <N> |
Target segment size in MiB of in-memory data (default 128) |
h5i-db tables#
List tables with row counts and time ranges. Columns: table, version,
rows, bytes, segments, time_range, time_column.
h5i-db schema#
Show a table's schema and options (schema_revision, time_column,
sort_key, fields with types and nullability).
h5i-db sample#
Show the first rows of a table.
| Flag | Meaning |
|---|---|
-n, --rows <N> |
Row count (default 10) |
--version <N> |
Read at a specific version |
h5i-db rename#
Rename a table: a catalog edit, no data moves.
$ h5i-db rename market.db trades trades_raw
h5i-db drop-table#
Drop a table and its data. Refuses if the table is pinned by a snapshot, and
requires --yes:
$ h5i-db drop-table market.db scratch --yes
Irreversible
drop-table permanently deletes data; it is the one command that
bypasses versioning. Snapshots protect tables from it, so use them.
Reading & querying#
h5i-db query#
Run SQL. The query comes from the argument, or stdin when -.
$ h5i-db query market.db "SELECT symbol, vwap(price, size) FROM trades GROUP BY symbol" \
--format json --max-rows 1000 --timeout 30s
$ cat report.sql | h5i-db query market.db - --format csv > out.csv
| Flag | Meaning |
|---|---|
--max-rows <N> |
Abort after N produced rows |
--max-bytes <N> |
Stop after N output bytes (checked at batch boundaries); truncation exits 4 with a limit_exceeded envelope |
--timeout <dur> |
Query timeout, e.g. 30s, 5m |
--memory-limit-mb <N> |
Memory budget in MiB; enables disk spilling under pressure |
--spill-dir <path> |
Spill directory (with --memory-limit-mb) |
--threads <N> |
Number of threads / partitions |
--stats |
Print scan/pruning statistics to stderr after the query |
--predicate-cache |
Read and build immutable predicate-cache sidecars |
--as-of <at> |
Pin every table at a read point: a version, an RFC3339 availability timestamp, or a snapshot name |
--decision-time <ts> |
Hide rows stamped after this RFC3339 instant |
--embargo <dur> |
Extra gap subtracted from --decision-time, e.g. 1d |
See the SQL reference for h5i() time travel, asof_join, and
the time-series function library.
Point-in-time reads#
--as-of and --decision-time bound what a query can read, on two different
clocks. --as-of is the commit clock: it pins every table at a version, so a
restatement that landed later is not visible. --decision-time is the data
clock: it hides rows whose time column is later than the instant, so a window
cannot reach forward and a join cannot pull in a later timestamp.
They are independent because under a bulk load the two clocks are far apart:
ten years of history committed this morning has a first commit of today, so an
arrival pin at a historical instant resolves to nothing while an event-time
cutoff at that same instant is exactly what you want. When the clocks do agree,
an RFC3339 --as-of also supplies the decision time, so the common case stays
one flag.
# One decision date, both axes, for a walk-forward step.
h5i-db query market.db "SELECT vwap(price, size) FROM trades" \
--as-of 2026-07-01T00:00:00Z --decision-time 2026-07-01T00:00:00Z --embargo 1d
H5I_DB_AS_OF, H5I_DB_DECISION_TIME and H5I_DB_EMBARGO set the same
things for a whole shell, which is how you hand a bounded view to a script or
an agent rather than relying on it to pass the flag every time. An explicit
flag still wins over the environment.
The bound is part of the table, not a filter you compose with, so a query that
explicitly asks for later rows still gets none. Three things refuse rather
than being quietly exempt: a table with no time column, a table whose time
column is a bare integer carrying no unit, and, under --decision-time, the
table functions (h5i(), asof_join(), gapfill(), resample(), tail(),
latest_on()), which read their tables directly and cannot have the cutoff
pushed into them. Under --as-of alone those functions work normally,
resolving at the pinned version; selecting a different read point from
inside a pinned session is refused.
h5i-db versions#
List a table's committed versions: version, op, committed_at, rows,
bytes, segments, note.
h5i-db arrival-delta#
How much a query's answer moved because data arrived late: run it once at the current head and once at an earlier read point, and report the difference. What moves is what depended on commits that had not landed at the earlier point, which in a backtest is the part of a metric a live run starting that day could not have earned.
$ h5i-db arrival-delta market.db \
"SELECT symbol, vwap(price, size) AS vwap FROM trades GROUP BY symbol" \
--as-of 2026-07-01T16:00:00Z --format json
| Flag | Meaning |
|---|---|
--as-of <at> |
The decision point: an integer version, an RFC3339 timestamp (matched by commit availability time, like h5i()), or a snapshot name |
--tolerance <f> |
Absolute per-cell delta below which a difference is treated as noise (default 1e-9) |
The report carries changed, per-column head → asof (delta) for the common
single-row-metric case, max_abs_delta, row_count_differs,
withheld_versions (per table, the head-vs-as-of version gap), vacuous, and
notes.
Read it as a measurement, not a verdict. Look-ahead comes in many shapes
and this sees one of them: rows that exist now but had not been published. A
signal that reads its own bar leaves the delta unmoved, so no value of it, high
or low, clears a query of look-ahead; bound the event-time axis with
query --decision-time for that. And check vacuous before
reading the number: when both read points resolve to the same version, which is
the normal state of a bulk-loaded database, the zero is arithmetic rather than
evidence, and the report says so in notes.
Writing data#
h5i-db ingest#
Ingest Parquet/CSV/Arrow into a table, from a file or from stdin with -.
$ h5i-db ingest market.db trades ticks.parquet
$ curl -s https://…/ticks.csv | h5i-db ingest market.db trades - --input-format csv
| Flag | Meaning |
|---|---|
--input-format <fmt> |
auto (default) | parquet | csv | arrow. Auto uses the file extension, or sniffs leading bytes on stdin |
--mode <mode> |
append (default) for a strict ordered append; write to replace the table contents |
--retries <N> |
Retry appends on version conflicts (default 5; safe for pure appends) |
Plus the shared write flags. Input batches are schema-aligned against the table (purely representational casts like timezone-less CSV timestamps are applied automatically). CSV assumes a header row.
Arrow over stdin
Pipe an Arrow IPC stream, not an IPC file: a file's random-access footer can't be consumed from a pipe. h5i-db detects the difference and tells you.
h5i-db restore#
Make a historical version current. History only moves forward, so restore commits a new version holding the old contents.
$ h5i-db restore market.db trades 42 --note "roll back bad load"
h5i-db replace-range#
Replace all rows in [start, end) of the time column with the given input.
$ h5i-db replace-range market.db trades \
--start 2026-07-01T09:30:00Z --end 2026-07-01T16:00:00Z \
--input corrected.parquet --plan
| Flag | Meaning |
|---|---|
--start <t> |
Range start, inclusive; RFC3339, or a raw integer in the column's unit |
--end <t> |
Range end, exclusive |
--input <file> |
Replacement data (or - for stdin); omit to delete the range |
--input-format <fmt> |
As in ingest |
--plan |
Stage a previewable plan instead of committing immediately |
h5i-db delete-range#
Delete all rows in [start, end), shorthand for replace-range with no
input. Same --start/--end/--plan flags.
$ h5i-db delete-range market.db trades --start 09:30… --end 09:31… --plan
{"plan_id": "5c41…", "summary": {"rows_affected": 12481, "segments_reused": 127}}
Plans, policy & snapshots#
h5i-db plan#
Manage previewable-mutation plans (created by --plan above).
| Subcommand | Meaning |
|---|---|
plan list <db> <table> |
Pending plans: plan_id, op, base_version, created_at, expired, summary |
plan show <db> <table> <plan-id> |
Full plan JSON on stdout; before/after row samples on stderr |
plan apply <db> <table> <plan-id> |
Publish the plan; fails with a conflict if the head moved |
plan discard <db> <table> <plan-id> |
Drop the plan; staged segments become vacuumable |
Plans expire after 7 days; see plan hygiene.
h5i-db policy#
The mutation policy decides which operations may commit without a reviewed plan.
$ h5i-db policy show market.db
$ h5i-db policy set market.db direct_delete=false direct_write=false
Keys: direct_append, direct_write, direct_replace, direct_delete,
direct_restore, direct_compact, each true/false. The update is an
atomic read-modify-write.
The mutation policy gates who may write directly; the data policy below gates what data may be written at all.
h5i-db data-policy#
Opt-in, per-table data-safety constraints checked on every write (and at plan time), so a violating mutation is refused before it can be applied. A table with no policy is unconstrained and pays only one metadata lookup on the write path; reads are never touched.
| Subcommand | Meaning |
|---|---|
data-policy get <db> <table> |
Show the table's policy (empty when unset) |
data-policy set <db> <table> <policy> |
Install a typed JSON policy (inline JSON, @path to read a file, or - for stdin) |
data-policy clear <db> <table> |
Remove the policy (writes become unconstrained) |
$ h5i-db data-policy set market.db trades '{"constraints":[
{"name":"positive_price",
"predicate":{"compare":{"column":"price","op":"gt","value":{"float":0.0}}},
"on_fail":"reject"}]}'
Predicates compose not_null, compare, and in_set with and/or/not;
on_fail is reject (fail the write) or warn. Evaluation is fail-closed: a
NULL never satisfies a comparison. A violation raises data_policy_violation
(exit 2, not retryable).
h5i-db snapshot#
Pin table versions under a name.
| Subcommand | Meaning |
|---|---|
snapshot create <db> <name> [tables…] |
Pin current versions (all tables when omitted); --note supported |
snapshot list <db> |
List snapshots |
snapshot delete <db> <name> |
Delete a snapshot (the versions it pinned remain readable by number) |
h5i-db fork#
Writable workspaces over a pinned base, for running several lines of work against one dataset at once. See Forks for the model.
| Subcommand | Meaning |
|---|---|
fork create <db> <name> |
Pin every table and open a workspace; --note, --as-of, --meta, --count supported |
fork list <db> |
Every fork with lineage and liveness: parent/depth, commits_own, last_write_ns, stale_shadows (promotes that would now conflict), what it owns (bytes_own) and what it holds back (bytes_pinned) |
fork show <db> <name> |
One fork's pins and metadata |
fork diff <db> <name> |
What the fork changed, from manifests alone; --table to narrow |
fork promote <db> <name> --table <t> |
Land one of its tables on the base |
fork drop <db> <name>… |
Delete the named forks and everything they own |
$ h5i-db fork create market.db agent-01 --note "hypothesis 1"
$ h5i-db ingest market.db features out.parquet --fork agent-01
$ h5i-db query market.db "SELECT count(*) FROM features" --fork agent-01
$ h5i-db fork diff market.db agent-01
$ h5i-db fork promote market.db agent-01 --table features
$ h5i-db fork drop market.db agent-01
fork create accepts --as-of <rfc3339> to pin the past instead of the
present, and --meta (inline JSON, @file, or -) to record whatever ties
the fork back to the run that made it.
Wide fanouts. fork create <db> <name> --count N creates
<name>-0000 … <name>-000(N-1) over a single resolution of the base, and
fork drop takes several names at once. Every fork of one base at one instant
pins the same versions, so the batch does one pass over the catalog however
many branches it makes, which is what makes a few hundred short-lived
branches a reasonable thing to create and then throw away. With --count the
command returns a JSON list; without it, a single object as before.
The --fork flag. Every data command takes --fork <name> and then reads
and writes inside that workspace, so an existing script runs unchanged against
a fork by adding one flag. Database-wide commands (snapshot, vacuum,
set-retention) refuse it and say so: they move state a fork's siblings
depend on. fork create is the exception: with --fork it creates the new
fork inside that one (see Forks).
Promotion conflicts. fork promote compare-and-swaps against the version
the fork started from. If the base moved, it exits 3 with
code: "promote_conflict" and retryable: false — retrying cannot help,
because the work was computed against a base that no longer exists. Re-fork
and re-run, or drop the fork.
Compaction is the one exception, and it is handled for you. If every
intervening base commit was a compaction then the base's rows did not
change, only where they live, so the promote is rebased onto the new
layout instead of rejected, and the result reports rebased_from. That works
while the fork still holds every row it inherited; a fork that deleted
inherited rows cannot be replayed from metadata (a compacted segment may merge
rows it dropped with rows it kept), so that case still conflicts and the
message says why.
Maintenance & tools#
h5i-db compact#
Rewrite small segments into target-sized ones, as a new version. Row count is verified preserved.
| Flag | Meaning |
|---|---|
--target-mb <N> |
Override the target segment size (MiB of in-memory data) |
h5i-db vacuum#
Remove unreachable objects, dry run unless --apply.
$ h5i-db vacuum market.db # inspect candidates
$ h5i-db vacuum market.db --apply # actually delete
| Flag | Meaning |
|---|---|
[table] |
Restrict to one table (optional positional) |
--grace-seconds <N> |
Never touch objects newer than this (default 3600) |
--apply |
Actually delete |
Read the cadence guidance before scripting this.
h5i-db verify#
Check structural integrity: checksums, hash chain, object existence.
$ h5i-db verify market.db trades --deep
--deep additionally re-reads every segment and verifies content checksums.
Problems are reported in the chosen format and the command exits non-zero.
h5i-db demo#
Build a small database and walk the whole arc on real data: ingest, the metric a strategy would have traded on, a vendor restatement previewed through plan/apply, the arrival delta that correction reveals, and a session that cannot read past its decision instant. Takes about a second.
$ h5i-db demo
$ h5i-db demo --dir ./scratch --keep # keep the database it builds
h5i-db ui#
Launch the local review UI, loopback only.
| Flag | Meaning |
|---|---|
--port <N> |
Port (default 7351) |
--allow-mutations |
Enable plan apply/discard from the UI (default: read-only) |
The UI shows pending plans with previews, a live fork monitor, the version timeline with audit badges, version diffs, and an SQL scratchpad that reports pruning per query.
The Forks tab renders the fork lineage as a tree that updates itself over
a server-sent event stream: each fork carries one glanceable status —
conflict (the base moved under a shadowed table, so promote will refuse),
working (committed within the last 15 s), ahead (holds unpromoted
commits), or idle — plus what it wrote, what its pins hold back, and the
agent metadata attached at fork create --meta. Selecting a fork shows its
per-table divergence and copy-ready promote/drop commands; the UI itself
never mutates forks. tools/fork_demo.sh <db> drives a simulated agent
swarm against a database if you want to watch the tree move, and
tools/demo_data.py seeds one first — synthetic multi-symbol ticks
(synth --symbols AAPL,MSFT,NVDA --db <db>) or a day of real 1s bars from
Binance's public archive (binance --symbols BTCUSDT,ETHUSDT --db <db>,
no API key).