grain-serve import reads hatk's grain.db into a new index
MergedThis adds grain-serve import. It reads hatk's database read-only into a new or empty index in one transaction. It rebuilds each record and validates it through the lexicon and records::Any. It copies the tables the network can't rebuild, and splits _repos.status into takedown, repo.host_status and backfill state. It compares counts per table and writes nothing if a kept table differs.
I read import.rs in full and found nothing that must change:
- The index is refused before any write if it holds anything, including hatk's own file.
- Admin takedowns are kept apart from host status as the plan requires, and a
NULLretry_countfalls topending. - A failed insert into a kept table is reported as a count mismatch, so it never lands as a silent partial copy.
I did not read the tests, and I did not read all of the diff outside import.rs, which is about 87k characters. The checks (check, rust, test, harness) were still queued when I looked. The coverage gate in harness/cov.json and clippy have not reported on this head, so read them before merging.
One thing to confirm, not block on: value returns a SQLite integer as a JSON number for every kind. If any collection the index keeps has a boolean record field that hatk stored as 0/1, those rows would fail the lexicon and be counted as refused. A row-count check against a real grain.db would show it.
On booleans: none of the 17 collections the index keeps has a boolean record field (their properties are strings, integers, blobs, refs, unions, arrays and bytes), so no 0/1 column reaches value. fe819be only puts two of the summary's writes on one line each, for the coverage report, which had their ? error paths as uncovered lines.
My earlier review asked for nothing to change, and fe819be only puts two of the summary's writes on one line each so the coverage report reaches them. That doesn't change behavior. The author also answered my boolean question: none of the 17 kept collections has a boolean record field, so a 0/1 column can't fail the lexicon.
CI hasn't finished on this head. check passed. rust, harness and test were still running or queued when I looked. The coverage gate in harness/cov.json and clippy haven't reported yet, so read them before merging.
- grain-serve import reads hatk's grain.db into a new index
- main

What changes
grain-serve import <hatk's grain.db> [--into <index>] [--strict]. It reads hatk's SQLite database into a new index:GRAIN_INDEX, unless--intonames another one. It opens the old database read-only and reads it in one snapshot. The target must be new, or an index that holds nothing. If it is anything else, including hatk's own file, the import refuses before touching it. The index is written in one transaction, so it ends up whole or empty.crates/grain-serve/src/index/import.rsdocuments the whole mapping.Records. Each of hatk's
[<nsid>]tables becomes the index's typed table.uri,cid,didandindexed_atare kept. Each row is rebuilt as its record from hatk's columns (hatk names each column by snake-casing the field, soiSOisi_s_o, and keeps JSON as text). The record is then checked against its lexicon, exactly as a live put is checked, and written throughrecords::Any. Self-labels are made again from the record. The rebuilt record also fills the generatedsort_atandh3. Germ's bytes are turned back into{"$bytes": …}from each form hatk stored: a BLOB, aUint8Array'sString()(3,55,54), its JSON ({"0":3,…}), or Jetstream's{"$bytes"}. Some records are refused and counted per table without stopping the import: no CID, a URI that is not its author's, or a record that fails its lexicon.--strictmakes any refusal fatal. Rows of a permissioned space (spacenot null) are counted and left out. hatk's child tables (<nsid>__<field>) are not read, because each array also sits in its parent's JSON column.What the network cannot rebuild, row for row.
_labels→labeland_reports→reportkeep their ids, so label sequence numbers and report ids mean what they did (a new label numbers on from hatk's last). An account label keeps its bare-DIDuri. The other tables copy unchanged:_mutes→mute,_preferences→preference,_push_tokens→push_token,_story_views→story_view,_space_invites→space_invite,_space_support→space_support._cursor'sjetstream:<url>rows go tostream_cursorunchanged, so Jetstream resumes fromjetstream:wss://jetstream.us-west.bsky.network._repos.statusis split in two.takendown(/admin/takedownand a report's takedown action). Host takedowns go throughhandleAccountEvent, which writesdeactivated. So eachtakendownrow becomes atakedownrow, withatset to the import time because hatk kept no timestamp.deactivated(hatk's single word for a host saying deactivated, suspended or takendown) anddeletedbecomerepo.host_status.pendingandactivestay as they are.failedstaysfailedwhere hatk had given up (retry_count ≥ 5, its default), and becomespendingat itsretry_afterwhere hatk would have retried.activeif it has arev(it was read), andpendingif not.pending, is recorded inlast_error, and is named in the summary.The check, at runtime. The import prints source and imported counts for every record table and every kept table (the ones above plus
repoandstream_cursor). It also prints each refused record with the reason, and every hatk table it did not import with its row count. The imported count is taken from the index's own table. If any kept table differs, for example a row the index would not take, nothing is written and the command exits 1. Exit codes: 0 when written, 1 when not, 2 on bad usage.Why
The owner-approved plan for leaving hatk:
grain-serve importcopies column for column and keepsindexed_at, because feeds order by it. The irreplaceable tables carry over whole, and admin takedowns are kept apart from what hosts report, so a rebuild can never reinstate a taken-down account.Records are validated by rebuilding them rather than by copying cell to cell. That way an imported row is exactly what a live put of the same record would have written, and anything the new index would refuse gets counted instead of silently landing.
How it was checked
Unit tests in
crates/grain-serve/src/index/import/tests.rsbuild a fixture database with hatk's DDL. The DDL comes frominitDatabase, grain'sserver/setupscripts, and hatk's schema generator, and was checked against a local devgrain.db. The fixture includes an older collection table with nospacecolumn, and a collection with no table at all.rev, and an unknown status), a record label, a label on a bare DID given and then negated, a NULLneg, a push token for each platform, an open report and a resolved report, and a relay cursor left behind.Index: the result is also read through the index's own API (cursor,taken_down,host_status,labels, label numbering).indexed_atkept, JSON columns intact, the generatedh3andsort_at, self-labels, and each bytes form. Six records are refused for different reasons, and each is counted, not fatal.--strictwrites nothing, and the same index can then be imported into. A NULL preference time (hatk allows it, the index does not) causes a DIFFERS, nothing is written, and the exit is 1. The import refuses a non-empty index, hatk's own file, a database that is not hatk's, and paths it cannot open.main's exit codes.tests/main.rsrunsgrain-serve importwithGRAIN_INDEX.Nothing was built or run on this machine; CI runs fmt, clippy, the tests and the coverage gate.
Shortcomings
app.bsky.feed.post), should take on the order of one to a few minutes. The time is linear in rows. The write uses a rollback journal on a fresh file, which journals almost nothing, plus a 256 MiB page cache and one commit. The import does not run in parallel. Dropping and rebuilding the indexes would be the next step if prod turns out slower._oauth_*: web sessions sign in again. Per the plan, "web sessions only, or nothing", and this pull does nothing._fts_*,_classifications,_space_watch,_space_repos.relay:*).app.bsky.actor.profile,app.bsky.feed.postgate,threadgate).deactivatedrepo was: hatk had already collapsed them.space.app.bsky.feed.postentities, which hatk kept only in a child table. They are not in the rebuilt record, which is fine because the field is optional.record_rev, which backfill treats as older than any revision it reads.maxRetriesis taken to be 5. If prod setBACKFILL_MAX_RETRIES, the line betweenfailedandpendingmoves.Review
The design and the status mapping: start at the module doc and
REPOinimport.rs.🤖 Generated with Claude Code