The observable model (day-model) — normative

day-model is a store whose writes wake only the readers of the field that changed. With one Signal<Vec<Item>>, every observer re-runs when any field of any element changes; bind’s equality gate keeps the native side precise, so the waste is compute; every row’s closures re-run and re-clone on every keystroke, and the waste grows with the list. Here each (element, field) is its own dependency node: editing one item’s name re-runs one closure instead of every row’s.

Enable it with the day facade’s model feature:

day = { version = "0.2", features = ["model"] }

use day::prelude::* then brings Store, Keyed, Elem, Field, Source, the #[derive(Observable)] macro, and the day_model crate name the derive’s generated code resolves against. The full API is day::model::*.

Declaring a model

#[derive(Observable, Clone, PartialEq)]
pub struct Item {
    #[obs(key)]
    pub id: u32,
    pub name: String,
    pub done: bool,
    pub address: Address,        // any Observable struct nests
    #[obs(skip)]
    pub cache: Option<Vec<u8>>,  // no accessor, no path, no trigger
}

The derive generates an ItemFields trait with one typed accessor per field, implemented for every Source<Item> (so store.name(), store.elem(id).name() and item.address().city() all work), plus Identified from the #[obs(key)] field and an Item::OBSERVED_FIELDS list for tests. Import the trait (use crate::model::ItemFields;) where the accessors are called.

Two rules apply. The key is always explicit: a struct that happens to carry an id that is not its key would make inference a trap, and a keyed collection over a struct with no #[obs(key)] is a compile error naming Identified. And field ids come from field names, so no hand-assigned index can ever collide.

Keys and ids

A key field is an integer, a Uuid, or a String, whatever implements AsKey. Integers are their own path handle, so an integer key costs nothing beyond the value itself. Wide keys intern process-globally to a handle on first use, so paths stay 12 bytes and every collection index stays u64-keyed; a handle, once minted, is stable for the process’s life (undo records and long-lived ids rely on it) and reverses through Key::of_handle. The interner is global rather than thread-local, because a background transaction’s reindex must mint the same handles the main thread resolves. interned_keys() reports its size.

Uuid is the default to teach, and Uuid::now_v7() the way to mint one: v7 is time-ordered, so inserts cluster at the B-tree’s edge instead of scattering pages, ids sort by creation, and uniqueness holds across devices, which makes merge and sync tractable later. The cost is that a v7 id carries its creation instant, so do not expose one where that matters. day_model::Uuid re-exports uuid::Uuid, so a model file needs no uuid dependency of its own. (Generation is native-target only until the web pipeline’s entropy import lands; the type works everywhere, and a web build receives ids rather than minting them.)

ModelId<M> is the typed surface: Copy, 8 bytes, opaque, and what elem, query results, list slots and destinations all speak, so a wrong-model id is a compile error rather than a wrong row. Everything converts into one (a raw handle from keys(), an integer literal, a Uuid, a &str), so store.elem(5), store.elem(uuid) and store.elem(id) are all elem. ModelId::key() recovers the real key for display or a deep link; Debug prints it.

#[derive(Observable, Clone, Default, PartialEq)]
pub struct Card {
    #[obs(key)] pub id: Uuid,     // or u32, or String
    pub title: String,
}

let id = Uuid::now_v7();
store.restructure("add", Op::Insert, id, |k| k.push(Card { id, ..Default::default() }));
store.elem(id).title().write("Draft".into());   // by key
let ids: Vec<ModelId<Card>> = store.ids();      // typed, tracked
assert_eq!(ids[0].key().as_uuid(), Some(id));

Integer keys reserve the top bit (1 << 63 and up is the interned-handle space); a debug build refuses a key at or above it rather than letting it alias a wide handle. Two rows sharing a key is an app bug the index cannot represent; the documented behavior is that the index resolves to the last one, and under persistence the fold’s upsert coalesces them to one stored row.

The store

thread_local! {
    static ITEMS: Store<Keyed<Item>> = Store::new(Keyed::default());
}
fn items() -> Store<Keyed<Item>> { ITEMS.with(|s| *s) }

A Store<T> handle is Copy and process-lifetime, like Signal::global: created inside whatever scope first touches it, it does not die with that scope. Store<Keyed<T>> is the collection case:

  • elem(key) — one element, O(1) by key; a handle whose fields bind controls.
  • keys() — a tracked read of the collection’s shape (which keys, in what order). A field write does not wake it; an insert, removal or reorder does.
  • restructure(label, op, key, f) — the structural write. The Op (Insert/Delete/Move) and the key ride the change log so a persistence layer can tell an insert from a delete; the UI, which only re-reads keys(), does not care.
  • update(label, f) — mutate the whole value; every reader wakes. It is the coarse option, right for wholesale loads.

Fields are bindings

it.name() is a Field: Copy, itself a Source (so fields nest to any depth, and a write to address.city does not wake a reader of address.postcode), and a Binding, so every two-way control takes it directly:

let it = items().elem(id);
text_field(it.name())                      // read AND write, no draft signal, no watch()
toggle(it.done())
day_piece_datetime::date_picker(it.date().map(date_of, iso_of))   // converted, still two-way

.map(to, from) converts on both sides with plain fns (an ISO string as a date, #RRGGBB as a Color) and the result is still Copy and still a binding. Reads track the most specific path touched: field.with(f) wakes only for that field, source.with(f) is the coarse subscription that wakes for anything under it. A reader opts into precision by what it reads; writes never need to know.

Observation belongs to computations. A tracked read inside a binding, memo or watch claims its path for exactly that computation’s current run, released when it re-tracks or dies, the same per-run bookkeeping day-reactive keeps for its own sources. Outside any computation (a build seeding an initial value, an event handler), a tracked read subscribes nothing and therefore claims nothing: no trigger is created at all, because nothing could ever wake through it.

Driving a list

A store is a row source (docs/list.md): list(store, row) shows the collection in its own order, and list(store.rows(projection), row) orders it through a key projection, a tracked read of key ids that reads only the fields the order depends on. The row builder receives a ModelSlot, itself a Source, so the derive’s accessors hang off it and follow the row across cell recycling:

list(items.rows(model::ordered_keys), |slot: ModelSlot<Item>| {
    row((
        label(move || slot.name().read()),   // wakes only for THIS row's name
        toggle(slot.done()),                 // two-way; follows the recycle
    ))
})
.on_select(|it: Elem<Item>| … )

The costs land where they should: a field edit patches only the control showing it; a change the projection reads re-runs only the projection and reloads natively; and a cell scrolled across the whole collection leaves no claims behind. day-pieces/tests/model_rows.rs measures all three.

Deleted rows

Reading a field of a deleted row returns the field’s Default (never a panic), and elem.exists() is the tracked guard: it re-runs its reader when the row is deleted or comes back. A write to a gone row is a silent no-op and announces nothing.

when(move || it.exists(), page(it), gone_notice())

Threads

Store is Send + Sync (when T is). A worker edits through a transaction; the write lock is the atomicity, so a reader never sees half of one:

let mut tx = store.transact();
tx.data().get_mut(2).unwrap().name = "from a worker".into();
store.elem(2).name().touch(tx.paths());   // name what changed, as portable components
drop(tx);                                  // the drop commits and queues the announcements

Announcing is the main thread’s job: store.pump() wakes exactly the paths the worker named. The trigger tables are thread-local to the main thread; what crosses the boundary is plain path components, re-established on arrival.

The change log

Every write announces (path components, field label, operation), observable headlessly with no UI at all:

let (_, log) = day_model::record(|| {
    store.elem(1).name().write("x".into());
});
assert_eq!(log, vec!["name"]);

record_changes yields the full Change records; record_values additionally captures each write’s prior and new value (the form an undo unit needs: one clone per write while a consumer asks, nothing when none does). Those two are scoped test hooks; install_change_sink(f) registers a standing consumer of every announced change until remove_change_sink, which is how day-persistence’s container watches the stores it loaded; store.store_id() names a store the way a change’s first path component does. observed_paths() and interned_nodes() expose the cost of observation itself, so a test can assert that triggers and interner slots are reclaimed when the scopes observing them die.

Changes can also flow the other way. with_author("name", f) stamps every change announced inside f with an author tag (None is the user), so consumers can tell an import, a replay or a merge from the user’s own edits, and decline their own echoes. store.merge_row(key, value, &["field", …]) feeds one row’s new value in from outside the app’s editing (another connection’s committed write, an import), replacing the stored row and announcing exactly the named fields, so their readers wake and nothing else does. An absent row announces nothing (an insert is restructure’s job); persistence.md’s external-changes section describes how a persistence container uses the pair.

Sessions and undo

Two consumers of the change log live here rather than in persistence, because neither needs a database. Sessions are the write-side of ValueChanged/ValueCommitted: field.write_preview(v) updates the value and wakes this field’s readers but records nothing; field.write_commit(v) seals the gesture as one record whose prior is the pre-session value; field.session() adds cancel() (Escape restores, zero records). Bound controls drive the pair automatically. UndoStack::new(levels) + stack.watch(store) turns the same log into history: units are turns, inversion comes from the captured prior values (a Delete carries its row), replay is tagged author: "undo" so consumers can tell it from the user, and can_undo/undo_label are signals. #[derive(Observable)] emits the ApplyField impl replay writes back through. day::install_undo(&stack) fronts the stack natively where the platform has an undo system (docs/persistence.md has the platform table).

Transient UI state

stack.set_transient_context(capture, restore) rides UI state that is not model data (a selection, a scroll position) along the history. capture runs as each unit seals and its snapshot belongs to that point of history; undo restores the snapshot of the unit history lands on (the previous unit’s, or the base snapshot taken at install once the stack empties), and redo restores the redone unit’s own. Changing the UI state between units records nothing and restores nowhere, because it is not history. The asymmetry matters: select shape A, move it, select shape B, move it, undo, and the selection lands on A, the state as it stood when A’s move sealed, rather than on the transient switch to B. Snapshots live only in the stack’s memory, so nothing persists; and because capture runs at seal, a selection write made in the same turn as its operation (place-then-select) is part of that unit’s snapshot. restore must write plain signals, never a watched store, because a store write inside a restore would fork history from inside a replay.

Costs

  • A trigger exists only where something looked; unobserved paths cost nothing to write.
  • Triggers are refcounted by observing scope and reclaimed when the last one dies; interner slots are refcounted by their triggers and children and reclaimed the same way. A Copy handle held across a reclamation revalidates and re-interns on its next use.
  • Element lookup is O(1): Keyed maintains its own key→index map.
  • Building a field handle costs no interner lookup; interning is paid once per element handle and once per nested struct, not per read.

The scaffold’s own editor (day new, src/pages/detail.rs) is the worked example: each form control binds a field accessor directly, and the model file’s one coarse watch handles all of its persistence. When a coarse watch stops being enough, docs/persistence.md is the next step: the same store, loaded from and autosaved to SQLite.

Undo groups spanning input events

UndoStack::begin_group(label) returns a must-use UndoGroup guard. Keep it in the interaction state and drop it after the final commit to seal one unit across event turns. Nested grouped calls join the outer unit and retain its label. The last guard seals the changes, even on an early return; an empty group creates no history entry. All watched edits during the interval belong to that group, so keep its lifetime limited to the interaction. This groups Day-Sketch’s Option/Alt-drag insertion and final movement into one Duplicate unit. See tests/undo.rs in day-model for multi-turn, nested-group, and empty-group regressions.

Undo and redo return false while a group is active, preventing replay from invalidating a live gesture. They become available again after its final guard drops.