Registries and SQL
An optional layer for teams that keep a data catalog. Publish tether’s history as SQL tables that dashboards and pipelines can join, or drive which objects tether tracks from a registry query.
export, publish, and import are tested and documented but sit outside tether’s core loop, and the code lives under tether.experimental.registry. The commands print a one-line note saying so. The exported schema (TABLES) and the import columns (CANONICAL_COLUMNS) may change before 0.2; the commands, and the tether.ExportBundle / tether.ImportSpec re-exports, stay. See Writing a Backend for what “experimental” promises.
Data registries keep one row per object in SQL, saying what exists and where. tether keeps its facts as TOML manifests in the VCS, which is what gives it diffs, merges, undo and a cheap history walk. Three commands treat SQL tables as a derived view of the manifests, never the other way round:
| Direction | Command | What moves |
|---|---|---|
| manifests -> file | tether export |
commits, objects, states, refs as SQLite / Parquet / CSV / JSONL |
| manifests -> database | tether publish |
the same tables upserted into a Postgres schema |
| registry -> manifests | tether import |
which objects to track (and where to start), as add / update / remove |
Nothing in this page changes how tether records or pins state: commit still does that, and only commit does.
The tables
Every output shares one schema (tether.experimental.registry.TABLES), so a SQLite export and a Postgres schema have the same columns. JSON columns are TEXT in SQLite and JSONB in Postgres; timestamps are ISO-8601 text and TIMESTAMPTZ.
| Table | Key | Row |
|---|---|---|
commits |
commit_id | one VCS commit: change_id (jj), author, authored_at, committed_at, message, manifest_hash |
commit_parents |
commit_id, parent_id |
one edge of the commit graph (position 0 = first parent) |
| refs | name, kind |
a bookmark / branch / tag / head and the commit it points at |
| objects | commit_id, key |
one manifest at one commit: kind, uri, locator_json, identity_json, identity_hash, policy_*, state_json, state_hash, pin_id, pin_ref, recoverable, captured_at |
object_states |
kind, identity_hash, state_hash |
one distinct (system, state): the versions a registry keys on, with first_commit_id |
listings, listing_entries |
listing_name (, path) |
per-file listings for directory / prefix objects (--listings) |
workspace |
workspace_id, key |
this checkout’s bookmark branches (working refs), fork points, and last snapshot (--workspace) |
tether_meta |
key | schema_version, exported_at, head, vcs, tether_version |
Two views come along: objects_head (the objects at the exported head) and object_pins (each pin and how many commits reference it). uri is the locator’s uri when it has one – the natural join key to a catalog’s URI column. identity_hash and state_hash are blake2b digests of the same canonical JSON that produces pin_id, so the same state on the same system hashes the same everywhere.
Export
tether export tether.sqlite # every reachable commit
tether export tether.sqlite --rev main # one revision
tether export dump/ --format parquet --listings # one file per table + schema.json
tether export tether.sqlite --append # upsert into an existing file--append never deletes: a jj commit that was later rewritten or abandoned keeps its rows. Rebuild without --append now and then, or let publish (below) do the incremental work against a database.
Query it in place:
sqlite3 tether.sqlite "SELECT key, pin_ref, state_json FROM objects_head"import duckdb
con = duckdb.connect()
con.sql("ATTACH 'tether.sqlite' AS t (TYPE sqlite)")
con.sql("""
SELECT key, json_extract_string(state_json, '$.snapshot_id') AS snapshot
FROM t.objects_head WHERE kind = 'icechunk'
""").show()Or skip the file:
from tether import Repo
bundle = Repo.find(".").export(listings=True)
tables = bundle.to_arrow() # dict[str, pyarrow.Table]
bundle.to_sqlite("tether.sqlite") # or .to_dir("dump", "parquet")Joining a registry
Suppose a catalog has catalog(uri, name, ...) and a runs table records the VCS commit each job read as tether_rev (e.g. from TETHER_REV). Which Icechunk snapshot did each run read?
SELECT r.run_id, c.name,
json_extract_string(o.state_json, '$.snapshot_id') AS snapshot_id,
o.pin_ref
FROM runs r
JOIN t.objects o ON o.commit_id = r.tether_rev AND o.kind = 'icechunk'
JOIN catalog c ON c.uri = o.uri;No tether process is involved; the export is a plain table.
Publish
publish writes the same tables into Postgres and is meant to run after every tether commit, from CI or an orchestrator step:
export TETHER_PUBLISH_DSN=postgresql://tether@db.example.org/catalog # or --to
tether publish --dry-run # per-table row counts, nothing written
tether publish # schema `tether`; --schema to change itpublished 9 row(s) to schema 'tether' (12 commit(s) already present)
commits 1 row(s)
commit_parents 1 row(s)
refs 3 row(s)
objects 2 row(s)
object_states 1 row(s)
tether_meta 5 row(s)
It is idempotent and incremental. Commits are immutable, so rows of commits, commit_parents and objects whose commit is already in the schema are skipped. refs, workspace and tether_meta describe now and are rewritten. Everything runs in one transaction, with INSERT ... ON CONFLICT DO UPDATE. Tables and views are created IF NOT EXISTS, so only the first run needs CREATE on the database. Like --append, it never deletes commit rows: a jj commit rewritten after publishing keeps its row under its old id, which objects_head ignores. Drop and republish the schema when that matters.
The DSN comes from --to or $TETHER_PUBLISH_DSN, never from tether.toml – the same rule as backend credentials. Needs the postgres extra (pip install 'tether-vcs[postgres]').
From Python, bundle.to_postgres(dsn, schema="tether") returns a PublishReport; pass connection= to reuse an open connection or pool.
Import
import runs the other way. The registry says what to track; tether writes the manifests. Rows must have tether’s canonical columns:
| Column | Required | Meaning |
|---|---|---|
| key | yes | object key |
| kind | yes | backend kind |
| uri | one of | becomes locator.uri |
locator_json |
uri / locator_json |
full locator as a JSON object (merged over uri) |
policy_file, policy_pin |
no | default from [defaults] in tether.toml |
at |
no | initial position, exactly like add --at |
Other columns are ignored (and named in the plan). The mapping from your registry’s columns is SQL on your side, which keeps tether free of per-registry options:
tether import postgresql://reader@db.example.org/catalog --query "
SELECT 'zarr/' || name AS key,
'icechunk' AS kind,
uri
FROM catalog
WHERE format = 'icechunk'
" --dry-runplan: import (2 write(s))
add zarr/run-041 [icechunk] s3://bucket/run-041.icechunk register
update zarr/run-042 [icechunk] s3://bucket/run-042.icechunk locator changed; committed state kept
zarr/run-043: unchanged
Sources: a Postgres DSN, a SQLite file (--table or --query), a .csv, or a .jsonl. The query runs as one statement in a read-only transaction. A standing query lives in .tether/secrets.toml, which import uses when neither --table nor --query is given. The committed tether.toml may not hold one, or its author would choose SQL that runs with every cloner’s DSN.
# .tether/secrets.toml
[import]
query = "SELECT 'zarr/' || name AS key, 'icechunk' AS kind, uri FROM catalog WHERE format = 'icechunk'"Then tether import postgresql://... is enough. The plan has three verbs:
- add: the key is new.
update: the key exists and its locator or policy differs. The manifest’s committedstateand pin are kept; the next status shows whether the object moved.remove: only with--sync, for registered keys the source no longer lists. Liketether remove, this leaves working branches forgc.
A key whose kind would change is refused: remove and add it again. --plan FILE and --from-plan FILE work as for other planned commands; an import plan checks the manifests, not the checkout. After importing, run tether status and tether commit: import changed what is tracked, and commit records what state it is in.
Round trip
The canonical columns are a subset of the objects table, so an export can be edited and imported back:
tether export snap.sqlite
sqlite3 snap.sqlite "UPDATE objects SET policy_pin = 'record' WHERE key LIKE 'scratch/%'"
tether import snap.sqlite --sync --query "
SELECT key, kind, locator_json, policy_file, policy_pin
FROM objects_head
"An unmodified export imports as an empty plan.
Prior art
Files are canonical, tables are a rebuildable index – every comparable tool lands on the same shape:
| Tool | Shape | tether analogue |
|---|---|---|
| git-history | a file’s git history as commits / item / item_version tables |
export / publish |
| Quilt | package manifests in S3, Iceberg tables beside them | export / publish |
| Dolt, Iceberg | version graph as dolt_log / $snapshots system tables |
export / publish |
| Kart | git holds the data; a SQL database is the working copy; kart commit folds edits back |
import then tether commit |
| Fossil | a VCS in SQLite whose SQL metadata fossil rebuild recomputes from the artifacts |
the manifests are canonical |