Apps

Persist

# persist — schema-first persistence Your `deftype` schema is the source of truth. `deft/persist` derives a storage layout from it (SQLite DDL today, via the `src/sqlite.dft` backend) and hands back typed accessors. Reads convert rows back through `schema/from-value`; writes validate through `schema/validate` first — the same rules govern construction, decoding, and the database's own constraints (`NOT NULL`, `CHECK`, `UNIQUE`). The API is async-first: pure functions (`persist/sql`, `persist/name-of`, `persist/repo`, the codecs) are synchronous, while every store operation returns a `Future` — `[await [persist/find …]]`, or `… |> await` mid-pipeline. Awaiting suspends only the calling coroutine, so TUI app threads keep rendering while the store works, and a remote backend (Postgres, …) fits the same surface without fighting the API. ## Quick Start
import "deft/persist" as persist

deftype Sale %{
    :id     %{ :type :number :primary-key true }
    :region %{ :type :string :required true :index true }
    :month  %{ :type :string :required true }
    :amount %{ :type :number :default 0 } }

set store [sqlite/open ".deft/app.db"]
[await [persist/ensure $store :Sale]]           # idempotent layout create
set sales [persist/repo $store :Sale]           # typed accessor (sync)

[await [persist/insert $sales %Sale{ :region "West" :month "jan" :amount 4 }]]
# => %Sale{:id 1 :region "West" :month "jan" :amount 4}

[await [persist/get $sales 1]]                  # → %Sale{...} or nil
[await [persist/update $sales %{:id 1 :amount 9}]]   # plain map = partial patch
[await [persist/delete $sales 1]]               # → rows changed
Everything is receiver-first, so await mid-pipeline and keep going:
set rows ([persist/find $sales %{ :where %{ :region "West" } :order %{ :month :asc } }]
    |> await
    |> filter {|s| ($s~amount > 2)}
    |> map {|s| "$s~month: $s~amount"}
    |> collect)

# Fan out across stores/queries — multi-await, or await-all for a list:
[await [persist/count $sales] [persist/count $sales %{ :where %{ :month "jan" } }]]
## Results Awaiting resolves to plain success values — vectors of typed records, single records, numbers, `nil` — so they flow straight into `|>` pipelines. Failure resolves to a `%Err{ :message … :path? }`; check with `err?` after the await. Validation failures keep the path-annotated shape of `schema/validate`, so you can point the user at the offending field:
set r [await [persist/insert $sales %{:region "West" :bogus 1}]]
if [err? $r] {
    echo "rejected:" $r~message          # unknown field 'bogus'
}
## API `types` accepts a single type, a vector, or a `%{type opts}` map for per-type overrides. Sync functions return values directly; async functions return a Future — await them. | Function | Returns | Purpose | |----------|---------|---------| | `[persist/sql :Type ?opts]` | string | Backend definition SQL — table DDL or `CREATE VIEW` (pure, inspectable) | | `[persist/name-of :Type]` | string | Storage name (`:HTTPServer` → `"http_server"`) | | `[persist/repo $store :Type ?opts]` | `%PersistRepo{}` | Typed accessor (pure binding) | | `[persist/view $store $view]` | `%PersistRepo{}` | Read-only typed accessor over a declarative view (pure binding) | | `[persist/ensure $store refs ?opts]` | Future → true | Create missing layout: tables and/or views (idempotent) | | `[persist/migrate $store refs ?opts]` | Future → report | Align layout additively + preflight data + create/recreate views | | `[persist/check $store refs ?opts]` | Future → report | Report rows the schema rejects (tables and views) | | `[persist/all $repo ?spec]` | Future → records | All rows as typed values | | `[persist/find $repo ?spec]` | Future → records | Filtered / sorted / paged rows | | `[persist/get $repo id]` | Future → record/nil | One row by primary key | | `[persist/insert $repo value]` | Future → record | Validate + INSERT → stored value | | `[persist/insert-many $repo values]` | Future → records | All-or-nothing batch insert | | `[persist/update $repo value]` | Future → record | Validate + UPDATE → stored value | | `[persist/upsert $repo value]` | Future → record | INSERT … ON CONFLICT(pk) DO UPDATE | | `[persist/delete $repo id]` | Future → number | DELETE by primary key → rows changed | | `[persist/count $repo ?spec]` | Future → number | Row count, with optional `:where` | | `[persist/query $repo sql ?params]` | Future → records | Raw SQL → typed rows | | `[persist/pivot $store $ref spec]` | Future → matrix | Cross-tabulation over a type or view | | `[persist/foreign-keys $store ?on]` | Future → true | Toggle FK enforcement on the connection (default ON) | | `[persist/meta-ensure $store]` | Future → true | Create the reserved `_persist_meta_` table | | `[persist/meta-read $store scope ?version]` | Future → entry/nil | Read a stored schema blob (current by default) | | `[persist/meta-history $store scope]` | Future → entries | All retained versions for a scope | | `[persist/meta-check-text $store scope version text]` | Future → `%{:ok …}`/`%Err` | Compare a raw blob without parsing the stored one | | `[persist/meta-write-text $store text opts]` | Future → entry | Write a raw blob (`:scope`, `:version`) | | `[persist/meta-record-text $store text scope version mode]` | Future → entry | `:if-missing`, `:auto` (bump on change), or `:strict` | | `[persist/meta-prune $store scope ?keep]` | Future → number | Drop old non-current versions | `%PersistRepo{}` implements the `Persistable` protocol, so postfix dispatch works too — the result is still a Future: `[await [$sales(all)]]`, `[await [$sales(get 1)]]`, `[await [$sales(insert-many $batch)]]`. ## Async, transactions, and backends - One handle, one mode. The first persist call flips its SQLite handle to async-only; raw `sqlite/*` sync functions on that handle then return `%Err`. Drive outer transactions with the async verbs (`sqlite/begin-async` / `commit-async` / `rollback-async`), use `persist/query` for raw reads, or open a second handle. - Ordering. All statements for a handle are FIFO on its executor thread, so multi-statement sequences (transactions, savepoints) stay in order; `insert-many`'s savepoint nests inside an outer async transaction. - Concurrency. Different handles run in parallel — await several futures at once with `[await $f1 $f2 …]` (or `await-all` from `deft/stdext/async`) to fan out across repos; a TUI coroutine awaiting a store keeps painting. - Backends. Every store operation funnels through the backend's `io-*` driver (`src/sqlite.dft`): `io-exec`, `io-query`, `io-query-one`, `io-scalar`, `io-query-cols`, all `@async`. A remote backend implements the same five with the same semantics and the facade (and every caller) is unchanged. Keep stores' raw SQL dialect access behind `persist/query`. ### Common options | Option | Applies to | Meaning | |--------|-----------|---------| | `:name "…"` | all | Storage name override | | `:id true/false` | all | Implicit `id` primary key column (default true) | | `:indexes @{…}` | ensure/sql/migrate | Composite index specs `%{:fields @{…} :name :unique}` | | `:append @{sql…}` | ensure/sql/migrate | Extra raw statements | | `:sample n` / `:all true` | migrate/check | Preflight scan size (default 1000) | | `:force true` | migrate | Apply CHECK-bearing columns despite invalid rows | | `:check true` | migrate | Run the preflight scan (default true) | ### Find specs `%{ :where %{field value} :order %{field :asc|:desc} :limit n :offset n }` — all keys optional. `:where` is equality only (`nil` → `IS NULL`), one `:order` field, values are parameterized. Unknown fields or bad directions error with `:path`. For anything richer use `persist/query`. ### Pivot specs
[await [persist/pivot $store :Sale %{
    :rows  @{:region}         # 1+ row-key fields
    :cols  @{:year :month}    # 1+ column-key levels
    :value :amount
    :agg   :sum               # sum|avg|min|max|count|first (default sum)
    :where "amount > 0"       # raw SQL filter, applied to both axes
    :col-order :asc
    :name "persist_pivot" }]]
# → %{ :name :cols @{"region" "2024.0 / jan" …} :rows @{%{…} …} }
Compiles to the built-in SQLite `crosstab` virtual table (rowsql built from the relation, column set via `SELECT DISTINCT`). `$store` position accepts a type or a `%PersistView{}` — crosstabs over views just work. Returns raw (key normalized) rows — pivot column names are dynamic, so values stay raw. ## Declarative views A view projects one or more deftypes into a real, persistent `CREATE VIEW` in the store — visible to any SQLite client, usable in `persist/query` and crosstabs, but read-only through persist. - The shape is a `deftype` (decoding/validation reuse the normal codec). - Joins are declared from the schema: a shape field with `:via @{:ref-field … final-field}` walks the base type's `:references` chain (each hop but the last must be a reference field), auto-generating LEFT JOINs. Multi-hop: `:via @{:person :org :label}`. - Fields without `:via`/`:from` are base columns; `:from @{:alias field}` picks a column from an explicit `%JoinSpec{}` alias. - The escape hatch for schemas without `:references`: `:joins @{ %JoinSpec{ :alias :u :type :Person :kind :left/:inner` :on @{ %{:left @{base-alias field} :right @{:u field}} } } }`.` Base alias = `:as` or snake(`:from`).
deftype Sale %{
    :id     %{:type :number :primary-key true}
    :region %{:type :string}
    :buyer  %{:type :number :references %{:type :User :field :id}} }

deftype SaleView %{
    :id     %{:type :number :primary-key true}
    :region %{}
    :amount %{}
    :buyer  %{:type :string :via @{:buyer :name}} }

def sales-by-buyer %PersistView{ :from :Sale :shape :SaleView }

[await [persist/ensure $store @{:Sale :User $sales-by-buyer}]]
# CREATE VIEW IF NOT EXISTS "sale_view" ("id", "region", "buyer") AS
#   SELECT "sale"."id", "sale"."region", "t1"."name"
#   FROM "sale" "sale" LEFT JOIN "user" "t1" ON "t1"."id" = "sale"."buyer"

set buyers [persist/view $store $sales-by-buyer]   # read-only repo
set rows [await [persist/all $buyers]]             # → @{%SaleView{…} …}

[await [persist/pivot $store $sales-by-buyer %{    # crosstab over the view
    :rows @{:region} :cols @{:buyer} :value :amount }]]
Spec: `%PersistView{ :from :shape :name? :join :left|:inner(dflt :left)` :as? :joins? :where? }` (`:where` is a raw SQL predicate). Shape field` keys: `:via`, `:from`, `:join` (per-field kind override; conflicting kinds on a shared path are an error). Semantics: writes through a view repo return `%Err` ("read-only"). `persist/migrate` compares each generated view against `sqlite_master.sql` and reuses unchanged definitions, dropping changed ones (reverse order) and recreating them (declaration order); `persist/check` scans view rows too (no rowids). Views hold no data, so recreation is always safe. v1 projects columns + joins only — aggregates/expressions use `persist/query` or explicit `:joins`. ## Field-spec storage keys (hybrid) Core mapping derives from the existing `deftype` vocabulary (`:type`, `:required`, `:default`, `:enum`); storage-specific keys can be declared on the field spec: | Key | Effect | |-----|--------| | `:primary-key true` | `PRIMARY KEY`; the repo's key for get/update/upsert/delete | | `:unique true` | `UNIQUE` column constraint | | `:index true` | Single-column index | | `:sql-type "INTEGER"` | Raw column type override | | `:sql-default "datetime('now')"` | Raw expression default (parenthesized; cannot be added by ALTER — migrate reports it blocked) | | `:references %{:type :User :field :id :on-delete :cascade}` | `REFERENCES` clause; the child-side column is auto-indexed (SQLite doesn't index FK columns itself) | No `:primary-key` → an implicit `id INTEGER PRIMARY KEY AUTOINCREMENT` column is added unless `:id false`. If a field is named `id` but not marked `:primary-key`, no implicit column is added (name clash) and keyed operations report "no primary key". ### Type mapping | Deft type | SQLite | Notes | |-----------|--------|-------| | `:string` | TEXT | | | `:number` | REAL | f64 — for money use INTEGER cents or TEXT and `:sql-type` | | `:boolean` | INTEGER | 1/0, decoded back to booleans | | `:keyword` | TEXT | bare name (`:open` → `"open"`) | | `:list` / `:set` / `:map` / `:any` | TEXT | `deft/stringify`, round-trips via `deft/parse` | | nested `deftype` | TEXT | stringified tagged map, decoded recursively | | `:many true` fields | TEXT | always stored stringified as a vector | Rows are decoded through `schema/from-value` with `:coerce true` — extra DB columns (including an implicit `id`) are preserved as extra keys on the typed map, so `$row~id` works even when `id` is not a declared field. ## Referential integrity SQLite enforces `REFERENCES` clauses only when `PRAGMA foreign_keys` is ON — and the pragma is per-connection, OFF by default. `persist/ensure` and `persist/migrate` switch it on for the connection before doing anything, so every write through a repo afterwards is checked (invalid parent references and forbidden parent deletes come back as `%Err` with SQLite's `FOREIGN KEY constraint failed`). Stores whose tables were created out of band should call `[await [persist/foreign-keys $store]]` (or the same `PRAGMA` via `sqlite/exec-async`) once. Two consequences worth knowing: - adding a `:references` column by `ALTER TABLE` requires a NULL default with FKs enabled — `persist/migrate` reports such columns as blocked; - SQLite does not index foreign-key child columns, so persist generates `CREATE INDEX IF NOT EXISTS "idx_<table>_<col>"` for every `:references` field automatically (on create and on migrate). ## Write semantics - `insert` validates strictly (unknown fields are errors), applies `:default`s and coercion, omits nil fields (DB defaults/NULL apply), and returns the stored row (generated key included). - `insert-many` takes a vector (or a single value) and inserts the whole batch inside one transaction — a savepoint, so it nests inside an outer `sqlite/begin-async`. All rows are validated/encoded up front, so a validation failure never touches the store; any failure (validation or SQL) rolls the batch back and returns the `%Err` with the failing row's 0-based `:index` (and the original error under `:error`). On success it returns the vector of stored rows. - `update` accepts two shapes: - a plain map → partial patch: fields are merged onto the current row first, so required fields and omitted columns keep their stored values; - a `%Type{…}` value → full state: defaults applied by the tagged literal are part of the write. - `upsert` = insert when the key is absent, else `ON CONFLICT DO UPDATE`. - `delete` returns rows changed. - Concurrency: updates are read-modify-write by key (no optimistic locking yet); wrap multi-statement work in `sqlite/begin-async` / `commit-async`. ## Schema metadata Every persist-managed database carries a reserved `_persist_meta_` table. It stores one canonical schema-descriptor blob per (`scope`, `version`); `is_current` marks the blob that produced the live data. A scope is a schema/model name (`"default"` for direct persist use). History is retained for diagnostics and explicit migrations. ``` _persist_meta_(scope, version, meta_version, persist_version, deft_version, schema_hash, is_current, types, created_at, updated_at) ```` - `types` is plain `deft/stringify` data — never executable source. - `meta_version` is the hard format gate; `persist_version` records the library version and is checked by major version. - `schema_hash` is SHA-256 over the canonical blob, so loads can verify a dataset without parsing a potentially large descriptor. - Same version + different hash is an error (bump the version); a newer version inserts a new row and keeps the old one current=0. - `persist/ensure` records a merged descriptor for direct use; schema-builder-generated `persist.dft` records the full schema blob (types, views, libraries, model graph), and `persist/migrate` updates it after a clean additive migration. - `_persist_meta_` is reserved: a type mapping to that storage name is rejected. The blob is data, not code: loaders still need the compiled `deftype`/model modules. It enables exact version checks, DB-to-schema regeneration, and safe diagnostics. ## Migration `persist/migrate` is additive by design: 1. missing tables are created (from the same `persist/sql` plan); 2. missing columns are added via `ALTER TABLE` when SQLite allows it — `PRIMARY KEY`, `UNIQUE`, NOT NULL-without-default and expression defaults are blocked and reported with a reason; 3. indexes are created (`IF NOT EXISTS`); 4. the preflight (`persist/check`) decodes a sample (default 1000, `:all true` for everything) and reports rows the schema rejects under `:invalid`; when any exist, CHECK-bearing new columns are blocked unless `:force true`; 5. extra DB columns and affinity drift are reported in `:missing` and `:changed` — never dropped or rewritten. `persist/check` runs the same scan standalone (audit after out-of-band edits). Schema removals and type changes are diagnostics only. ## Persisting app state A common use is an app saving its configuration/last state. For small shared preferences use `config/*`; for app-owned typed state (todos, documents, filters, selection, related records) keep a schema directory beside the app and drive it through the generated model:
from "todo.scm/model.dft" import new-todo

set m [new-todo]
set r [await [$m load ".deft/todo.db"]]   # :no-meta = first run
set todo [$m create %Todo{ :id "t1" :title "Buy milk" }]
[await [$m persist ".deft/todo.db"]]      # snapshot replace
`[$model persist]` is a snapshot, not an append: the current app state becomes the dataset, and the versioned schema blob is recorded in `_persist_meta_`. `[$model load]` refuses a mismatched schema/version until the generated `migrate` runs. See the runnable todo app (`packages/todo`) and the manual page `help/manual/03-Platform/10-app-state.md` for the full app-state pattern. ## Limits - SQLite backend only (v1); the async facade is store-neutral and the `io-*` driver is the seam a remote backend slots into. - Metadata stores the schema at the moment of persist/migrate; direct `ensure` calls only add missing type entries and do not reconcile changed columns. - Numbers are f64 (`:sql-type` for exact storage); BLOBs read as nil. - No relationships/joins beyond declarative views (use `persist/query` for anything richer), no optimistic locking, no bulk update/delete (loop or use `sqlite/exec-async` inside a transaction). - Views are read-only in SQLite (no INSTEAD OF triggers), project columns + joins only (no `:expr`/aggregates yet), and cannot wrap `temp` crosstab vtabs — but crosstab over a view works. - `:where` in pivot/view specs and `persist/query` are raw SQL escape hatches — parameterize or trust the source.