# 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
````