# Persistence `deft/persist` turns a [`deftype`](../01-Language/types-and-protocols) schema into a storage layout and typed accessors. You declare the fields once; the package derives the `CREATE TABLE` statements and hands back a repository whose reads are returned as typed values and whose writes are validated through [`schema/validate`](../02-Stdlib/schema) before they reach the database. The same field specs generate both the app-side validation and the database's own `NOT NULL` / `CHECK` / `UNIQUE` constraints, so the two can never drift apart. The package is pure Deft on top of the native [`sqlite/*`](sqlite) functions — no build flag, works in every host, and the v1 backend is SQLite. The API is deliberately store-neutral in naming: functions speak of types, records, and storage rather than tables and columns. Only the raw escape hatches say "SQL". The API is also async-first: pure functions (`persist/sql`, `persist/name-of`, `persist/repo`) are synchronous, while every store operation returns a `Future`. Awaiting suspends only the calling coroutine — a TUI stays responsive while the store works — and a remote backend (Postgres, …) fits the same surface naturally, since every operation funnels through one async driver seam.
import "deft/persist" as persist
## Quick Start
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'
}
## Functions Store operations return a `Future` (await them); `persist/sql`, `persist/name-of`, and `persist/repo` are synchronous. | Function | Purpose | |---|---| | `persist/sql :Type ?opts` | The backend definition SQL — table DDL or `CREATE VIEW` (pure, printable) | | `persist/name-of :Type` | Storage name (`:HTTPServer` → `"http_server"`) | | `persist/repo $store :Type ?opts` | Build the typed accessor (pure binding) | | `persist/view $store $view` | Read-only typed accessor over a declarative view | | `persist/ensure $store refs ?opts` | Create the layout if missing — tables and/or views (idempotent) | | `persist/migrate $store refs ?opts` | Align layout + preflight data + create/recreate views | | `persist/check $store refs ?opts` | Report rows the schema rejects (tables and views) | | `persist/all $repo ?spec` | All rows as typed values | | `persist/find $repo ?spec` | Filtered / sorted / paged rows | | `persist/get $repo id` | One row by primary key, or nil | | `persist/insert $repo value` | Validate + INSERT → stored value | | `persist/insert-many $repo values` | All-or-nothing batch insert → vector of stored values | | `persist/update $repo value` | Validate + UPDATE → stored value | | `persist/upsert $repo value` | INSERT … ON CONFLICT(pk) DO UPDATE | | `persist/delete $repo id` | DELETE by primary key → rows changed | | `persist/count $repo ?spec` | Row count, with an optional `:where` | | `persist/query $repo sql ?params` | Raw SQL → typed rows | | `persist/pivot $store $ref spec` | Cross-tabulation over a type or declarative view | | `persist/foreign-keys $store ?on` | Toggle FK enforcement on the connection (default ON) | | `persist/meta-read $store ?scope ?version` | Read a stored schema blob | | `persist/meta-history $store scope` | Retained versions for a scope | | `persist/meta-check-text $store scope version text` | Compare a raw blob without parsing | | `persist/meta-write-text $store text opts` | Write a raw schema blob | | `persist/meta-record-text $store text scope version mode` | Record with `:if-missing`/`:auto`/`:strict` | The `types` argument accepts a single type, a vector of types, or a `%{type opts}` map for per-type overrides. `persist/repo` returns a `%PersistRepo{}` implementing 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. Every statement for a handle is FIFO on its executor thread, so multi-statement sequences (transactions, savepoints) stay ordered; `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]` (`await-all` from `deft/stdext/async` wraps a list), and 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 neither the facade nor any caller changes. Keep dialect-specific raw SQL behind `persist/query`. ## Declaring Storage Core mapping derives from the ordinary field-spec vocabulary — `:type`, `:required`, `:default`, `:enum` — plus a handful of storage-specific keys. The field specs stay readable as schema; the storage keys refine how they land on disk: | Field spec key | Effect | |---|---| | `:primary-key true` | `PRIMARY KEY`; the 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 | | `:references %{:type :User :field :id :on-delete :cascade}` | `REFERENCES` clause; the child-side column is auto-indexed (SQLite does not index FK columns itself) | No `:primary-key` field ⇒ an implicit `id INTEGER PRIMARY KEY` AUTOINCREMENT` column is added, unless `:id false` is passed in the` options. Options common to the layout functions: | Option | Meaning | |---|---| | `:name "…"` | Storage name override | | `:id true/false` | Implicit id column (default true) | | `:indexes @{…}` | Composite index specs `%{:fields @{…} :name :unique}` | | `:append @{sql…}` | Extra raw statements | | `:sample n` / `:all true` | Preflight scan size (default 1000) | | `:force true` | Apply CHECK-bearing columns despite invalid rows | | `:check true` | Run the migrate preflight (default true) | ### Type mapping | Deft type | Column | Notes | |---|---|---| | `:string` | TEXT | | | `:number` | REAL | f64 — for money store INTEGER cents or TEXT via `:sql-type` | | `:boolean` | INTEGER | 1/0, decoded back to booleans | | `:keyword` | TEXT | bare name (`:open` → `"open"`) | | `:list` / `:set` / `:map` / `:any` | TEXT | `deft/stringify`, decoded via `deft/parse` | | nested `deftype` | TEXT | stringified tagged map, decoded recursively | | `:many true` fields | TEXT | stored stringified as a vector | Rows decode through `schema/from-value` with `:coerce true`; extra database columns — including an implicit `id` on a type that does not declare one — are preserved as extra keys, so `$row~id` works regardless. ## Referential Integrity SQLite enforces `REFERENCES` clauses only when `PRAGMA foreign_keys` is ON — per-connection and OFF by default. `persist/ensure` and `persist/migrate` switch it on before doing anything, so writes through a repo afterwards are checked: a missing parent reference or a delete of a still-referenced parent resolves to `%Err` (`FOREIGN KEY` constraint failed`), and `:on-delete :cascade` / `:set-null` fire as` declared. Stores whose tables were created out of band call `[await [persist/foreign-keys $store]]` once (or set the PRAGMA via `sqlite/exec-async`). `persist` also auto-indexes the child side of every `:references` field (`CREATE INDEX IF NOT EXISTS "idx_<table>_<col>"`), on create and on `persist/migrate` — SQLite itself only indexes the parent side, so without this every parent delete/update and FK check would scan the child table. One migration caveat: with FKs enabled, `ALTER TABLE ADD` COLUMN … REFERENCES` requires a NULL default, so such columns are` reported as blocked rather than applied. ## Writing - `insert` validates strictly (unknown fields are errors), applies `:default` values and coercion, omits nil fields so database defaults apply, and returns the stored record with its generated key. - `insert-many` takes a vector of values (or a single value) and inserts the batch in one transaction — implemented with a savepoint, so it also nests inside an outer `sqlite/begin`. Every row is validated and encoded before the store is touched; any failure rolls the whole batch back and returns the `%Err` with the failing row's 0-based `:index` (the original error is under `:error`). On success it returns the vector of stored records. - `update` accepts two shapes: - a plain map is a partial patch — the provided keys are merged onto the current row first, so omitted fields and required fields keep their stored values; - a `%Type{…}` value is the full new state — the defaults materialized by the tagged literal are part of the write. - `upsert` inserts when the primary key is absent, otherwise `ON CONFLICT DO UPDATE`s every non-key column. - All write verbs are async like the reads — `[await [persist/insert …]]`. Multi-statement work wraps in `sqlite/begin-async` / `commit-async`; updates are read-modify-write by key (optimistic locking is on the roadmap, not in v1). ## Finding `persist/find` takes a spec map, all keys optional:
[await [persist/find $sales %{
    :where  %{:region "West"}               # equality; nil → IS NULL
    :order  %{:month :desc}                 # one key
    :limit  10
    :offset 20}]]
Field names and order directions are checked against the type — typos resolve to `%Err` with a `:path`. For anything richer (joins, `LIKE`, aggregates) use `persist/query`, which runs parameterized SQL and decodes each row through the same schema:
[await [persist/query $sales "SELECT * FROM sale WHERE region LIKE ?" @{"W%"}]]
## Evolving a Schema `persist/migrate` aligns existing storage with the type, additively and non-destructively: 1. missing tables are created from the same plan as `persist/ensure`; 2. missing columns are added via `ALTER TABLE` where 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. a preflight decodes a sample of existing rows (default 1000, `:all` true` for all) and reports rejected ones under `:invalid`; when any` exist, CHECK-bearing new columns are blocked unless `:force true`; 5. extra database columns and affinity drift are reported under `:missing` / `:changed` — never dropped or rewritten.
set rep [await [persist/migrate $store :Sale]]
# => %{"Sale" %{:created false
#               :added   @{"amount"}          # applied
#               :blocked %{"due" "…reason"}   # reported only
#               :missing @{"legacy"}          # in DB, not in schema
#               :changed @{%{:column "priority" :db-type "INTEGER" :schema-type "REAL"}}
#               :invalid @{…}                 # rows the schema rejects
#               :scanned 1000}}
`persist/check $store types` runs the same scan without migrating — an audit after out-of-band edits. Schema removals and type changes are diagnostics; apply them by hand. For a complete app-owned schema and last-session walkthrough, see Persisting App State. ## Schema Metadata Every persist database carries a reserved `_persist_meta_` table. It stores one canonical schema-descriptor blob per `(scope, version)`; `is_current` marks the version that produced the live data. The blob contains the full schema — types, fields/specs, views, libraries, and (for generated models) the relation/order/info graph — as plain Deft data, never executable source. ``` _persist_meta_(scope, version, meta_version, persist_version, deft_version, schema_hash, is_current, types, created_at, updated_at) ```` - `ensure` records/merges the descriptor for direct persist use; schema-builder-generated `persist.dft` and generated models record the full schema blob; `migrate` updates it after a clean additive pass. - Same version with a different `schema_hash` is rejected. A higher version inserts a row and keeps the old version with `is_current=0`. - `meta_version` is the hard format gate; `persist_version` is checked by major version. - Raw text checks (`meta-check-text`) compare hashes without parsing the stored blob, which keeps large schemas cheap and avoids loading a descriptor just to reject a mismatch. - The storage name `_persist_meta_` is reserved for user types. The blob verifies and documents a dataset; loading still requires the matching compiled `deftype`/model modules. It can also be used by tools to regenerate schema sources from a database. ## Declarative Views A view combines tables relationally — but not as a throwaway query: it is a real `CREATE VIEW` persisted in the store's schema, declared from the `deftype` schemas themselves, and it becomes a first-class relation for typed reads and crosstabs. The shape is a `deftype` (so decoding and validation reuse the normal codec). Joins are declared from the schema's own `:references` via `:via` paths on shape fields:
deftype User %{
    :id   %{:type :number :primary-key true}
    :name %{:type :string}
    :org  %{:type :number :references %{:type :Org :field :id}} }

deftype Sale %{
    :id     %{:type :number :primary-key true}
    :region %{:type :string}
    :amount %{:type :number}
    :buyer  %{:type :number :references %{:type :User :field :id}} }

deftype SaleView %{
    :id     %{:type :number :primary-key true}   # base column
    :region %{}                                   # base column
    :amount %{}                                   # base column
    :buyer  %{:type :string :via @{:buyer :name}}         # 1 hop → User.name
    :org    %{:type :string :via @{:buyer :org :label}} } # multi-hop → Org.label

def sales-by-buyer %PersistView{ :from :Sale :shape :SaleView }
[await [persist/ensure $store @{:Org :User :Sale $sales-by-buyer}]]
generates (deterministic aliases: base, then `t1`, `t2`… per path prefix — shared prefixes dedupe, self-joins stay distinct): ```sql CREATE VIEW IF NOT EXISTS "sale_view" ("id", "region", "amount", "buyer", "org") AS SELECT "sale"."id", "sale"."region", "sale"."amount", "t1"."name", "t2"."label" FROM "sale" "sale" LEFT JOIN "user" "t1" ON "t1"."id" = "sale"."buyer" LEFT JOIN "org" "t2" ON "t2"."id" = "t1"."org" ```` Then use it like any relation:
set buyers [persist/view $store $sales-by-buyer]     # read-only repo
set rows [await [persist/all $buyers]]               # → @{%SaleView{…} …}
[await [persist/find $buyers %{:where %{:buyer "Ada"}}]]

# Crosstabs over the view — rows/cols/value are shape fields:
[await [persist/pivot $store $sales-by-buyer %{
    :rows @{:region} :cols @{:buyer} :value :amount :agg :sum }]]
Spec: `%PersistView{ :from :shape :name? :join? :as? :joins? :where? }` — `:join` is the default kind (`:left`, or `:inner`), `:as` renames the base alias, `:where` is a raw SQL predicate. Shape field keys: `:via` reference path, `:from @{alias field}` for explicitly joined columns, `:join` for a per-field kind override (conflicting kinds on a shared path are an error). Schemas without `:references` use the explicit escape hatch:
def flat-view %PersistView{
    :from :Sale :shape :SaleFlat
    :joins @{ %JoinSpec{
        :alias :u :type :User :kind :inner
        :on @{ %{:left @{:sale :buyer} :right @{:u :id}} } } } }
Semantics: views are read-only through persist (writes return `%Err`); `persist/migrate` reuses unchanged definitions (comparing the stored `sqlite_master.sql`), drops changed ones in reverse order and recreates them in declaration order — views hold no data, so recreation is safe; `persist/check` scans view rows for decode failures too. v1 projects columns + joins only: for aggregates or computed columns, use `persist/query`, or declare more `:joins`. ## Cross Tabulations `persist/pivot` compiles a declarative pivot into the built-in SQLite `crosstab` virtual table — over a type or a declarative view. Rows and columns are named by field, the column set is discovered with `SELECT DISTINCT`, and the aggregate is chosen from `sum` (default), `avg`, `min`, `max`, `count`, `first`:
[await [persist/pivot $store :Sale %{
    :rows  @{:region}         # 1+ row-key fields
    :cols  @{:year :month}    # 1+ column-key levels
    :value :amount
    :agg   :sum
    :where "amount > 0"       # raw SQL filter, applied to both axes
    :col-order :asc}]]
# => %{:name "persist_pivot"
#      :cols @{"region" "2024.0 / jan" "2024.0 / feb" …}
#      :rows @{%{…} …}}
Multi-level column names are the rendered key levels joined with `" / "`. Values stay raw (pivot columns are dynamic); row keys are returned with keyword keys. The spec is validated strictly — unknown fields and bad aggregates come back as `%Err` with paths. ## A Complete Example
import "deft/persist" as persist

deftype Expense %{
    :id       %{:type :number :primary-key true}
    :category %{:type :string  :required true :index true}
    :month    %{:type :string  :required true}
    :amount   %{:type :number  :required true :min 0}
    :recurring %{:type :boolean :default false}
}

set store [sqlite/open ":memory:"]
[await [persist/ensure $store :Expense]]
set expenses [persist/repo $store :Expense]

# One atomic batch (validation happens before the store is touched):
[await [persist/insert-many $expenses @{
    %{:category "rent" :month "jan" :amount 1200 :recurring true}
    %{:category "food" :month "jan" :amount 400}
    %{:category "food" :month "feb" :amount 380} }]]

set jan [await [persist/count $expenses %{:where %{:month "jan"}}]]
echo "january entries: $jan"

echo "by category:"
set matrix [await [persist/pivot $store :Expense %{
    :rows  @{:category}
    :cols  @{:month}
    :value :amount
    :agg   :sum}]]
$matrix~rows
    |> each {|r| echo "$r~category jan=$r~jan feb=$r~feb"}
## See Also - Types and Protocols — `deftype` and the field-spec vocabulary the storage layer extends - Schema — `schema/validate` / `schema/from-value`, the validators persistence writes through - SQLite — the underlying `sqlite/*` API - Packages — how `deft/persist` is mounted and imported