https://github.com/threez/prostore.cr
Declarative ORM for Crystal with automatic migration
https://github.com/threez/prostore.cr
Last synced: about 1 month ago
JSON representation
Declarative ORM for Crystal with automatic migration
- Host: GitHub
- URL: https://github.com/threez/prostore.cr
- Owner: threez
- License: mit
- Created: 2026-05-09T06:52:02.000Z (about 2 months ago)
- Default Branch: main
- Last Pushed: 2026-05-09T09:21:52.000Z (about 2 months ago)
- Last Synced: 2026-05-09T09:28:53.458Z (about 2 months ago)
- Language: Crystal
- Size: 113 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# prostore
A declarative ORM for Crystal targeting SQLite and PostgreSQL. The model is the
single source of truth for schema state — you do not write migration files.
Schema evolution is computed by diffing the desired state against the live
database, with a protobuf-inspired discipline that makes renames safe and forbids
in-place type changes.
The architectural decisions are recorded in [`doc/adr/`](doc/adr/). Read those
for *why*; this README covers *how*.
---
## Contents
1. [Installation](#installation)
2. [Quick start](#quick-start)
3. [Defining a model](#defining-a-model)
- [field](#field)
- [table_name](#table_name)
- [index](#index)
- [foreign_key](#foreign_key)
- [query](#query)
- [reserved tags](#reserved-tags)
4. [Type system](#type-system)
5. [CRUD](#crud)
6. [Querying](#querying)
- [Query builder](#query-builder)
- [Predicates (Q module)](#predicates-q-module)
- [Named queries](#named-queries)
7. [Default values, backfills, and lazy fields](#default-values-backfills-and-lazy-fields)
8. [Schema evolution](#schema-evolution)
9. [Migration system](#migration-system)
10. [Drift detection](#drift-detection)
11. [Connection](#connection)
12. [Operator CLI](#operator-cli)
13. [Testing](#testing)
14. [Architecture](#architecture)
---
## Installation
```yaml
# shard.yml
dependencies:
prostore:
github: threez/prostore
```
```sh
shards install
```
```crystal
require "prostore"
```
---
## Quick start
```crystal
require "prostore"
class User < Prostore::Model
field 1, :id, Int64, primary: true, auto_increment: true
field 2, :email, String
field 3, :name, String?
index 1, [:email], unique: true
end
# Boot: migrate then connect (shares the same connection for in-memory SQLite)
Prostore.setup("sqlite3://app.db")
# Insert
u = User.allocate
u.email = "alice@example.com"
u.name = "Alice"
u.save # → INSERT; u.id is now set
u.persisted? # → true
# Find
alice = User.find(u.id) # raises if missing
alice = User.find?(u.id) # returns nil if missing
# Update
alice.name = "Alice Smith"
alice.save # → UPDATE
# Delete
alice.destroy
# Query
User.all.order_by(:email).to_a
User.where(email: "alice@example.com").first
User.where(Prostore::Q.gt(:id, 10)).count
```
---
## Defining a model
```crystal
class Post < Prostore::Model
# ... fields, indexes, etc.
end
```
Every subclass is automatically registered in `Prostore.models` at compile time.
### field
```crystal
field , :, , **opts
```
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `tag` | `Int` literal | yes | Stable numeric identity. Survives renames. Never reuse a retired tag. |
| `name` | `Symbol` literal | yes | Column name. |
| `Type` | Crystal type | yes | See [Type system](#type-system). `T?` declares nullable. |
| `primary:` | `Bool` | no | Marks the primary key column. At most one per model. |
| `auto_increment:` | `Bool` | no | Auto-assign PK on INSERT. Requires `primary: true` and `Int32`/`Int64`. Uses `GENERATED BY DEFAULT AS IDENTITY` on PostgreSQL, `INTEGER PRIMARY KEY AUTOINCREMENT` on SQLite. |
| `default:` | scalar, symbol, `SQL.expr(...)`, or lambda | no | Value for new rows. A `Bool`, `Int`, or `String` literal is auto-wrapped to its SQL equivalent (`false`, `0`, `'text'`) **and** seeded on `.new + .save` so the ORM honours it. A `Symbol` is emitted verbatim as a SQL keyword or function (`:CURRENT_TIMESTAMP` → `CURRENT_TIMESTAMP`); `SQL.expr("...")` emits a verbatim `DEFAULT` clause — both are DB-side only (wrap in a lambda for ORM-side eval). A lambda `->(_m : MyModel) { ... }` runs in Crystal before INSERT. See [Default values](#default-values-backfills-and-lazy-fields). |
| `backfill:` | scalar, symbol, `SQL.expr(...)`, or lambda | no | Value for *existing* rows when adding a non-null column to a populated table. Accepts the same forms as `default:`. If identical to `default:`, a single-step `ADD COLUMN` is used. If different, a three-step plan (add nullable → backfill → apply NOT NULL) is used. Required when adding a non-null column to an existing table without a `default: SQL.expr(...)`. |
| `lazy:` | lambda | no | Compute-on-read field. Column is nullable in the DB; the lambda materializes the value on first read and persists it on `save`. Mutually exclusive with `default:`/`backfill:`. Requires `T?`. |
```crystal
class User < Prostore::Model
field 1, :id, Int64, primary: true, auto_increment: true
field 2, :email, String
field 3, :created_at, Time, default: :CURRENT_TIMESTAMP
field 4, :status, String, default: "active", backfill: "active"
field 5, :active, Bool, default: true, backfill: true
field 6, :score, Int32?, lazy: ->(_u : User) { compute_score(_u) }
field 7, :nickname, String?
field 8, :slug, String, default: ->(_u : User) { _u.email.split("@").first }
end
```
**Scalar literal auto-wrap:** `default: "active"` becomes SQL `'active'`;
`default: false` becomes SQL `false`; `default: 0` becomes SQL `0`.
**Symbol shorthand for SQL keywords/functions:** `:CURRENT_TIMESTAMP` emits
`CURRENT_TIMESTAMP` verbatim — no quotes. Use this for any SQL keyword or
0-argument function. Use `SQL.expr("...")` when you need a full expression with
operators or arguments (`gen_random_uuid()`, column references, etc.).
**Lambda shape:** Lambda defaults and backfills receive the model instance as a
single argument: `->(_m : MyModel) { ... }`. 0-arg lambdas `->{ ... }` are also
accepted — prostore detects the arity at compile time.
### table_name
Override the auto-derived table name (snake_case of the class name):
```crystal
class EmailMessage < Prostore::Model
table_name "messages"
# ...
end
```
Without an override, `Txmail::Account` → `txmail_account`. Module separators
`::` become `_`. The `prostore_` prefix is reserved.
### index
```crystal
index , [:, ...], **opts
```
| Parameter | Required | Description |
|-----------|----------|-------------|
| `tag` | yes | Stable numeric identity. |
| `columns` | yes | Array of symbol column names. Order matters for composite index left-prefix rules. |
| `unique:` | no | `true` to add `UNIQUE`. Default `false`. |
| `where:` | no | `SQL.expr("...")` partial-index predicate (PostgreSQL / SQLite). |
| `name:` | no | Override the auto-generated name (`__..._idx`). |
```crystal
index 1, [:email], unique: true
index 2, [:tenant_id, :status]
index 3, [:deleted_at], where: SQL.expr("deleted_at IS NOT NULL")
index 4, [:slug], name: "posts_slug_unique_idx", unique: true
```
On PostgreSQL, indexes are created with `CREATE INDEX CONCURRENTLY IF NOT EXISTS`
to avoid locking the table.
### foreign_key
```crystal
foreign_key , [:, ...], references: TargetModel, **opts
```
| Parameter | Required | Description |
|-----------|----------|-------------|
| `tag` | yes | Stable numeric identity. |
| `columns` | yes | Local columns participating in the constraint. |
| `references:` | yes | Target `Prostore::Model` subclass. |
| `references_fields:` | no | Target columns (defaults to the target's primary key). |
| `on_delete:` | no | `:no_action` (default), `:restrict`, `:cascade`, `:set_null`, `:set_default`. |
| `on_update:` | no | Same options as `on_delete:`. |
| `name:` | no | Override the auto-generated constraint name (`__fkey`). |
```crystal
class Comment < Prostore::Model
field 1, :id, Int64, primary: true, auto_increment: true
field 2, :post_id, Int64
field 3, :body, String
index 1, [:post_id]
foreign_key 1, [:post_id], references: Post, on_delete: :cascade
end
```
SQLite enforces `PRAGMA foreign_keys = ON` on every connection automatically.
### query
Declare a named, statically-analysed query. The block receives a query builder
via `with builder yield`, so `where`, `order_by`, `limit`, etc. dispatch
directly without a receiver.
```crystal
query :, ->() { }
```
```crystal
class User < Prostore::Model
query :by_email, ->(e : String) { where(email: e) }
query :active, -> { where(status: "active") }
query :top_in_tenant, ->(t : Int64) { where(tenant_id: t).order_by(:score, desc: true).limit(10) }
query :recent, ->(t : Time) { where(Q.lt(:created_at, t)).order_by(:created_at, desc: true) }
end
User.by_email("alice@example.com").first
User.active.count
User.top_in_tenant(42_i64).to_a
User.recent(1.hour.ago).to_a
```
Comparison predicates (`Q.lt`, `Q.gt`, `Q.lte`, `Q.gte`, `Q.ne`, `Q.in`,
`Q.like`) are fully supported inside named query bodies. The macro walker
extracts the field name from the predicate for index-coverage analysis.
At migrate time, every named query is validated: each filtered or sorted field
must be covered by a declared index (ADR-0006 strict mode, left-prefix rule). A
`SchemaError` is raised if coverage is missing — add an `index` declaration before
or alongside the query.
### reserved tags
Permanently retire a tag when you remove a field, index, or foreign key. Omitting
a tag without reserving it is a runtime error.
```crystal
reserved 3 # field tag 3 is permanently retired
reserved_index 2 # index tag 2 is permanently retired
reserved_foreign_key 1 # FK tag 1 is permanently retired
```
Retired tags may never be reused on the same model.
---
## Type system
Prostore maps Crystal types to a portable type tag, which is then translated to
backend-specific DDL. Nullable types are declared by appending `?`.
| Crystal type | Portable tag | SQLite affinity | PostgreSQL type |
|---|---|---|---|
| `Int32` | `int32` | `INTEGER` | `INTEGER` |
| `Int64` | `int64` | `INTEGER` | `BIGINT` |
| `Float32` | `float32` | `REAL` | `REAL` |
| `Float64` | `float64` | `REAL` | `DOUBLE PRECISION` |
| `String` | `string` | `TEXT` | `TEXT` |
| `Bool` | `bool` | `INTEGER` | `BOOLEAN` |
| `Time` | `time` | `TEXT` | `TIMESTAMP WITH TIME ZONE` |
| `Bytes` / `Slice(UInt8)` | `bytes` | `BLOB` | `BYTEA` |
| `UUID` | `uuid` | `TEXT` (36-char) | `UUID` |
| `BigDecimal` | `decimal` | `TEXT` | `NUMERIC` |
| `JSON::Any` | `json` | `TEXT` | `JSONB` |
| `Array(T)` where T is any above | `array_` | `TEXT` (JSON-encoded) | `JSONB` |
| Any `Enum` subclass (default) | `enum_string` | `TEXT` (member name) | `TEXT` |
| Any `Enum` subclass, `as: :int` | `enum_int` | `INTEGER` (member value) | `BIGINT` |
`Time` on PostgreSQL is `TIMESTAMP WITH TIME ZONE` (`timestamptz`). Crystal's
`Time` is always UTC.
Array types use JSONB on PostgreSQL and JSON-encoded TEXT on SQLite. Native
PostgreSQL arrays (e.g., `INTEGER[]`) are not used — JSONB provides a uniform IO
path across both backends.
Reads return `Array(T)` directly and writes accept `Array(T)` — encoding to JSON
happens at the model boundary, so callers never touch `to_json` / `from_json`.
Array literals are **not** accepted as `default:` values (the parser only takes
scalar literals, symbols, `SQL.expr(...)`, or a lambda). For an "always an
array, possibly empty" field, use a 0-arg lambda:
```crystal
field 9, :domain_ids, Array(String), default: ->{ [] of String }
```
Alternatively, declare the field nullable (`Array(String)?`) — the default is
then auto-inferred as `NULL`, at the cost of nil-checking every read site.
### Enums (ADR-0016)
Any Crystal `Enum` subclass — including `@[Flags]` enums — is a valid field
type. Storage is name-backed (`TEXT`) by default; `as: :int` selects integer
storage. The declared member set becomes part of the schema and is enforced
by a named `CHECK` constraint on the column.
```crystal
enum Status
Active
Pending
Archived
end
enum Tier
Bronze
Silver = 5
Gold = 10
end
@[Flags]
enum Perms
Read
Write
Execute
end
class User < Prostore::Model
field 1, :id, Int64, primary: true, auto_increment: true
field 2, :status, Status # TEXT, CHECK IN ('Active', 'Pending', 'Archived')
field 3, :tier, Tier, as: :int # INTEGER, CHECK IN (0, 5, 10)
field 4, :perms, Perms # @[Flags] → INTEGER, CHECK 0..7 (1|2|4)
end
```
Reads return the Crystal `Enum` value directly; writes accept it directly. No
manual `to_s` / `from_value` at call sites.
**Evolution:** members can be *added* freely — migration emits a CHECK-swap
step that widens the accepted set, existing rows untouched. Members **cannot
be removed, renamed, or have their integer value changed in place** (existing
rows may carry the old value; ADR-0003 + ADR-0016). The validator surfaces
these as `SchemaError` before any DDL runs and points at the
reserve-and-readd workflow.
`@[Flags]` enums are implicitly int-backed (any combination via `|` must
round-trip through the integer wire form). Passing `as: :string` for a flags
enum is a compile error.
#### Wire format — `naming:` (ADR-0017)
By default, `enum_string` columns store the source-level member name
(`"BounceHard"`). Pass `naming:` to translate to a different convention —
useful when the column is exposed verbatim to external surfaces (JSON
APIs, Prometheus labels, HTML `` markup) that already use
snake_case or lower_case:
```crystal
enum Reason
Active
BounceHard
ComplaintAbuse
end
class Suppression < Prostore::Model
field 1, :id, Int64, primary: true, auto_increment: true
field 2, :reason, Reason, naming: :snake_case
# → stored as "active" / "bounce_hard" / "complaint_abuse"
# → CHECK (reason IN ('active', 'bounce_hard', 'complaint_abuse'))
end
```
Supported algorithms:
| Symbol | `BounceHard` → |
|---|---|
| `:as_declared` (default) | `BounceHard` |
| `:snake_case` | `bounce_hard` |
| `:kebab_case` | `bounce-hard` |
| `:lower_case` | `bouncehard` |
The Crystal source-level name remains the canonical identifier in your
code (`Reason::BounceHard`). The `naming:` option only changes how the
value is *stored* and how it appears in the CHECK constraint. Reads and
writes via the ORM are transparent — you continue to assign and read
Crystal enum values directly.
**`naming:` only applies to `enum_string` columns** — int-backed and
`@[Flags]` enums store integers, so there's no name to translate. The
macro rejects `naming:` on those at compile time.
**Changing `naming:` on an existing column is a data migration.** The
stored bytes encode the old wire form; switching the algorithm without
rewriting existing rows would either fail the CHECK constraint or
silently corrupt reads. The validator detects this and raises a
`SchemaError` with the recommended workflow (UPDATE the column to the
new wire values via raw SQL, *then* change the declaration). See
ADR-0017 for the full discipline.
---
## CRUD
All CRUD methods use `Prostore.default_connection`. Set it at boot via
`Prostore.connect(url)` or `Prostore.setup(url)`.
### Creating records
```crystal
u = User.allocate
u.email = "bob@example.com"
u.save # INSERT; lambda defaults are evaluated here
u.id # assigned by the DB (auto_increment) or set before save
u.persisted? # true after a successful save
```
`save` decides INSERT vs UPDATE based on `persisted?`, not the PK value. A record
is not persisted until `save` succeeds. This means user-assigned (non-auto-increment)
primary keys work correctly:
```crystal
class Tag < Prostore::Model
field 1, :id, String, primary: true
field 2, :label, String
end
t = Tag.allocate
t.id = "tag:featured"
t.label = "Featured"
t.save # INSERT — persisted? was false
t.save # UPDATE — persisted? is now true
```
### Updating records
```crystal
user = User.find(1_i64)
user.email = "new@example.com"
user.save # UPDATE
```
### Deleting records
```crystal
user.destroy # DELETE WHERE id =
```
### Finding records
```crystal
User.find(42_i64) # returns User, raises Prostore::Error if not found
User.find?(42_i64) # returns User?, nil if not found
```
### All records
```crystal
User.all # Builder(User) — not yet executed
User.all.to_a # executes SELECT *
```
---
## Querying
### Query builder
All query methods return a new `Builder(T)` — each call is immutable and chainable.
Execution happens when a terminal method is called.
#### Filtering
```crystal
# Named-arg equality (shorthand for common cases)
User.where(status: "active")
User.where(tenant_id: 1_i64, status: "active") # implicit AND
# Range → BETWEEN / < / <=
User.where(score: 10..100) # 10 <= score <= 100
User.where(score: 10...100) # 10 <= score < 100
# Array → IN (...)
User.where(status: ["active", "pending"])
# Nil → IS NULL
User.where(deleted_at: nil)
# Arbitrary predicate
User.where(Prostore::Q.gt(:score, 50))
# Multiple where calls are ANDed together
User.where(tenant_id: 1_i64).where(Prostore::Q.gt(:score, 10))
```
Pure-equality predicates collapse into a single `where` — kwargs are themselves
ANDed, so prefer the one-call form:
```crystal
# Verbose
User.where(id: id).where(token: token).where(status: "active")
# Equivalent, preferred
User.where(id: id, token: token, status: "active")
```
Chain only when one of the predicates can't live in kwargs (e.g., `Q.gt`, `Q.or`,
`Q.not`).
#### Ordering
```crystal
User.order_by(:email) # ASC
User.order_by(:score, desc: true) # DESC
User.order_by(:tenant_id).order_by(:score, desc: true) # multiple
```
#### Paging
```crystal
User.limit(10)
User.offset(20).limit(10)
```
#### Column projection
```crystal
users = User.all.select(:id, :email).to_a
# Other ivars are nil; accessing a non-projected non-nullable field raises
```
#### Joins
FK-resolved join (automatically determines join columns from declared FKs):
```crystal
# User has posts via Post.foreign_key 1, [:user_id], references: User
User.all.joins(Post).where(Prostore::Q.gt(:total, 100)).to_a
```
If multiple FKs exist between two models, pass `fk_tag:` to disambiguate:
```crystal
User.all.joins(Post, fk_tag: 2)
```
Explicit join with raw column lists:
```crystal
User.all.joins("orders", ["id"], ["user_id"])
```
#### Terminal methods
| Method | Return type | Description |
|--------|-------------|-------------|
| `to_a` | `Array(T)` | Execute and return all rows. |
| `each { \|row\| }` | `Nil` | Stream rows without building an array. |
| `first` | `T?` | First row or nil. |
| `first!` | `T` | First row, raises if none. |
| `count` | `Int64` | `SELECT COUNT(*)`. |
| `empty?` | `Bool` | `count.zero?`. |
| `exists?` | `Bool` | `!empty?`. |
### Predicates (Q module)
`Prostore::Q` (aliased as `Q` at top level) provides predicate constructors for
use with `where(predicate)` or inside named query blocks.
```crystal
# Equality / comparison
Q.eq(:status, "active") # status = 'active'
Q.ne(:status, "deleted") # status != 'deleted'
Q.lt(:score, 10) # score < 10
Q.lte(:score, 10) # score <= 10
Q.gt(:score, 100) # score > 100
Q.gte(:score, 100) # score >= 100
# Membership
Q.in(:status, ["active", "pending"])
# Null checks
Q.null?(:deleted_at) # deleted_at IS NULL
Q.not_null?(:deleted_at) # deleted_at IS NOT NULL
# Pattern match
Q.like(:email, "%@example.com")
# Boolean combinators
Q.all(Q.gt(:score, 10), Q.lt(:score, 100)) # AND
Q.any(Q.eq(:status, "active"), Q.eq(:status, "pending")) # OR
Q.not(Q.eq(:status, "deleted")) # NOT
```
Predicates compose arbitrarily:
```crystal
User.where(
Q.all(
Q.eq(:tenant_id, 1_i64),
Q.any(Q.eq(:status, "active"), Q.eq(:status, "trial")),
Q.not(Q.is_null(:email))
)
)
```
### Named queries
```crystal
class User < Prostore::Model
index 1, [:email], unique: true
index 2, [:tenant_id]
index 3, [:status]
index 4, [:created_at]
query :by_email, ->(e : String) { where(email: e) }
query :in_tenant, ->(t : Int64) { where(tenant_id: t) }
query :active, -> { where(status: "active") }
query :created_before, ->(t : Time) { where(Q.lt(:created_at, t)).order_by(:created_at, desc: true) }
end
User.by_email("alice@example.com").first
User.in_tenant(7_i64).order_by(:email).to_a
User.active.count
User.created_before(1.week.ago).to_a
```
Named queries are validated against index coverage at migrate time. Every
`where` field (including comparison predicate fields like `Q.lt(:col, val)`)
and `order_by` field must be covered by a declared index under the left-prefix
rule.
---
## Default values, backfills, and lazy fields
`default:` accepts four forms. They differ in *when* the value is computed
and *who* computes it — pick the form that matches your need:
| Form | DDL `DEFAULT` clause emitted? | Applied on `.new + .save`? | Applied on raw SQL `INSERT` (column omitted)? |
|---|---|---|---|
| Scalar literal (`true`, `42`, `"active"`) | yes | **yes** — ivar seeded at save | yes |
| Symbol (`:CURRENT_TIMESTAMP`, `:gen_random_uuid`) | yes (verbatim) | no — DB-side only [†] | yes |
| `SQL.expr("…")` | yes (verbatim) | no — DB-side only [†] | yes |
| Crystal lambda `->(_m) { … }` | no | **yes** — lambda runs before INSERT | no (lambda is ORM-only) |
[†] The macro always lists every non-PK column in the ORM's INSERT, so the
DDL `DEFAULT` is shadowed by the explicit NULL bound from the unset ivar.
If you need a DB-computed default to also apply on `.new + .save`, wrap it
in a lambda (`default: ->{ Time.utc }`).
### Scalar literal defaults
Bool, Int, and String literals are auto-wrapped to their SQL equivalents *and*
captured as Crystal values so `.new + .save` seeds the ivar before INSERT
(otherwise the DDL DEFAULT would be shadowed by the explicit NULL):
```crystal
field 3, :active, Bool, default: false, backfill: false
field 4, :count, Int32, default: 0, backfill: 0
field 5, :role, String, default: "member", backfill: "member"
```
```crystal
u = User.allocate
u.email = "a@b.com"
u.save # → INSERT (..., role) VALUES (..., 'member')
User.find(u.id).role # => "member"
```
### Symbol and SQL.expr defaults (DB-side only)
Symbol literals are emitted verbatim as SQL keywords or 0-argument functions
— no quotes added:
```crystal
field 6, :created_at, Time, default: :CURRENT_TIMESTAMP
field 7, :uuid_col, String, default: :gen_random_uuid # PostgreSQL
```
Use `SQL.expr("...")` when the default must be a full SQL expression with
operators, arguments, or column references:
```crystal
field 8, :created_at, Time, default: SQL.expr("CURRENT_TIMESTAMP")
field 9, :status, String, default: SQL.expr("'active'")
```
The expression is emitted verbatim as a column-level `DEFAULT (...)` clause.
Existing rows are unaffected when you add such a column — the default only applies
to new inserts. **These forms do not seed the ivar on `.new + .save`** —
the macro emits every column in the INSERT, so the DB sees an explicit NULL
and never falls back to the DEFAULT clause. Wrap in a lambda if you need
the value applied through the ORM:
```crystal
field 6, :created_at, Time, default: ->{ Time.utc }
```
### SQL expression backfills
When adding a non-null column to a table that already has rows, declare both
`default:` (for new rows) and `backfill:` (for existing rows):
```crystal
field 8, :role, String,
default: "member",
backfill: "member"
```
If `default:` and `backfill:` are identical SQL expressions, the migration is a
single `ADD COLUMN … NOT NULL DEFAULT (…)` step (SQLite and PostgreSQL both apply
the default to existing rows in this case).
If they differ, the migration uses three steps: add nullable → server-side UPDATE
→ apply NOT NULL:
```crystal
field 6, :verified, String,
default: SQL.expr("'pending'"),
backfill: SQL.expr("CASE WHEN email LIKE '%@verified.example' THEN 'verified' ELSE 'pending' END")
```
### Crystal-lambda defaults
A lambda runs in Crystal before the INSERT, not at the DB level:
```crystal
field 7, :slug, String,
default: ->(_u : User) { _u.email.split("@").first }
```
0-arg lambdas are also supported:
```crystal
field 8, :token, String,
default: ->{ Random::Secure.hex(32) }
```
### Crystal-lambda backfills
The lambda receives each existing row as a model instance:
```crystal
field 9, :score, Int32,
default: SQL.expr("0"),
backfill: ->(row : User) { compute_score(row) }
```
The runner materializes rows in chunks, calls the lambda for each, and writes
the result back. The `WHERE col IS NULL` filter makes the loop idempotent.
### Lazy fields
A lazy field is stored as NULL in the database. The lambda runs on the first
read of the field and persists the value:
```crystal
field 10, :badge, String?,
lazy: ->(_u : User) { derive_badge(_u) }
```
Lazy fields:
- Must be nullable (`T?`).
- Cannot be used in `where` or `order_by` (compile-time error via ADR-0006).
- If a named query references a lazy field non-projectionally, the analyzer
overrides it to eager and emits a diagnostic at migrate time.
- Are mutually exclusive with `default:` and `backfill:`.
---
## Schema evolution
All schema changes are expressed by editing the model. prostore computes the
diff at boot and plans the necessary DDL steps.
### Add a nullable column
```crystal
field 5, :nickname, String?
```
One step: `ALTER TABLE users ADD COLUMN nickname TEXT`.
### Add a non-null column (same default and backfill)
```crystal
field 6, :role, String,
default: "member",
backfill: "member"
```
One step: `ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT ('member')`.
Existing rows get the default.
### Add a non-null column (different backfill)
```crystal
field 7, :verified, String,
default: "pending",
backfill: SQL.expr("CASE WHEN email LIKE '%@verified.example' THEN 'verified' ELSE 'pending' END")
```
Three steps: add nullable → server-side `UPDATE` with backfill expression →
rebuild to apply `NOT NULL` with default.
> **SQLite + foreign keys:** if the target table is referenced by a FK from
> another table, the three-step path fails on SQLite because `foreign_keys=ON`
> prevents the table rebuild. prostore raises a clear `MigrationError` at plan
> time listing the referencing tables and both fix strategies (declare the field
> nullable, or use identical `default:`/`backfill:` to take the single-step path).
### Add a non-null column with Crystal-lambda backfill
```crystal
field 8, :score, Int32,
default: SQL.expr("0"),
backfill: ->(row : User) { compute_score(row) }
```
Three steps: add nullable → chunked Crystal-side backfill loop → apply NOT NULL.
### Rename a field
Change the symbol in the `field` declaration; the tag stays the same:
```crystal
field 2, :handle, String # was :email; tag 2 unchanged
```
One step: `ALTER TABLE users RENAME COLUMN email TO handle`. Data is preserved.
### Remove a field
Reserve the tag. The column is dropped:
```crystal
reserved 3 # column tenant_id (tag 3) will be dropped
```
One step: `ALTER TABLE users DROP COLUMN tenant_id`. Tag 3 may never be reused.
### Replace a field (old → new pattern)
When you need a new type or default for an existing column, add a new tag and
keep both during a transition window:
```crystal
reserved 9 # :legacy_email retired
field 10, :email_v2, String,
default: ->(u : User) { u.email },
backfill: SQL.expr("legacy_email")
```
### Rename an index
Change the `name:` option (or rename the columns — but columns must be reserved
and re-added to change composition):
```crystal
index 1, [:email], unique: true, name: "users_email_unique" # was "users_email_idx"
```
One step: `ALTER INDEX … RENAME TO …` (PostgreSQL) or rebuild (SQLite).
### Remove an index
```crystal
reserved_index 2 # index tag 2 will be dropped
```
### Remove a foreign key
```crystal
reserved_foreign_key 1 # FK tag 1 will be dropped
```
---
## Migration system
### Running migrations
```crystal
# URL-based: opens a temporary connection, migrates, closes it.
# For file-based databases, use this at startup before Prostore.connect.
Prostore.migrate("postgres://user:pass@host/dbname")
# Connection-based: migrate on a pre-opened connection.
conn = Prostore::Connection.open(url)
Prostore.migrate(conn)
# Combined: open, migrate, set as default connection, return it.
# Use for sqlite3::memory: — all three operations share one connection.
conn = Prostore.setup("sqlite3::memory:?max_pool_size=1")
```
### Boot sequence
When `migrate` or `setup` runs:
1. Ensure bookkeeping tables (`prostore_migration`, `prostore_migration_step`,
`prostore_schema`) exist.
2. Compute the target schema fingerprint over all registered models.
3. Check for an in-progress migration:
- If yes: verify target fingerprint matches; resume from the first incomplete step.
- If no: validate model changes, diff, plan, persist the plan, start.
4. Claim a time-bounded lease (`claimed_until`; 5-minute default). Another process
holding a live lease causes an immediate error.
5. Execute each step in ordinal order: `mark_running → execute → mark_complete → heartbeat`.
6. Mark migration complete and release the lease.
### Resuming crashed migrations
Steps are persisted before any DDL runs (plan-at-start). If the process crashes
mid-migration, the next boot finds the in-progress migration and continues from
the first non-complete step. Each step is independently transactional and
idempotent.
### Concurrent safety
The lease mechanism (a time-bounded row lock in `prostore_migration`) ensures
only one runner executes at a time. A background heartbeat fiber renews the
lease every 30 seconds. If the runner crashes without releasing the lease, the
next runner can steal it after the TTL expires.
A version-skew check prevents a new deployment from attaching to a migration
started by an old schema version: if the target fingerprints differ, the new
runner refuses to start.
### Schema fingerprint
The fingerprint is a SHA-256 hash over field tags, types, nullability, primary
key, auto_increment, default SQL, backfill SQL, and lazy flags — but **not**
over field names. A rename does not change the fingerprint. The fingerprint
identifies the *target state*, not the labeling.
---
## Drift detection
If the database is modified outside of prostore (a DBA renames a column, drops
an index, etc.), the next `migrate` call detects the drift:
| Drift type | Behavior |
|-----------|----------|
| Managed column renamed externally | Auto-fix: rename back. |
| Managed column dropped externally | **Error** — data was lost; restore or reserve the tag. |
| Managed column type/nullability changed externally | **Error** — in-place changes are forbidden (ADR-0003). |
| Managed index dropped | Auto-fix: recreate. |
| Managed index renamed externally | Auto-fix: rename back. |
| Managed index UNIQUE flag changed | **Error** — definition change requires a new tag. |
| Unmanaged column, index, or table | Tolerated. |
---
## Connection
### Opening a connection
```crystal
conn = Prostore::Connection.open("sqlite3://app.db")
conn = Prostore::Connection.open("postgres://user:pass@host/dbname")
```
### Default connection
```crystal
Prostore.connect("sqlite3://app.db") # set default connection
Prostore.default_connection # retrieve it
Prostore.default_connection = conn # set directly (useful in tests)
```
CRUD and query methods use the default connection automatically. The migration
runner also accepts an explicit `Connection`.
### Connection methods
`Connection` exposes the underlying pool via `.db` and also delegates the most
common methods directly:
```crystal
conn.exec("INSERT INTO …", arg1, arg2)
conn.scalar("SELECT COUNT(*) FROM users")
conn.query_one("SELECT * FROM users WHERE id = ?", 1_i64) { |rs| … }
conn.query_one?("SELECT …", id, as: String) # nil-safe
conn.query_each("SELECT * FROM …") { |rs| … }
conn.transaction { |tx| … }
conn.with_connection { |db_conn| … } # pins all DDL to one connection
conn.db # DB::Database (full crystal-db API)
conn.adapter # Prostore::Adapter::Base
conn.close
```
### SQLite URL parameters
Several SQLite pragmas can be passed as URL query parameters and are applied
to every connection via `PRAGMA`:
| Parameter | SQLite PRAGMA |
|-----------|--------------|
| `journal_mode` | `PRAGMA journal_mode = ` |
| `synchronous` | `PRAGMA synchronous = ` |
| `cache_size` | `PRAGMA cache_size = ` |
| `temp_store` | `PRAGMA temp_store = ` |
```crystal
# Enable WAL mode
Prostore.setup("sqlite3://app.db?journal_mode=wal&synchronous=normal")
```
Note: `sqlite3::memory:` does not support WAL mode; the pragma is silently
ignored by SQLite in that case.
### SQLite in production (concurrent access)
SQLite's default journal mode (`delete`) serializes all access — readers and
the writer block each other, and connections fail immediately with
`SQLITE_BUSY` when another connection holds a lock (`busy_timeout` defaults
to 0). For any production workload with concurrent readers alongside a writer,
enable **WAL mode** and set a non-zero **busy_timeout**:
```crystal
Prostore.setup("sqlite3://app.db?journal_mode=wal&synchronous=normal&busy_timeout=5000")
```
| Setting | Why |
|---------|-----|
| `journal_mode=wal` | Readers never block the writer; the writer never blocks readers |
| `synchronous=normal` | Safe with WAL (checkpoints are still fsync'd); faster than `full` |
| `busy_timeout=5000` | Writer retries for up to 5 s on lock contention instead of failing immediately |
Increase `max_pool_size` (via URL) to allow Crystal fibers to hold multiple
concurrent reader connections:
```crystal
Prostore.setup("sqlite3://app.db?journal_mode=wal&synchronous=normal&busy_timeout=5000&max_pool_size=10")
```
There is no separate read-replica configuration — all connections share the
same pool and adapter. WAL is the mechanism that makes concurrent reads safe.
### In-memory SQLite
`sqlite3::memory:` creates a fresh database per `DB.open` call. To use an
in-memory database for tests, use `Prostore.setup` so migration and app queries
share the same connection:
```crystal
url = "sqlite3::memory:?max_pool_size=1&initial_pool_size=1&max_idle_pool_size=1"
conn = Prostore.setup(url, [User, Post] of Prostore::Model.class)
# conn is now both migrated and set as default_connection
```
---
## Backup
`Prostore::Backup.run` writes a point-in-time backup of the database to a
destination path. The destination may contain strftime tokens (`%Y %m %d
%H %M %S`), which are expanded to the current UTC time — so a single
`cron` line produces naturally-rotated, timestamped files:
```crystal
path = Prostore::Backup.run(conn, "/var/backups/app_%Y%m%d_%H%M%S.db")
# => "/var/backups/app_20260514_103045.db"
```
Or via the operator CLI:
```sh
prostore backup /var/backups/app_%Y%m%d_%H%M%S.db
# Backup written to /var/backups/app_20260514_103045.db
```
### SQLite
Uses [`VACUUM INTO`](https://www.sqlite.org/lang_vacuum.html) — an online,
non-blocking backup that works under WAL mode and produces a defragmented
copy. Requires SQLite 3.27.0 or later (released 2019).
### PostgreSQL
Shells out to `pg_dump` (plain-text format). `pg_dump` must be available in
`PATH`. Connection parameters (host, port, user, database) are derived from
`DATABASE_URL`; the password is passed via `PGPASSWORD`.
### Cron examples
```sh
# SQLite — hourly backup (one file per hour, 24 kept by filename convention)
0 * * * * DATABASE_URL=sqlite3:///var/app/app.db \
/path/to/prostore backup /var/backups/app_%Y%m%d_%H.db
# SQLite — every 6 hours
0 */6 * * * DATABASE_URL=sqlite3:///var/app/app.db \
/path/to/prostore backup /var/backups/app_%Y%m%d_%H.db
# PostgreSQL — daily dump at 03:00
0 3 * * * DATABASE_URL=postgres://user:pass@localhost/mydb \
/path/to/prostore backup /var/backups/mydb_%Y%m%d.sql
```
Use OS-level tools (`find -mtime`, `logrotate`) to prune old backups.
---
## Operator CLI
The `prostore` binary exposes operator commands. Set `DATABASE_URL` in the
environment:
```sh
export DATABASE_URL=sqlite3://app.db
# Show migration history and drift summary
prostore migrate status
# Dry-run drift check (no DDL applied)
prostore drift check
# Abort a stuck in-progress migration (does NOT revert completed steps)
prostore migrate abort 42
```
The same operations are available programmatically:
```crystal
conn = Prostore::Connection.open(ENV["DATABASE_URL"])
Prostore::Migration::CLI.status(conn)
Prostore::Migration::CLI.drift_check(conn)
Prostore::Migration::CLI.abort(conn, migration_id: 42_i64)
```
---
## Testing
### SQLite in tests
Use a temp file or in-memory database with `Prostore.setup`:
```crystal
# Temp file (supports WAL, isolated per process)
TEST_DB = "/tmp/myapp_test_#{Process.pid}.db"
conn = Prostore.setup("sqlite3://#{TEST_DB}", [User, Post] of Prostore::Model.class)
# In-memory (fastest, but requires setup to share the connection)
conn = Prostore.setup("sqlite3::memory:?max_pool_size=1", [User] of Prostore::Model.class)
```
### Resetting state between tests
`Prostore.delete_all` (aliased as `Prostore.test_reset`) deletes all rows from
the given model set in reverse FK dependency order so child rows are always
removed before their parent rows, even with `foreign_keys=ON`:
```crystal
ALL_MODELS = [User, Post, Comment] of Prostore::Model.class
after_each do
Prostore.test_reset(ALL_MODELS, conn)
end
```
Both methods accept an explicit `Connection`. If omitted, the process-wide
default connection is used.
### Setting the default connection in specs
```crystal
before_each do
conn = Prostore.setup(TEST_URL, MODELS)
end
after_each do
Prostore.default_connection = nil
conn.close
end
```
### Testing against PostgreSQL
Set `POSTGRES_URL` in the environment:
```sh
POSTGRES_URL=postgres://user:pass@localhost/test_db crystal spec
```
---
## Architecture
```
DSL (macros + Schema types)
↓ compile time
Schema::Definition (Class.prostore_schema)
↓ runtime planning
Diff::Engine + Diff::Validator + Drift::Detector + Query::Analyzer
↓ produces
Operation list → Steps::Planner → Step list
↓ persisted to prostore_migration_step
Steps::Executor (per backend) — each step in its own transaction
↓
Live database; prostore_schema bookkeeping updated atomically
```
**Key invariants:**
- Tags are the stable identity of fields, indexes, and foreign keys — not names.
- In-place type changes and nullability changes are forbidden (ADR-0003).
- The plan is persisted before any DDL runs; crashes resume from the last completed step (ADR-0009).
- Every named query must have index coverage; the runner enforces this at boot (ADR-0006).
- `prostore_` is a reserved prefix for table names and field names.
See [`doc/adr/`](doc/adr/) for the full design rationale.
---
## Development
```sh
shards install
crystal spec spec/unit/ # unit tests
crystal spec spec/integration/sqlite/ # SQLite integration tests
POSTGRES_URL=postgres://... crystal spec # full matrix
```
---
## License
MIT