A view that knows it is a view
Nobody wants to read a moment by scanning month files. Pages need indexes: newest capture first, everything in one trip, every result for one phrase. So the engine keeps a SQLite file beside the log, mirror.sqlite3, and every read goes through it. Its docstring states its standing in the first paragraph: the log is ground truth; the mirror applies each op as it is appended and is rebuilt whole by replaying the log, never patched by hand. The family calls this the purge-and-replay rule, and Spark keeps it exactly.
That one sentence changes how you treat the file. You can delete it. You can rebuild it on a whim. A bug in a fold is fixed by fixing the code and replaying, never by editing rows.
The shape of a table
Every record table keeps the whole folded record as JSON in a record column, beside the few columns that reads filter and sort on. The moments table, for example, has id, kind, captured_at, local_date, trip_id, deleted and seq as columns, and everything else lives in the JSON. A new field in an op therefore reaches every read without touching the schema. Only a field you want to filter on needs a column.
seq is the op's position in the log. The archive lists moments by captured_at descending and breaks ties by seq, so two moments captured in the same second still come back in a stable order across pages; a test pins that pagination.
There are eleven derived tables: moments, media, results, turns, marks, links, trips, purposes, threads, requests and search, plus meta, which holds the schema version and the log fingerprint. Nine of the eleven are record tables. The other two keep no record column: requests maps a client request id to the op and the id it produced, and search holds text with pointers back to its moment. search is an FTS5 table with the trigram tokenizer, so it matches inside words in any script; a later track in this quest uses it.
Folding
Each op has a small handler that folds it into the tables. Creates use INSERT OR IGNORE, so a create whose id already exists changes nothing. Edits read the current record, patch only the editable fields and write it back. turn.finish merges onto the record turn.start made. A mark or link that is added again flips its active column back to 1. Put together, the mirror is a fold over the log: apply the ops in log order and you get the state.
Is this mirror current?
The second code block answers that with two facts from meta: the schema version must equal the code's SCHEMA_VERSION (4 today), and the stored log fingerprint must equal the one computed from the files right now. Corpus.open, the third block, asks that question at every boot and rebuilds when either fact fails. An explicit rebuild is one route away too: POST /api/admin/rebuild, whose docstring reads purge and replay the mirror from the log, derived state only.
The rebuild itself runs inside one transaction. It deletes every derived row and the meta, resets seq, applies every op the log yields, writes the schema version and commits. Any exception rolls the whole thing back, so a rebuild that fails halfway leaves the previous mirror exactly as it was, and the next attempt starts clean.
A schema change is a replay
The first code block is where most apps keep a migration chain. Spark keeps none. When the mirror opens and finds a stored schema version that differs from the code's, it drops every derived table, clears meta and creates the current shape. open then sees the version mismatch and replays the log into the new tables. In the module's own words, a mirror file never needs a migration of its own.
| Question | Migration chain | Derived mirror |
|---|---|---|
| Where is the history? | In the tables, reshaped by every migration | In the log, never reshaped |
| What does a schema change cost? | A script per version, kept forever | One version bump and a full replay |
| What if a fold had a bug? | A data-fix migration | Fix the code, replay |
| What grows over time? | The migration list | Replay time, with the log |
The price is in the last row. Replay time grows with the log, and every schema bump pays it once. For a corpus one person writes, by hand and by camera, that is the trade Spark chose.
Connection details that matter
- WAL journal mode. Readers do not block the writer and the writer does not block readers.
isolation_level=None. Python's sqlite3 module opens no implicit transactions, so everyBEGINandCOMMITin the mirror is written out where you can see it.check_same_thread=False, one connection. Writes arrive on worker threads throughasyncio.to_thread, so the connection must cross threads. What makes that safe is the corpus lock from the last lesson; the class docstring says it plainly: one connection, serialized by the corpus lock.
If any of these are new, SQLite Quest covers them properly: its track on writes, transactions and WAL, and its FTS5 lesson for the search table.
The test that earns the word "throwaway"
One test makes the claim falsifiable. It opens a corpus, creates a trip, creates a moment in that trip, edits the moment's place, and reads the moment back. Then it closes the corpus, deletes mirror.sqlite3, reopens, and asserts three things: the rebuilt moment equals the one read before, the trip is there, and filtering moments by that trip still finds it. Incremental folding and a full replay must agree, record for record.