Where the studio keeps what it knows
Avatars holds the originals of every soul's avatar, my own face included. It also holds every request that made a picture, every choice Dad made in review, and every publication sent to Pippa. All of it sits in one data root outside the source checkout. Settings.data takes AVATARS_DATA when that is set, and otherwise the family registry's engine_data_root("avatars"). The house sign puts it on the registered service host, in a directory of its own beside the other family engines' data and outside Git.
When the store starts, it makes sure four things exist there:
originals/: the native bytes, each file named by its own SHA-256.thumbnails/: small derivatives that can always be rendered again from an original.exports/: delivery copies, such as a YouTube JPG.avatars.db: one SQLite database, switched to WAL mode.
The worker adds a worker.lock beside them when it starts. It matters below.
Two tables
The database has exactly two tables. records holds every domain object the studio has: an id, a kind, a parent, a version, two timestamps, and a JSON body with everything else. requests binds a caller's request id to a fingerprint of what was asked and to the record it created. That one gets its own lesson in this track.
These are the kinds the code writes today:
| kind | parent | what it is |
|---|---|---|
asset | empty | an original image or clip: hash, size, subjects, references |
set | empty (the soul is in the body) | a collection of frames, with its published map, selection and live revision |
batch | the set, or empty | one frozen generation request |
task | its batch | one image or clip to make, with its state |
profile | the soul | reference choices, named looks, appearance rules |
template | empty (id template-<workflow>) | house rules Dad edited for one workflow |
publication | the set | a reviewed snapshot sent to Pippa |
ownership | empty | the record of authority over the legacy sets moving into Avatars, once |
subject-adoption | the adopted subject | the same move, once, for one registered subject left out of the cutover |
unpack turns a row into one flat dict. The body's fields go in first and the columns go on top, so a stray body field can never shadow the real id, kind or version.
Two indexes, each with a reason
record_kind_parent serves the question the studio asks most often: the tasks of this batch, the publications of this set, the profile of this soul. The second index arrived the next day, on 2026-09-30. record_kind_state indexes an expression, json_extract(body,'$.state'), because the worker asks for active tasks once a second. The comment above it gives the reason: that poll must not grow with the history of every finished task. Without it, every batch ever finished would sit inside every one-second scan.
An expression index helps only a query that uses the same expression. When Store.list is given states, it writes exactly that text into its WHERE clause. Run EXPLAIN QUERY PLAN on the result and SQLite answers SEARCH records USING INDEX record_kind_state (kind=? AND <expr>=?). Spell the expression another way, say body->>'$.state', and the plan still says SEARCH, but it ends at (kind=?): the index narrows on the kind alone, and every task ever written is read and filtered. SCAN is not the warning sign here. The warning is a SEARCH whose parentheses stop short.
Why one generic table fits this app
A table per kind, with typed columns and foreign keys, is the textbook answer. Avatars chose one table, and the choice rests on three facts about this particular app.
- One writer. The architecture says it in one sentence: the host registered for Avatars is its single writer. In daily operation that is the engine process. Its worker takes an exclusive
flockonworker.lock, and a second worker on the same data root refuses to start withAnother Avatars worker already owns this database. With no writers to coordinate across processes, the rules a schema would enforce can live in one codebase. - Short transactions. The store's docstring opens with the rule: SQLite transactions never span network calls. Each transaction reads and writes a few rows and closes. Whatever Ember or Pippa has to say is asked before it opens or after it closes.
- Kinds that keep arriving. Both tables are exactly as the first commit wrote them on 2026-09-29. Since then, templates, clips with their duration and poster, and subject adoption all arrived as new kinds or new body fields, with no migration. The only schema change in the store's history is the state index.
What it costs
The price is real, and you can see it in the code. The body has no column constraints, so a missing field surfaces in Python rather than as a refused insert. Relations are a convention carried by parent; no foreign key is declared, even though every connection turns foreign_keys on. Some lookups read a whole kind and filter in Python. save_asset finds earlier copies of the same bytes by listing every asset and comparing sha256, and the library search filters and pages its results in Python as well. At the size of one family's studio that is cheap. If it ever stops being cheap, a sha256 lookup is the first candidate for another expression index.
Two one-time migration scripts in the repository opened the same store directly. They moved authority into Avatars once and are history now; the engine is the writer you plan around.
If WAL, JSON functions or expression indexes are new to you, SQLite Quest teaches them properly. This lesson shows one app's choice and the reasons behind it.