Backup & Recovery
Two ways to back up an Engrava database, what each one covers, and how to restore and verify. The most important things to know up front: a logical snapshot does not include the audit journal, the export is a consistent point-in-time snapshot even while writes continue (see below for what that guarantees and what it does not), and a naive file copy in WAL mode can lose data — all three are explained below.
Two kinds of backup
| Method | What it captures | Portable across versions? |
|---|---|---|
Logical snapshot (engrava snapshot) |
Thoughts, edges, embeddings, and actions as JSONL records | Only when the columns of the target version’s recognized tables are compatible — restore validates every column against a fixed, code-owned schema and rejects an unrecognized column on a known table |
| Physical file backup | The database file(s) — everything, including the audit journal. A direct filesystem copy can be byte-exact; VACUUM INTO and the Online Backup API produce a consistent copy that is not guaranteed byte-identical |
Tied to the SQLite file format (very stable) |
Pick the logical snapshot for portability and selective restore; pick a physical backup when you need the audit journal preserved or point-in-time file recovery — see below for which physical method gives you a byte-exact copy and which gives you a consistent one.
Logical snapshot and restore
engrava --db engrava.db snapshot -o backup.jsonl # export
engrava --db fresh.db restore -i backup.jsonl # import into a fresh db
The snapshot is JSONL: a metadata header line, then one record per
thought / edge / embedding / action. The export opens one read transaction
before its first read and holds it across the metadata header and all four
table scans, so the snapshot is a consistent point-in-time view rather than
four independent scans a concurrent writer could interleave with — a writer
committing a new thought and its edge between two scans can no longer leave
the edge in the snapshot while the thought it references is missing. It
writes to a temporary file next to -o and publishes it only once that read
has closed successfully, so a failing or cancelled export leaves an existing
file at -o untouched.
A snapshot does NOT include the audit journal. The
journal_entrytable — the tamper-evident hash chain — is not exported byengrava snapshot, and therefore is not recreated byrestore. What that leaves in the target’s journal depends on how you restored:
- A fresh target starts with an empty journal — there was no prior journal for it to have.
restore --clearemptiesjournal_entryalong with the four core tables it wipes, so the journal does not outlive the data it described.- A restore without
--clearmerges into an existing target instead — and if that target’s journal is non-empty, it refuses any record that collides with an existing row (a primary key orUNIQUEconstraint), rolling the whole restore back rather than letting the merge replace a row the journal still describes.--orphan-journal-entriesallows the merge anyway, accepting that the journal may end up describing data the merge discarded, andverifywill keep reporting the chain as valid regardless. The gate never fires against an empty journal, which is the ordinary case since journaling is opt-in. See CLI reference →restorefor the exact refusal message.If audit continuity matters, use a physical file backup (which copies the journal verbatim), not a logical snapshot. See Audit Trail.
restore options worth knowing (see the CLI reference
for the full list): --clear to empty the target’s four core tables and its
journal (not _metadata, extension_schema_versions, or extension-owned
tables) rather than wipe the whole target database, --skip-embeddings /
--re-embed to control embedding handling, --orphan-journal-entries to
allow a merge the collision gate above would otherwise refuse, and
--service for multi-service targets. When --clear encounters a persisted
sqlite-vec index, restore drops the derived table transactionally and the next
configured open rebuilds it. Keep engrava[vec] installed for that
virtual-table reset. Every restore — merge or --clear — also checks for the
full-text search index and, when it exists, rebuilds it from the target’s
thought rows before committing, so a restore that replaces a colliding
record does not leave keyword search matching stale content.
Embedding handling during restore
A normal restore imports the embedding rows carried by the snapshot.
--skip-embeddings discards those rows and restores text/graph/action data
without vectors.
--re-embed also discards the snapshot vectors, then generates new vectors for
every imported thought. The provider must come from the configuration passed via
--config. A top-level provider supports single-database restore and acts as the
fallback for configured services:
database:
path: ./fresh.db
embeddings:
provider: sentence-transformer
model: sentence-transformers/all-MiniLM-L6-v2
engrava --db fresh.db --config engrava.yaml restore -i backup.jsonl --clear --re-embed
A service can inherit that top-level provider or override it:
embeddings:
provider: ollama
model: nomic-embed-text
services:
data_dir: ./data
default_service: main
configs:
main:
embeddings:
provider: sentence-transformer
model: sentence-transformers/all-MiniLM-L6-v2
engrava --config engrava.yaml restore -i backup.jsonl --service main --clear --re-embed
services.configs.<name>.embeddings has precedence over the top-level fallback.
Without either provider, --re-embed fails before importing records. Retain the
snapshot embeddings or use --skip-embeddings when no provider is available.
The re-embed and its model/dimension/prefix identity update commit atomically.
Use a fresh target or --clear: a target with existing embeddings is rejected
without --clear so surviving vectors cannot be mislabeled as the new corpus.
The general --clear sqlite-vec reset described above also protects this path.
--skip-embeddings and --re-embed are mutually exclusive.
Trust boundary
restore is designed for snapshots you produced yourself with engrava snapshot — your own trusted backups. That is the supported input boundary.
Restore does not blindly trust the file. Every non-empty line must be a JSON
object — blank lines are ignored — and each record targeting a known table
(thought, edge, embedding, action) is validated against a fixed, code-owned
schema: its columns must be a known subset of that table’s columns, the required
columns must be present and non-null, and each value must match its column’s
type (an imported embedding vector must be valid base64 — the base64 check
applies only when embeddings are actually imported, not under
--skip-embeddings or --re-embed, which never read the stored vectors). A
record whose type is not one of those tables is skipped, so a newer snapshot
restored into an older build ignores record types it does not understand rather
than failing. The whole restore runs as one transaction: if any record is
malformed or carries a bad value, the transaction is rolled back and no rows
are written — not even a --clear that ran first, and not the records that
validated before the bad one. Column names never come from the file; the SQL
uses a fixed, built-in column set, so a hand-edited or corrupted key cannot
alter the statements that run.
What restore does not do is vouch for the content of a snapshot from someone else. The thought text, metadata, and other values are restored verbatim, so a snapshot obtained from an untrusted third party is untrusted data in your database — treat it with the same caution as any external import. Restore a snapshot you trust the origin of; if you must ingest an external one, review it first. Restore also assumes the file is not being modified while it runs; point it at a completed backup, not a snapshot that is still being written.
Physical file backup (WAL-safe)
Engrava runs in WAL mode, where recently-written data lives in the -wal
file until it is checkpointed into the main .db. A plain file copy is only safe
under specific conditions, so choose the method by whether the database is
live (being written) or stopped.
If the database is live (writers running)
A file copy of a database under active writes is not reliable — the .db and
-wal change during the copy and can be captured inconsistently. Use a method
that produces an internally consistent copy without stopping writers. Neither
of these two produces a byte-exact copy of the original file — see the checkpoint
method below for that.
SQLite Online Backup API — a hot, consistent backup driven from your own code
via Python’s sqlite3 backup API (source.backup(dest)). This is the
recommended way to back up a running database, and it supports incremental copies.
VACUUM INTO — writes a fresh, consistent, compacted copy of the database to
a new file. SQLite serialises it correctly against ongoing activity:
sqlite3 engrava.db "VACUUM INTO 'engrava-backup.db';"
Both produce a single clean .db you can store or move; neither requires copying
the -wal/-shm files, and neither is guaranteed byte-identical to the source
file — VACUUM INTO rebuilds the database into a fresh, compacted file, which
typically differs in size and byte layout even though its rows match.
If you can stop or quiesce writers
When you can take the database offline (or guarantee no writes for the duration), a copy is safe once it captures every committed change: copy the main file only after a checkpoint has folded the WAL back into it and the checkpoint reports it completed, or copy the whole file set (below). Stopping writers alone does not guarantee the checkpoint completes — an open reader can still pin WAL frames — so check its result before copying rather than copying unconditionally.
Checkpoint, then copy only if it completed:
# With no writers (and, ideally, no readers) active. The PRAGMA prints
# busy|log_frames|checkpointed_frames. busy = 0 means the checkpoint
# completed, so the main file alone holds every committed change. A
# non-zero busy means it did not (an open reader is a common cause), and
# the main file may then lack changes still in the WAL. `test -f` comes
# first because the sqlite3 CLI creates an empty database at a path that
# does not exist. The copy goes to a temporary name and replaces the
# backup only once complete, so a failed copy leaves an earlier backup
# intact.
test -f engrava.db &&
[ "$(sqlite3 engrava.db 'PRAGMA wal_checkpoint(TRUNCATE);' | cut -d'|' -f1)" = "0" ] &&
cp engrava.db engrava.db.bak.tmp &&
mv engrava.db.bak.tmp engrava.db.bak ||
{ echo "no backup made: missing file, incomplete checkpoint, or failed copy" >&2; false; }
wal_checkpoint(TRUNCATE) returns three integers: a busy flag, the number of WAL
frames, and the number checkpointed. An open reader can hold WAL frames pinned and
prevent a truncating checkpoint even with no writer active — the PRAGMA then
reports a busy result, and copying the main file anyway captures an incomplete
copy.
Or copy the file set (engrava.db + -wal + -shm) as one atomic unit —
e.g. via a filesystem-level snapshot (LVM, ZFS, a cloud volume snapshot) that
captures all three at the same instant. A plain cp of the three files of a
live database is not atomic and can still be inconsistent; only do the
multi-file copy when writers are stopped or behind a consistent snapshot.
Do not rely on a bare
cp engrava.db backup.db— or even a non-atomiccp engrava.db engrava.db-wal engrava.db-shm ...— while the database is being written. For a live database use the Online Backup API orVACUUM INTO.
Restoring
- From a snapshot:
engrava --db <target> restore -i backup.jsonl. Restore into a fresh database (optionally--clearan existing one) — the snapshot never carries the journal, so what that leaves in the target’s own journal depends on which of the three restore shapes above you used. For--re-embed, pass--configwith a top-level provider or a per-service provider override. - From a physical backup: stop the process, put the backed-up file in place,
and start again. A backup made with the Online Backup API,
VACUUM INTO, or a checked checkpoint-then-copy is a single self-contained.db. If instead you captured a multi-file filesystem snapshot, restoreengrava.db,engrava.db-wal, andengrava.db-shmtogether as the unit they were snapshotted in.
Verify a restore
After restoring, confirm the database is readable and the counts look right:
engrava --db restored.db info # reports counts; confirms the schema is readable
For a snapshot restore you can compare info counts against the source. If you
rely on the audit journal and restored from a physical backup, also re-run
journal verification (see Audit Trail) to confirm the
chain is intact.
Multi-service backups
With EngravaManager, each
service is its own database file under the shared data directory. Back them up
the same way — either snapshot each service (snapshot --service <name>) or
take a WAL-safe physical copy of each <name>.db (plus its -wal/-shm).
Because services are independent files, you can back up, restore, or delete one
without touching the others.
For the exhaustive package reference, see the OSS backup-and-recovery.md docs.