Persistence (day-persistence) — normative

day-persistence stores observable models in SQLite, and SQLite keeps owning the data: a ModelContainer opens a database, creates or migrates each model’s table, and reads no rows, so opening a million-row store costs what opening an empty one does. Rows enter memory by faulting (a get, a batch ensure_resident, a list materializing the rows it shows) into a bounded per-model cache, and queries compile to SQL the engine answers from its indexes, so memory holds a working set rather than the table.

The write half reuses day-model’s change log, folded. The container watches the change log the UI already produces, and at the end of any turn that touched a store the accumulated changes fold into the smallest statement list that expresses them: twenty keystrokes into one field are one UPDATE, a row inserted and then filled is one INSERT, and a row deleted is one DELETE no matter what preceded it.

Enable it with the day facade’s persistence feature (implies model):

day = { version = "0.2", features = ["persistence"] }   # bundled SQLite, the default engine

A cargo feature selects the engine: sqlite-system links the OS’s libsqlite3 instead of compiling the bundled one; sqlite-cipher builds SQLCipher with vendored crypto. use day::prelude::* brings Model (the derive and the trait), ModelContainer, Sqlite, Recorder, Secret, the schema! macro, and the day_persistence crate name the derive’s generated code resolves against. The full API is day::persistence::*.

The same API works on web-dom: :memory: databases run in-process, and a file database lives in the browser’s origin-private file system, held by the day-sql worker and reached synchronously; see The web below. rusqlite itself is native-only; the wasm build compiles the day-sqlite-worker engine instead, with FTS5 and R*Tree included. Compiling it needs a clang with a wasm32 backend; docs/web.md has the setup.

Declaring a model

#[derive(Model)] implies Observable and adds the schema half:

#[derive(Model, Clone, Default, PartialEq)]
#[model(table = "trips", index("start_day", "done"))]  // table name + a composite index
pub struct Trip {
    #[model(id)]
    pub id: u32,                     // the key: INTEGER PRIMARY KEY, and #[obs(key)] too

    #[model(unique)]
    pub name: String,                // TEXT NOT NULL UNIQUE

    #[model(index)]
    pub start_day: DayDate,          // INTEGER epoch days (the canonical DayDate form)

    pub rating: Option<f64>,         // REAL — Option drops NOT NULL, None stores NULL

    #[model(column = "note_text")]
    pub notes: String,               // the column name, where the derived one is not wanted

    #[model(with = Iso8601)]
    pub booked: DayDate,             // TEXT "2026-08-19", same Rust type, other stored form

    #[model(json)]
    pub tags: Vec<String>,           // any serde type as TEXT SQLite's json_* can reach

    #[model(transient)]
    pub is_selected: bool,           // observable, never written to the database
}

The rules are short. A struct maps to a snake_case table and a field to a column of the same name, each overridable (table =, column =). Types map through one public trait (ColumnValue, below): StringTEXT, integers and boolINTEGER, f32/f64REAL, Vec<u8>BLOB, Option<T> wraps any of them and owns NULL. #[model(index)] on a field, index("a", "b") on the struct for composites, unique for constraints. Tables are created STRICT when the engine accepts the keyword (always, for the bundled build) and plain otherwise. #[obs(skip)] removes a field from both halves; a field the change log cannot name could never mark its row dirty, so persisting it would silently lose edits.

derive(Model) compiles on every target, the web included. A build that keeps its web tier on plain stores can swap the derive instead: Observable accepts #[model(…)], reads #[model(id)] as the key, and ignores the schema half (the Showcase’s Query page keeps its ten-thousand-row demo in memory this way):

#[cfg_attr(not(target_arch = "wasm32"), derive(Model))]
#[cfg_attr(target_arch = "wasm32", derive(Observable))]

The container

let container = ModelContainer::open(Sqlite::app_data("trips.db")?, schema![Trip, Lodging])?;
let trips: Store<Keyed<Trip>> = container.cache::<Trip>();

open migrates and stops. cache::<M>() is the model’s working set: an ordinary day-model store holding the rows currently resident. Every binding, projection and list source works on it unchanged, and nothing in the UI knows the container exists, but its keys() are whatever happens to be faulted in, never the whole table. Enumerate rows through a query; read one row through the container:

let trip = container.get::<Trip>(id);          // resident, faulted from the file if needed
container.ensure_resident::<Trip>(&keys)?;     // a batch, in one chunked SELECT
container.insert(Trip { id, ..Default::default() });   // resident + dirty + announced
let n = container.table_count::<Trip>()?;      // SELECT COUNT(*) — the table's true size
container.warm::<Trip>()?;                     // the document pattern: fault EVERY row in

Editing requires residency (writing a row faults it in), and a delete works either way (a value-free delete folds to one statement; with an undo stack installed the row materializes first so the history can restore it). The cache is bounded: set_cache_limit(rows) (default DEFAULT_CACHE_LIMIT, 8192 per model) evicts the oldest clean rows past the limit, sparing anything dirty and anything a binding still observes; evicted rows fault back on the next read. A row deleted this turn does not resurrect through a fault, even though the file still holds it until the flush.

A document container (a sketch whose canvas draws the whole scene) says so explicitly: set_cache_limit(usize::MAX) then warm::<M>() per model at open. The file-sized working set then behaves exactly as the old load-at-open engine did, as an explicit choice instead of the default.

Autosave is on by default: a change sink marks rows dirty as the change log announces them, and the end of any turn that touched a store flushes the fold in one transaction. save() flushes now and returns the error at a known point; set_autosave(false) accumulates until you do. Autosave failures land in container.last_error(), a tracked Signal<Option<String>> a status line can watch. record_sql(f) runs a closure and returns the SQL one flush of everything it changed issues; that is the headless persistence assertion.

The fold’s merge rules, per row: same-row changes coalesce (column names accumulate on one UPDATE), an insert absorbs the edits that fill it, a delete absorbs everything, moves are order-only and order is not persisted. Row values are read from the store at flush time; the change log carries which rows and columns moved, never their contents. A wholesale Store::update upserts every resident row: the cache is a working set, so a rewrite covers what it holds and never infers deletions from absence. Deleting is an explicit act.

Rows then merge across each other where one statement can carry them. Deletes from the same table become a single DELETE … WHERE id IN (?, ?, …), and updates join when they set the same columns to the same values (the shape a multi-selection edit makes, where one field is written across every selected row). Updates carrying different values keep their own statements, because SET holds one value per column. Deleting a group of five shapes is one statement, and renaming twenty rows to the same name is one statement. Rows keyed by more than one column (a join table’s membership, which no single-column IN names) stay unbatched, and so do inserts, which already fold to one multi-row upsert. A batch longer than SQLite’s bound parameter limit splits into chunks inside the same transaction, so the whole flush still commits or rolls back together. record_sql shows the batched form, which is what to assert.

Within the transaction, deletes always come last. A relation column’s ON DELETE CASCADE fires per statement (DEFERRABLE defers the constraint check, never the action), so a parent row’s delete emitted mid-batch would take every child the batch had not yet re-parented (an ungroup detaches its members and deletes the group in one turn). With inserts and updates flushed first, a row still referencing a deleted parent at delete time is one the batch never detached, an orphan the cascade rule is supposed to take.

Drivers and engines

The container speaks to SQLite through the SqliteDriver trait; two drivers are built in.

Sqlite (feature driver-rusqlite, on by default) is rusqlite. Sqlite::at(path) opens a file, Sqlite::memory() an in-memory database, and Sqlite::app_data("name.db") the per-app data directory: DAY_DATA_DIR when the host passes one (the Android and OpenHarmony hosts do), the platform’s app-data convention otherwise, under a day-db/ leaf beside day-part-fs’s day-fs/. Builder options: .key(Secret) for SQLCipher, .cipher_migrate() to accept an older cipher generation, .wal(false) to opt out of WAL, and .with_init(|conn| …), a hook over the raw rusqlite connection for loadable extensions, custom SQL functions, and PRAGMAs the crate does not model.

Recorder is always compiled: it answers queries from fixtures and records every statement, which makes persistence assertable with no database on disk (see Testing below).

capabilities() reports what the open driver can do (durable, encryption, full_text_search, rtree, external_changes), so a settings page can offer only what is real. An external crate can implement SqliteDriver for an engine the built-ins cannot be (libSQL, SEE, a proxy). Beyond execute/query, the connection trait carries execute_batch (a statement script — a migration step) and query_named (rows with their column names, for callers that surface rows as named objects); the built-ins implement both, defaults keep external drivers compiling. day-lite’s per-miniapp storage rides this same driver, so a superapp carrying both crates compiles one SQLite and the app’s engine features govern miniapp storage too.

Schema, fingerprints, migrations

Day claims only what it created. The derived DDL is documented, stable and readable by any SQLite tool; the bookkeeping is one _day_schema table (table_name, fingerprint, version), and every other table in the file is left alone, so an app can point the container at a database that also holds its own hand-made tables, and the two coexist.

Each model’s declared schema (table, columns, types, flags, indexes) hashes to a fingerprint. Equal fingerprints open instantly. A difference runs lightweight migration: added columns are ALTER TABLE … ADD COLUMNed and backfilled with the field’s Rust default, dropped columns are dropped. What lightweight migration cannot close (a rename, a type or codec change) needs a staged migration:

let plan = MigrationPlan::new()
    .custom(1, 2, |conn| {
        conn.execute("ALTER TABLE trips RENAME COLUMN heading TO name", &[]).map(|_| ())
    });
let container = ModelContainer::open_with(Sqlite::app_data("trips.db")?, schema![Trip], plan)?;

Stages run in ascending order from the file’s version, each in its own transaction; versions only go up, a gap is an error at open, and a file newer than the build refuses to open, because an old app writing a new schema would corrupt the data. Lightweight migration runs after the stages, so a stage handles the rename and the fingerprint closes the rest.

When a stored row will not decode, the read is lenient in exactly one case: NULL in any column reads as the field’s Default (this makes an added column’s old rows readable before their backfill). Everything else is a Decode error naming what disagreed.

Value codecs

ColumnValue is the one public trait behind the type-mapping table; implement it and your type is a column everywhere a built-in is:

impl ColumnValue for Rgb {
    const SQL_TYPE: SqlType = SqlType::Text;
    fn to_sqlite_value(&self) -> Value { Value::Text(format!("#{:02X}{:02X}{:02X}", self.r, self.g, self.b)) }
    fn from_sqlite_value(v: Value) -> Result<Self, DbError> { Rgb::parse(v.as_text()?) }
}

ValueCodec<T> is the named-alternative form, serde’s #[serde(with = …)] idiom for columns. Implement it on a unit struct and select it per field with #[model(with = …)]; #[model(json)] is sugar for the built-in Json codec. Option handling belongs to the framework: an impl never sees NULL and cannot disagree about it.

day-piece-datetime ships the date and time impls behind its persistence feature: DayDate and DayTime canonically as INTEGER (epoch days, seconds-of-day), plus Iso8601 (TEXT, lexicographic order is chronological order), EpochSeconds and EpochMillis for interop that expects Unix time.

Queries

A Query is a predicate, a sort and a window over one model’s table: ids only, and a tracked read. ids()/count()/first() re-run their caller exactly when the result set changes. Predicates build from the same names that bind controls: the derive emits Trip::name() (a typed column ref) beside the accessor trip.name(), so a typo is a compile error and every argument encodes through the field’s own codec.

let trips = container.query::<Trip>()
    .filter(Trip::done().eq(false) & Trip::start_day().between(from, to))
    .sort(Trip::start_day().asc())
    .limit(200)
    .live();

// The reactive form: the fetch re-derives when its signals change.
let results = container.query_fn::<Trip>(move || {
    let t = term.get();
    Fetch::new().filter(Trip::name().contains_ci(t)).sort(Trip::name().asc())
});

// The badge form: a live COUNT holding no ids — `SELECT COUNT(*)` behind the same
// dependency gate, O(1) memory at any result size. `count_fn` is its reactive twin.
let unread = container.query::<Trip>().filter(Trip::done().eq(false)).live_count();
label(move || unread.get().to_string());

The predicate vocabulary

Comparisons (eq, ne, lt, le, gt, ge, between), text (contains, contains_ci, starts_with, starts_with_ci), sets (is_in, not_in), and presence (is_null, is_not_null). A reference column adds is(id), is_one_of(ids), is_set and is_unset. They compose with &, | and !.

container.query::<Paper>().filter(Paper::pages().is_in([12, 45, 96]))
container.query::<Paper>().filter(Paper::title().starts_with("Quick"))   // case-SENSITIVE
container.query::<Paper>().filter(Paper::shelf().is_null())
container.query::<Essay>().filter(Essay::author().is(ada) | Essay::author().is_unset())

A set is sorted once when the predicate is built, so membership is a binary search rather than a scan, which keeps a large is_in cheap. An empty set matches nothing (in SQL it compiles to a constant, because IN () is a syntax error there rather than an empty set). starts_with is not built on LIKE, because LIKE’s SQLite default is case-insensitive for ASCII and would answer a different question.

Asking about a row’s relatives

A predicate can cross a declared relation. The derive emits Trip::lodging() (no receiver) beside the instance accessor trip.lodging() that reads and writes it, and one quantifier vocabulary covers both cardinalities, since a to-one is a to-many of at most one.

// Parent → children.
container.query::<Trip>().filter(Trip::lodging().any(Lodging::name().contains("Kyoto")))
container.query::<Trip>().filter(Trip::lodging().is_empty())
container.query::<Trip>().filter(Trip::lodging().count_ge(3))
container.query::<Trip>().filter(Trip::lodging().none(Lodging::confirmed().eq(false)))

// Child → the row its reference names.
container.query::<Lodging>().filter(Lodging::trip().any(Trip::done().eq(true)))

// Many-to-many, either direction, same vocabulary.
container.query::<Note>().filter(Note::tags().any(Tag::name().eq("rust".to_string())))
container.query::<Tag>().filter(Tag::notes().any(Note::title().starts_with("Draft")))

// They are ordinary predicates, so they compose.
container.query::<Trip>().filter(
    Trip::lodging().any(Lodging::confirmed().eq(true)) & Trip::done().eq(false),
)
FormReads asOver an empty relation
any(p)some related row matchesfalse
none(p)no related row matchestrue
all(p)every related row matchestrue — vacuously, as in SQL
is_empty()no related rowstrue
count_ge(n)at least n related rowsn == 0

all over an empty relation is true, which is the reason none sits beside it: “no unconfirmed lodging” and “every lodging confirmed” differ exactly for the rows with nothing related, and an app usually means the former.

Crossing a relation keeps the zero-cost tier. A relation predicate compiles to a correlated EXISTS over the relation’s indexed foreign key (a join crosses through the join table; nesting is unlimited, and each level is another subquery the engine plans). Its dependency set names the far table’s columns the inner predicate reads, so a related column the predicate never mentions costs nothing at all; one it does read marks the query stale for one requery. is_empty and count_ge compile to NOT EXISTS and a correlated COUNT over the same index.

NULL follows SQL’s rule, in both paths

A comparison against a NULL column is UNKNOWN, not false, and UNKNOWN is not a match, so Paper::shelf().ne(Some("A".into())) does not select a row whose shelf is NULL, exactly as WHERE shelf <> 'A' does not. (A nullable column’s Col is Col<Option<T>>, so its comparisons take the wrapped value; is_null/is_not_null need no operand at all.) UNKNOWN propagates through &, | and ! by SQL’s own three-valued logic, which lets the in-memory evaluation and the SQL form agree about NULL. is_null/is_not_null stay definite, as IS NULL does.

Sorting is unaffected and keeps SQLite’s ORDER BY rule, where NULL sorts below numbers; ordering and comparison are different questions and day answers each the way SQL does.

One evaluation path: the engine’s

A fetch compiles whole (predicate to WHERE, sorts to ORDER BY with the key as a deterministic tie-break, window to LIMIT), and the engine answers it from its indexes, at any table size. Case-insensitive text predicates stay exact: the native driver registers day_fold (Rust’s full-Unicode to_lowercase as a scalar SQL function) at open, so ÉCOLE folds in SQL exactly as it folds in Rust; SQLite’s own lower() folds ASCII only and would answer a different question. A driver without the function (Capabilities::unicode_fold, false for the web engine today) takes a fallback: SQL runs the exact conjuncts and carries the dependency columns, and the folding test re-checks over them in memory, preserving order and window.

Liveness is dependency-gated invalidation: a change to a column the query never mentions costs nothing (no SQL runs and nothing wakes); a change the query does depend on marks it stale, and one requery after the turn’s flush re-derives its ids. The fresh answer is diffed against the previous one into Insert/Remove/Move deltas a list can animate (verified by simulation before delivery; a delta list that would not land exactly on the new set reloads instead). Reads are always current: ids(), count(), take_events() and the rest settle pending staleness first, so imperative same-turn code never sees yesterday’s answer. With autosave off, queries answer from the last save, since only the file can answer them; that is the one read-side consequence of deferring writes. The 600-edit agreement test in tests/liveset.rs pins the property the deltas must hold: a mirror maintained purely from the deltas lands, id for id, where a fresh fetch does.

list(query, row) (facade feature persistence) makes a query a row source: the query holds ids, and rows fault in as the list binds them, a window at a time (Query::materialize(range) is the same path, callable directly), so showing row 40,000 of a large result costs one windowed SELECT rather than 40,000 resident rows. Bound rows stay resident (observed rows never evict); scrolled-away rows may leave and fault back. Set changes reach the native list as ListPatch::Splice row deltas it can animate; an NSTableView slides the row out instead of reloading. Hosts that cannot animate treat a splice as a reload.

There are three tiers of SQL access, each with its own price: the typed builder (incremental, the default); query_raw::<M>(sql, params, &["tables"]), a read-only SELECT of ids, re-run whole whenever a flush touches a named table; and with_connection(|conn| …), the driver’s connection for maintenance and imports, whose writes bypass the change log entirely. Call rescan() afterward: it re-reads the resident rows from the file (without re-marking them dirty) and re-runs every query, at O(working set + results), never O(table).

Full-text and spatial

Both of SQLite’s own answers ship in the derive:

#[derive(Model, Clone, Default, PartialEq)]
#[model(table = "posts", fts("title", "body", tokenize = "unicode61 remove_diacritics 2"),
        spatial(lat = "lat", lon = "lon"))]
struct Post { /* … */ }

query.filter(Post::fts().matches("kyoto OR osaka")).sort(rank())   // bm25, best first
query.filter(Post::geo().within(GeoRect { min_lat, max_lat, min_lon, max_lon }))

// Crossing a relation composes with a match: the search box that reads two tables.
query.filter(Post::title().contains_ci(t) | Post::chapters().any(Chapter::fts().matches(t)))

tokenize = "…" selects the FTS5 tokenizer (the diacritics-folding unicode61 form is what a search field usually wants); it is part of the schema fingerprint, so changing it rebuilds the shadow. A matches predicate works inside a relation crossing too (the compiled subquery resolves the target model’s shadow), which is how an app whose bodies live in a separate model searches titles and bodies in one fetch.

The schema derive generates the standard patterns rather than asking you to hand-write them: an external-content FTS5 table (posts_fts, content=posts) and an R*Tree table (posts_geo), each kept true by three AFTER INSERT/UPDATE/DELETE triggers, inside the same transaction as every write, and correct even for rows another tool writes into the file. (This is also why the fold’s upsert is a true ON CONFLICT DO UPDATE and not INSERT OR REPLACE: the latter’s implicit delete skips the delete triggers unless recursive_triggers is on, and the index would rot.) A freshly created shadow backfills from existing rows.

Both predicates compile through their shadows. matches is a subquery over the FTS5 table (rank() joins it so bm25 orders the result), and its dependency set is the indexed columns: a change to one of them re-queries after the flush, when the triggers have run, and a change to any column outside the set still costs nothing. within narrows through the RTree first and re-checks the exact ranges; the shadow stores 32-bit outward-rounded entries, a candidate superset that can never miss. A driver whose engine lacks FTS5 or RTree fails open with the module named, an error at open rather than a silent downgrade at query time.

Undo

let stack = container.undo(100);   // one history over every store, 100 units deep
day::install_undo(&stack);         // native fronts where the platform has them

The unit of undo is a turn (everything one event’s dispatch changed), inverted from the same change log the SQL fold reads: a Set inverts by writing the prior back, a Delete carries the row it removed and inverts to an insert, an Insert inverts to a delete. An undo replays those inverses (in reverse, in one batch) tagged with the author undo, and everything downstream handles them as ordinary changes: field triggers wake exactly what changed back, live queries animate the row’s return, and autosave writes the inverse statements, so undoing a delete is one INSERT rather than a snapshot. grouped(label, f) makes a multi-write gesture one unit; set_label_resolver turns unit labels into display text; can_undo/undo_label are signals a button or menu wires to directly. The stack lives in day-model, so a plain in-memory store undoes identically; undo does not depend on persistence.

Where the platform has its own undo system, day::install_undo mirrors the stack into a native front (Cap::UndoBridge): on macOS the window answers undoManager with a Day-owned NSUndoManager subclass, so the stock Edit menu retitles and enables itself and ⌘Z lands through the responder chain, while a focused text field’s own manager keeps precedence, which is the typing rule. iOS gets the same front through the root view controller, so the three-finger gestures, shake-to-undo and the iPad menu bar all reach the one stack. Everywhere else the app’s own affordances call stack.undo() directly.

Sessions: preview, then commit

A slider mid-drag must move the model live (labels and swatches render from it) while the durable fact is the settled value. The Binding trait carries the split, write_preview (from Event::ValueChanged) and write_commit (from Event::ValueCommitted), and a day-model field implements it as a session: previews land in the store and wake this field’s readers, but no change record exists until the commit, which announces one record whose prior is the pre-drag value. Sixty thumb positions cost sixty UI wakes (their job), one undo unit, and one UPDATE. Text fields ride the same machinery: each keystroke previews, and Return or focus loss commits; that is the typing coalescer. field.session() gives the explicit form (preview/commit/cancel; Escape restores and writes zero records); a plain Signal binding defaults both methods to write, so nothing changes where no session semantics exist. A session left open (focus never leaves a field before the process dies) has not committed, so its typing is not yet in the change log or the file.

Encryption

With the sqlite-cipher engine, .key(Secret::new(…)) opens an encrypted database; a wrong or missing key is DbErrorKind::BadKey at open, not a decode error later. rekey re-encrypts in place; encrypt_to/decrypt_to write converted copies (SQLCipher’s own export path; the conversion cannot happen in place). Day does not store the key: it arrives as a Secret that never prints and is zeroed on drop, and where it lives between launches (the OS keychain is the usual place) is the app’s decision. Builds without the engine reject .key with an error at open.

Maintenance

backup_to(path) is VACUUM INTO: a transactionally consistent, already-compacted snapshot taken while the app keeps writing, after flushing what is pending; there is no restore verb, because restoring is opening the copy. integrity_check() parses the PRAGMA into findings (empty means sound). checkpoint() folds the WAL into the main file; run it before the OS copies the file whole (device backup). vacuum() compacts in place; size_bytes() answers the settings-page question.

Relations

One<M> is a to-one reference: the foreign-key column on the child, stored in the target’s own key shape. Many<M> is the other side: a marker field storing nothing, whose accessor is a view over the children’s foreign keys, one indexed SELECT on a parent’s first read, memoized until a membership write invalidates it, and overlaid with the turn’s unflushed edits so mid-turn reads include them. The child’s foreign key is the only stored fact, so maintained inverses fall out of the pipeline that already exists, with no parallel bookkeeping. The API shape (@Model-style declarations, delete rules on the relationship) follows SwiftData’s vocabulary, so the concepts transfer:

#[derive(Model, Clone, Default, PartialEq)]
#[model(table = "trips")]
struct Trip {
    #[model(id)] id: Uuid,
    name: String,
    #[model(relation(target = Lodging, inverse = "trip", delete = "cascade"))]
    lodging: Many<Lodging>,
}

#[derive(Model, Clone, Default, PartialEq)]
#[model(table = "lodging")]
struct Lodging {
    #[model(id)] id: Uuid,
    name: String,
    trip: One<Trip>,            // Option<One<Trip>> for a nullable reference
}

trip.lodging().ids()            // tracked: the children, in relation order
trip.lodging().add(lodging_id)  // writes the child's FK — one UPDATE
lodging.trip().write(One::to(other_trip));   // …and the same thing, from the child

Write either side and both wake: add goes through the child’s foreign key, so the change announces, captures for undo, folds to one statement, and reaches any live query watching either table. Reads are tracked through the parent’s own field path, so a membership change wakes exactly the readers of that parent’s relation rather than every reader of the table.

Delete rules are declared on the Many side and default to nullify (SwiftData’s): children survive and their references clear, which requires Option<One<M>>; wiring refuses nullify over a required reference, naming the fix. cascade deletes the children with the parent, recursively and through the normal pipeline: the children come from one indexed SELECT, their deletes fold into chunked statements, and the engine’s own ON DELETE clause backstops rows this process never saw. With an undo stack installed the cascade materializes the rows it removes, so it is one undo unit that restores the whole subtree. deny refuses while children remain, through container.delete::<M>(id), the checked path; a raw restructure delete cannot be refused after the fact, so deny is the one rule that needs it. The generated DDL carries the matching REFERENCES … ON DELETE … clause, DEFERRABLE INITIALLY DEFERRED so statement order within a transaction never trips it, which also enforces the same rules on any other process writing the file.

Ordered to-many (ordered = "field") names a f64 field of the child that holds its position, a real, visible column. Placement is fractional, so a drag writes one row; when a gap bisects away the siblings rebalance to whole numbers first (O(n), rare, still one statement per row). insert_at(child, i) and move_to(child, i) are the verbs; an unordered relation refuses both.

Many-to-many declares join = "table" instead of an inverse. The derive generates the join table (the pair as its primary key, a foreign key per side, an index on the reverse side), and memberships are ordinary rows keyed by the pair, so they fold to SQL, undo, and merge through the same machinery every other row uses. Declare it on both models to read it from both (one relation, one store, two views); order lives on the membership, declared with a bare ordered on the side that owns it, because the same child sits at different positions under different parents.

#[model(relation(target = Note, join = "note_tags"))]
notes: Many<Note>,                       // on Tag
#[model(relation(target = Tag, join = "note_tags"))]
tags: Many<Tag>,                         // on Note — the same memberships, read the other way

tag.notes().add(note_id);                // one INSERT into note_tags
note.tags().contains(tag_id)             // true, from the same row

Deleting either side drops its memberships; under cascade it also takes the rows across the join that no other row still holds, so deleting one album never takes a shared photo with it. Model::RELATIONS exposes every declaration as data, for tools and tests.

A container can hold a second SQLite file that someone else owns — a catalog an app downloads and replaces wholesale, a database another tool builds — and read it through the same caches, queries, and lists as its own tables:

#[derive(Model, Clone, Default, PartialEq)]
#[model(table = "stations", external = "catalog", fts("name"))]
pub struct Station {
    #[model(id, column = "rowid")]
    pub id: u64,                      // the file's implicit rowid: stable within one file
    pub uuid: Vec<u8>,                // the catalog's own key, stable across files
    pub name: String,
    pub country: String,
    #[model(link(target = Favorite, local = "uuid", remote = "station"))]
    pub favorites: Linked<Favorite>,  // a marker: no column
}

#[derive(Model, Clone, Default, PartialEq)]
#[model(table = "favorites")]
pub struct Favorite {
    #[model(id)]
    pub id: u64,
    pub station: Vec<u8>,             // the catalog row's uuid, by VALUE
    #[model(link(target = Station, local = "station", remote = "uuid"))]
    pub link: Linked<Station>,
}

let container = ModelContainer::open(Sqlite::app_data("tunes.db")?, schema![Favorite])?;
container.attach_database("catalog", path_to_stations_sqlite, schema![Station])?;

container.query::<Favorite>().filter(Favorite::link().any(Station::country().eq("US")))
container.query::<Station>().filter(Station::favorites().is_empty())

external = "alias" marks a model whose table lives in the database ATTACHed under alias: its TABLE is the qualified alias.table, and the container creates, migrates, and fingerprints nothing for it — the file is read as it is, and attached read-only where the engine honors mode=ro, so a write to an external row fails at flush. Columns map by name like any model’s; a NULL reads as the field’s default, which is how a catalog’s nullable TEXT lands in a plain String. A table whose primary key is not an integer (a BLOB uuid, a composite) is addressed through its implicit rowid: #[model(id, column = "rowid")]. A WITHOUT ROWID table has no such handle and is not a model; read it with with_connection. An external model may declare fts(…), which names an index the file already carries (alias.table_fts, the same content= or contentless FTS5 the file’s builder made); matches, rank(), and their dependency tracking work unchanged.

attach_database(alias, path, schema) attaches the file and installs the schema’s models; calling it again with the same alias swaps files: the old one is detached, every resident row of its models is dropped, and every live query re-runs — a downloaded update lands with one call. detach_database(alias) leaves the models registered and empty. Both flush first, since SQLite refuses ATTACH/DETACH inside an open write transaction.

Links relate rows by value rather than by key: #[model(link(target = T, local = "column", remote = "column"))] on a Linked<T> marker field declares that this model’s local column names rows of T whose remote column equals it. No foreign key is written (SQLite cannot enforce one across databases, and the catalog side is not ours to constrain), so a favorite whose station left the catalog simply has an empty link, which is_empty() finds. A link’s predicate builder is the relation vocabulary — any, none, all, is_empty, count_ge — compiled to the same correlated EXISTS, joined on the two columns; declaring the link on both sides gives both directions. The query watcher treats the owner’s local column as membership: rewriting it moves the row across the link. Links work between two of the container’s own tables too; they are simply relations without a key.

External changes

Another connection’s committed writes (another process, a sync engine, a CLI editing the file) do not announce themselves. check_external() looks: detection is one PRAGMA data_version (the counter moves only when another connection commits, never for this one’s own writes), so it is cheap enough to wire to app foreground, window focus, or a timer. When the counter moved, pending local edits flush first, then only the resident rows re-read and diff, at O(working set) rather than O(table). Changed fields announce per column, disappearances take the structural path, every live query re-derives (a list animates the row another process inserted), and rows that arrived elsewhere become visible through the re-run queries and fault in like any other row. The merged changes are tagged ModelContainer::EXTERNAL_AUTHOR ("database"): the autosave fold declines the echo, an undo stack skips them (another author’s writes are not the user’s history), and any change sink can tell them from the user’s edits. A row another connection rewrote arrives whole, so its #[model(transient)] fields reset to their defaults, exactly as at fault.

Capabilities::external_changes says whether detection is real: the built-in driver claims it for file databases on native targets; memory databases (no second connection can reach one), the Recorder, and the web engine (its OPFS access is exclusive) answer Ok(false). Writes made through with_connection are this connection’s own, so the counter does not move for them; that recovery stays rescan(). tests/external.rs is the reference suite.

Testing

The Recorder makes persistence a unit-test subject:

let (driver, log) = Recorder::new().with_table("trips", fixtures);
let container = ModelContainer::open(driver, schema![Trip])?;
let sql = container.record_sql(|| {
    trips.elem(1).name().write("Osaka".into());
    trips.elem(1).name().write("Nara".into());
})?;
assert_eq!(sql, ["UPDATE trips SET name = ? WHERE id = ?"]);

with_table registers the rows any SELECT … FROM that table answers with, by table rather than by statement, so faulting paths keep only the keys they asked for, and a query against the Recorder answers with every fixture key whatever its predicate says. Assert the SQL the query compiled (the Recorder’s real value), and use Sqlite::memory() where predicate results themselves are under test. log.sql() and log.entries() expose everything issued, parameters included. The crate’s own suites in crates/day-persistence/tests/ are the reference: the fold rules against the Recorder (container.rs), the derive (derive.rs), real files, coexistence, backups and migration (sqlite.rs), and the cipher lifecycle (cipher.rs, --features cipher).

Not in this version

External storage (#[model(external)]) is a later phase, and eviction order is insertion-order rather than true LRU (true LRU is proposed, not promised). Row identity is the #[model(id)] key in its own stored shape (INTEGER, a 16-byte BLOB for Uuid, TEXT for a string key; see model.md); a model’s own display order is a projection concern and is not persisted, though an ordered relation’s position is.

The key layer refuses two shapes rather than mis-serving them. fts(…)/spatial(…) need an integer key (both address rows by SQLite’s ROWID), and a key field takes no codec: its stored form is the key’s own canonical one, because the fold’s WHERE parameters and the merge’s decoding derive it from the key kind.

Watching the SQL

Sqlite::trace_sql(f) installs the engine’s own statement trace (sqlite3_trace_v2 with SQLITE_TRACE_STMT): f sees every statement the connection executes (migrations, autosave flushes, live-query SELECTs, undo replays, maintenance) with bound parameters expanded by SQLite itself. The usual shape is a debug-only logger:

let driver = Sqlite::app_data("trips.db")?;
let driver = if cfg!(debug_assertions) {
    driver.trace_sql(|sql| trace!("sql: {sql}"))
} else {
    driver
};

The trace installs after any PRAGMA key, so a cipher key never reaches the sink. On web-dom a file database’s engine runs in the day-sql worker, out of closure reach: its statements log to the browser console as [day-sql] lines instead of calling f (:memory: databases call f on every target).

The web

OPFS, the browser’s origin-private file system, exposes its only synchronous random-access API inside dedicated workers, so on web-dom the engine runs in one: the day-sql worker, a second instantiation of the app’s own wasm module that day-cli’s host page spawns at boot. Every SqliteConnection call crosses a SharedArrayBuffer as one request; the main thread blocks the few microseconds until the reply state flips, and the worker flushes to storage before answering, so a commit that returned has landed and capabilities().durable is true. :memory: databases skip the channel and run in-process. Apps see none of this: Sqlite::at, autosave, undo, queries, and backup_to behave as they do everywhere else.

What the page must provide:

  • Cross-origin isolation. SharedArrayBuffer exists only under COOP/COEP response headers. day launch sends them; a static deployment must too (docs/web.md). Without isolation there is no worker: memory databases still work, file opens fail with Unsupported, and durable reads false, which the app can show in its UI.
  • One tab. OPFS access handles are exclusive, so the first tab of the app owns its databases; a second tab runs memory-only until the first closes.

Sqlite::web_storage() is the document-pool surface for file-per-document apps: exists/list for numbering a fresh document, import_db for landing an Open… pick, export_db for handing bytes to a download (flush first, so the image includes the current turn). Database names are the “paths” the rest of the app already uses; Sqlite::at("a.db") on the web opens the pool entry named a.db.