# Persisting App State Apps have two kinds of persistent state, and they use different tools: | State | Use | |---|---| | Small shared preferences, toggles, theme choices | [`config/*`](config) — a reactive global key/value registry saved to `.deft/config.dft` | | App-owned typed data, last session state, filters/selections, related records | `deft/persist` + a generated schema — saved to a SQLite database | Use `config/*` when every app should see the value and a flat key/value store is enough. Use `deft/persist` when the state has structure: multiple todos, documents, recent items with ids, relationships, or a schema that may evolve. A generated in-memory model (`deft/model`) gives you `load` and snapshot `persist` on top of that typed schema. The reference app is `packages/todo` — a normal TUI app (launch it with `retro app deft/todo`). Its schema directory lives beside its source: ```text packages/todo/ ├── package.dft ├── src/store.dft # headless load/save + pure state edits ├── src/todo.dft # Deftlet panel (draw + keys) ├── test/test_todo.dft └── todo.scm/ # app-owned schema, generated by Schema Builder ├── types.dft ├── design.dft ├── persist.dft ├── model.dft └── schema-meta.dft ```` ## Define the app's schema Save the app schema from the Schema Builder into the app's own `<name>.scm/` directory. The todo app keeps a list plus one persisted configuration row:
deftype Todo %{
    :id %{ :type :string :primary-key true }
    :title %{ :type :string :required true }
    :done %{ :type :boolean :default false }
    :created %{ :type :number :default 0 } }

deftype AppState %{
    :id %{ :type :string :primary-key true }
    :filter %{ :type :keyword :default :all :enum @{ :all :active :done } }
    :selected %{ :type :string :default "" }
    :saved-at %{ :type :number :default 0 } }
`AppState` is a singleton (`id "app"`). It is ordinary typed data, not a config registry entry: it is saved and versioned with the rest of the app's dataset. ## Load at startup, persist on change The app's UI-free store module owns the lifecycle. It loads into plain app state and rebuilds a fresh model snapshot when saving:
from "../todo.scm/model.dft" import new-todo

def open {|path|
    @async
    set m [new-todo]
    set loaded [await [$m load $path]]
    if [err? $loaded] {
        if ([get $loaded :code] != :no-meta) {
            return %Err{ :message $loaded~message :code $loaded~code } } }
    set d [$m dump]
    set types [get $d :types %{}]
    %Ok{ :value %{
        :todos [get $types "Todo" @{}]
        :state [get $types "AppState" %{}] } } }

def save {|path snapshot|
    @async
    set m [new-todo]
    for todo in [get $snapshot :todos] { [$m create $todo] }
    [$m create [get $snapshot :state]]
    [await [$m persist $path]] }
- `:no-meta` on first run is normal: open an empty snapshot and seed a default `AppState`. - `persist` is a snapshot replace: the current app state becomes the dataset. Deleting a todo in memory removes its row on the next save. - Save after meaningful mutations. For a TUI, a local SQLite write is fast enough for small state; larger datasets should debounce or serialize saves (never write from two handles at once). The panel calls the store from its event handlers and returns `$self` to redraw:
def cycle-filter! {||
    set s [deref $state]
    set r [await [store/save $s~db [store/cycle-filter $s~snapshot]]]
    if [err? $r] {
        [reset! $state [merge $s %{ :error $r~message }]]
    } else {
        [reset! $state [merge $s %{ :snapshot $r~value :error "" }]] }
    nil }
The panel is a widget tree composed with `deft/ui/compose` (`[div]`, `|> [children …]`, `|> [props …]`) from `ui/multiline-text`, `ui/checkbox`, `ui/button`, `ui/label`, `ui/row`, and `ui/column`; new todos are typed into the editor (no dialog), and every todo row is a standard checkbox. `AppState` carries the last filter and selected todo id, so relaunching the app restores the same view. ## Schema changes `[$model load]` verifies the `_persist_meta_` schema blob against the generated `model.dft`/`schema-meta.dft` version and hash before reading any rows. If the schema changed, `load` returns `:schema-mismatch` instead of silently decoding old data. Regenerate `todo.scm/` from the Schema Builder, then run the generated migration explicitly (the todo app binds this to `m`):
import "../todo.scm/persist.dft" as schema

set db [sqlite/open ".deft/todo.db"]
[await [schema/migrate $db]]
[sqlite/close $db]
The migration is additive and updates the stored schema descriptor. Retry `[$model load]` afterward. Destructive changes (removed/retyped fields, renamed tables) need a hand-written migration or a new dataset. ## Operational notes - Keep one app-owned database per app, e.g. `.deft/todo.db`; the app's snapshot persists only the tables in its schema. - `TODO_DB` is a convenient dev/test override; production apps usually resolve an app-data path once at startup. - `_persist_meta_` is reserved and managed by persist. It holds the versioned schema descriptor, not app configuration. - Back up the database like any other SQLite file; WAL mode is optional and can be enabled with `PRAGMA journal_mode = WAL`. - Clear/reset state by saving an empty snapshot, not by deleting rows out from under the model. ## Runnable example ```sh retro app deft/todo retra app deft/todo # dev/test database TODO_DB=/tmp/todo.db retro app deft/todo deft test packages/todo/test/test_todo.dft ````