{"id":50329084,"url":"https://github.com/threez/prostore.cr","last_synced_at":"2026-05-29T08:30:50.335Z","repository":{"id":356684461,"uuid":"1233624329","full_name":"threez/prostore.cr","owner":"threez","description":"Declarative ORM for Crystal with automatic migration ","archived":false,"fork":false,"pushed_at":"2026-05-09T09:21:52.000Z","size":116,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-09T09:28:53.458Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Crystal","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/threez.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-09T06:52:02.000Z","updated_at":"2026-05-09T09:21:56.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/threez/prostore.cr","commit_stats":null,"previous_names":["threez/prostore.cr"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/threez/prostore.cr","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/threez%2Fprostore.cr","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/threez%2Fprostore.cr/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/threez%2Fprostore.cr/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/threez%2Fprostore.cr/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/threez","download_url":"https://codeload.github.com/threez/prostore.cr/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/threez%2Fprostore.cr/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33644104,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-05-29T02:00:06.066Z","response_time":107,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-05-29T08:30:49.404Z","updated_at":"2026-05-29T08:30:50.324Z","avatar_url":"https://github.com/threez.png","language":"Crystal","funding_links":[],"categories":[],"sub_categories":[],"readme":"# prostore\n\nA declarative ORM for Crystal targeting SQLite and PostgreSQL. The model is the\nsingle source of truth for schema state — you do not write migration files.\nSchema evolution is computed by diffing the desired state against the live\ndatabase, with a protobuf-inspired discipline that makes renames safe and forbids\nin-place type changes.\n\nThe architectural decisions are recorded in [`doc/adr/`](doc/adr/). Read those\nfor *why*; this README covers *how*.\n\n---\n\n## Contents\n\n1. [Installation](#installation)\n2. [Quick start](#quick-start)\n3. [Defining a model](#defining-a-model)\n   - [field](#field)\n   - [table_name](#table_name)\n   - [index](#index)\n   - [foreign_key](#foreign_key)\n   - [query](#query)\n   - [reserved tags](#reserved-tags)\n4. [Type system](#type-system)\n5. [CRUD](#crud)\n6. [Querying](#querying)\n   - [Query builder](#query-builder)\n   - [Predicates (Q module)](#predicates-q-module)\n   - [Named queries](#named-queries)\n7. [Default values, backfills, and lazy fields](#default-values-backfills-and-lazy-fields)\n8. [Schema evolution](#schema-evolution)\n9. [Migration system](#migration-system)\n10. [Drift detection](#drift-detection)\n11. [Connection](#connection)\n12. [Operator CLI](#operator-cli)\n13. [Testing](#testing)\n14. [Architecture](#architecture)\n\n---\n\n## Installation\n\n```yaml\n# shard.yml\ndependencies:\n  prostore:\n    github: threez/prostore\n```\n\n```sh\nshards install\n```\n\n```crystal\nrequire \"prostore\"\n```\n\n---\n\n## Quick start\n\n```crystal\nrequire \"prostore\"\n\nclass User \u003c Prostore::Model\n  field 1, :id,    Int64,  primary: true, auto_increment: true\n  field 2, :email, String\n  field 3, :name,  String?\n\n  index 1, [:email], unique: true\nend\n\n# Boot: migrate then connect (shares the same connection for in-memory SQLite)\nProstore.setup(\"sqlite3://app.db\")\n\n# Insert\nu = User.allocate\nu.email = \"alice@example.com\"\nu.name  = \"Alice\"\nu.save           # → INSERT; u.id is now set\nu.persisted?     # → true\n\n# Find\nalice = User.find(u.id)        # raises if missing\nalice = User.find?(u.id)       # returns nil if missing\n\n# Update\nalice.name = \"Alice Smith\"\nalice.save       # → UPDATE\n\n# Delete\nalice.destroy\n\n# Query\nUser.all.order_by(:email).to_a\nUser.where(email: \"alice@example.com\").first\nUser.where(Prostore::Q.gt(:id, 10)).count\n```\n\n---\n\n## Defining a model\n\n```crystal\nclass Post \u003c Prostore::Model\n  # ... fields, indexes, etc.\nend\n```\n\nEvery subclass is automatically registered in `Prostore.models` at compile time.\n\n### field\n\n```crystal\nfield \u003ctag\u003e, :\u003cname\u003e, \u003cType\u003e, **opts\n```\n\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `tag` | `Int` literal | yes | Stable numeric identity. Survives renames. Never reuse a retired tag. |\n| `name` | `Symbol` literal | yes | Column name. |\n| `Type` | Crystal type | yes | See [Type system](#type-system). `T?` declares nullable. |\n| `primary:` | `Bool` | no | Marks the primary key column. At most one per model. |\n| `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. |\n| `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 `-\u003e(_m : MyModel) { ... }` runs in Crystal before INSERT. See [Default values](#default-values-backfills-and-lazy-fields). |\n| `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(...)`. |\n| `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?`. |\n\n```crystal\nclass User \u003c Prostore::Model\n  field 1, :id,         Int64,      primary: true, auto_increment: true\n  field 2, :email,      String\n  field 3, :created_at, Time,       default: :CURRENT_TIMESTAMP\n  field 4, :status,     String,     default: \"active\", backfill: \"active\"\n  field 5, :active,     Bool,       default: true,     backfill: true\n  field 6, :score,      Int32?,     lazy: -\u003e(_u : User) { compute_score(_u) }\n  field 7, :nickname,   String?\n  field 8, :slug,       String,     default: -\u003e(_u : User) { _u.email.split(\"@\").first }\nend\n```\n\n**Scalar literal auto-wrap:** `default: \"active\"` becomes SQL `'active'`;\n`default: false` becomes SQL `false`; `default: 0` becomes SQL `0`.\n\n**Symbol shorthand for SQL keywords/functions:** `:CURRENT_TIMESTAMP` emits\n`CURRENT_TIMESTAMP` verbatim — no quotes. Use this for any SQL keyword or\n0-argument function. Use `SQL.expr(\"...\")` when you need a full expression with\noperators or arguments (`gen_random_uuid()`, column references, etc.).\n\n**Lambda shape:** Lambda defaults and backfills receive the model instance as a\nsingle argument: `-\u003e(_m : MyModel) { ... }`. 0-arg lambdas `-\u003e{ ... }` are also\naccepted — prostore detects the arity at compile time.\n\n### table_name\n\nOverride the auto-derived table name (snake_case of the class name):\n\n```crystal\nclass EmailMessage \u003c Prostore::Model\n  table_name \"messages\"\n  # ...\nend\n```\n\nWithout an override, `Txmail::Account` → `txmail_account`. Module separators\n`::` become `_`.  The `prostore_` prefix is reserved.\n\n### index\n\n```crystal\nindex \u003ctag\u003e, [:\u003ccol\u003e, ...], **opts\n```\n\n| Parameter | Required | Description |\n|-----------|----------|-------------|\n| `tag` | yes | Stable numeric identity. |\n| `columns` | yes | Array of symbol column names. Order matters for composite index left-prefix rules. |\n| `unique:` | no | `true` to add `UNIQUE`. Default `false`. |\n| `where:` | no | `SQL.expr(\"...\")` partial-index predicate (PostgreSQL / SQLite). |\n| `name:` | no | Override the auto-generated name (`\u003ctable\u003e_\u003ccol1\u003e_..._idx`). |\n\n```crystal\nindex 1, [:email],              unique: true\nindex 2, [:tenant_id, :status]\nindex 3, [:deleted_at],         where: SQL.expr(\"deleted_at IS NOT NULL\")\nindex 4, [:slug],               name: \"posts_slug_unique_idx\", unique: true\n```\n\nOn PostgreSQL, indexes are created with `CREATE INDEX CONCURRENTLY IF NOT EXISTS`\nto avoid locking the table.\n\n### foreign_key\n\n```crystal\nforeign_key \u003ctag\u003e, [:\u003ccol\u003e, ...], references: TargetModel, **opts\n```\n\n| Parameter | Required | Description |\n|-----------|----------|-------------|\n| `tag` | yes | Stable numeric identity. |\n| `columns` | yes | Local columns participating in the constraint. |\n| `references:` | yes | Target `Prostore::Model` subclass. |\n| `references_fields:` | no | Target columns (defaults to the target's primary key). |\n| `on_delete:` | no | `:no_action` (default), `:restrict`, `:cascade`, `:set_null`, `:set_default`. |\n| `on_update:` | no | Same options as `on_delete:`. |\n| `name:` | no | Override the auto-generated constraint name (`\u003ctable\u003e_\u003ccol\u003e_fkey`). |\n\n```crystal\nclass Comment \u003c Prostore::Model\n  field 1, :id,      Int64, primary: true, auto_increment: true\n  field 2, :post_id, Int64\n  field 3, :body,    String\n\n  index 1, [:post_id]\n  foreign_key 1, [:post_id], references: Post, on_delete: :cascade\nend\n```\n\nSQLite enforces `PRAGMA foreign_keys = ON` on every connection automatically.\n\n### query\n\nDeclare a named, statically-analysed query. The block receives a query builder\nvia `with builder yield`, so `where`, `order_by`, `limit`, etc. dispatch\ndirectly without a receiver.\n\n```crystal\nquery :\u003cname\u003e, -\u003e(\u003cargs\u003e) { \u003cbuilder chain\u003e }\n```\n\n```crystal\nclass User \u003c Prostore::Model\n  query :by_email,      -\u003e(e : String)  { where(email: e) }\n  query :active,        -\u003e              { where(status: \"active\") }\n  query :top_in_tenant, -\u003e(t : Int64)   { where(tenant_id: t).order_by(:score, desc: true).limit(10) }\n  query :recent,        -\u003e(t : Time)    { where(Q.lt(:created_at, t)).order_by(:created_at, desc: true) }\nend\n\nUser.by_email(\"alice@example.com\").first\nUser.active.count\nUser.top_in_tenant(42_i64).to_a\nUser.recent(1.hour.ago).to_a\n```\n\nComparison predicates (`Q.lt`, `Q.gt`, `Q.lte`, `Q.gte`, `Q.ne`, `Q.in`,\n`Q.like`) are fully supported inside named query bodies. The macro walker\nextracts the field name from the predicate for index-coverage analysis.\n\nAt migrate time, every named query is validated: each filtered or sorted field\nmust be covered by a declared index (ADR-0006 strict mode, left-prefix rule). A\n`SchemaError` is raised if coverage is missing — add an `index` declaration before\nor alongside the query.\n\n### reserved tags\n\nPermanently retire a tag when you remove a field, index, or foreign key. Omitting\na tag without reserving it is a runtime error.\n\n```crystal\nreserved 3              # field tag 3 is permanently retired\nreserved_index 2        # index tag 2 is permanently retired\nreserved_foreign_key 1  # FK tag 1 is permanently retired\n```\n\nRetired tags may never be reused on the same model.\n\n---\n\n## Type system\n\nProstore maps Crystal types to a portable type tag, which is then translated to\nbackend-specific DDL. Nullable types are declared by appending `?`.\n\n| Crystal type | Portable tag | SQLite affinity | PostgreSQL type |\n|---|---|---|---|\n| `Int32` | `int32` | `INTEGER` | `INTEGER` |\n| `Int64` | `int64` | `INTEGER` | `BIGINT` |\n| `Float32` | `float32` | `REAL` | `REAL` |\n| `Float64` | `float64` | `REAL` | `DOUBLE PRECISION` |\n| `String` | `string` | `TEXT` | `TEXT` |\n| `Bool` | `bool` | `INTEGER` | `BOOLEAN` |\n| `Time` | `time` | `TEXT` | `TIMESTAMP WITH TIME ZONE` |\n| `Bytes` / `Slice(UInt8)` | `bytes` | `BLOB` | `BYTEA` |\n| `UUID` | `uuid` | `TEXT` (36-char) | `UUID` |\n| `BigDecimal` | `decimal` | `TEXT` | `NUMERIC` |\n| `JSON::Any` | `json` | `TEXT` | `JSONB` |\n| `Array(T)` where T is any above | `array_\u003cT\u003e` | `TEXT` (JSON-encoded) | `JSONB` |\n| Any `Enum` subclass (default) | `enum_string` | `TEXT` (member name) | `TEXT` |\n| Any `Enum` subclass, `as: :int` | `enum_int` | `INTEGER` (member value) | `BIGINT` |\n\n`Time` on PostgreSQL is `TIMESTAMP WITH TIME ZONE` (`timestamptz`). Crystal's\n`Time` is always UTC.\n\nArray types use JSONB on PostgreSQL and JSON-encoded TEXT on SQLite. Native\nPostgreSQL arrays (e.g., `INTEGER[]`) are not used — JSONB provides a uniform IO\npath across both backends.\n\nReads return `Array(T)` directly and writes accept `Array(T)` — encoding to JSON\nhappens at the model boundary, so callers never touch `to_json` / `from_json`.\n\nArray literals are **not** accepted as `default:` values (the parser only takes\nscalar literals, symbols, `SQL.expr(...)`, or a lambda). For an \"always an\narray, possibly empty\" field, use a 0-arg lambda:\n\n```crystal\nfield 9, :domain_ids, Array(String), default: -\u003e{ [] of String }\n```\n\nAlternatively, declare the field nullable (`Array(String)?`) — the default is\nthen auto-inferred as `NULL`, at the cost of nil-checking every read site.\n\n### Enums (ADR-0016)\n\nAny Crystal `Enum` subclass — including `@[Flags]` enums — is a valid field\ntype. Storage is name-backed (`TEXT`) by default; `as: :int` selects integer\nstorage. The declared member set becomes part of the schema and is enforced\nby a named `CHECK` constraint on the column.\n\n```crystal\nenum Status\n  Active\n  Pending\n  Archived\nend\n\nenum Tier\n  Bronze\n  Silver = 5\n  Gold   = 10\nend\n\n@[Flags]\nenum Perms\n  Read\n  Write\n  Execute\nend\n\nclass User \u003c Prostore::Model\n  field 1, :id,     Int64,  primary: true, auto_increment: true\n  field 2, :status, Status                 # TEXT, CHECK IN ('Active', 'Pending', 'Archived')\n  field 3, :tier,   Tier,   as: :int       # INTEGER, CHECK IN (0, 5, 10)\n  field 4, :perms,  Perms                  # @[Flags] → INTEGER, CHECK 0..7 (1|2|4)\nend\n```\n\nReads return the Crystal `Enum` value directly; writes accept it directly. No\nmanual `to_s` / `from_value` at call sites.\n\n**Evolution:** members can be *added* freely — migration emits a CHECK-swap\nstep that widens the accepted set, existing rows untouched. Members **cannot\nbe removed, renamed, or have their integer value changed in place** (existing\nrows may carry the old value; ADR-0003 + ADR-0016). The validator surfaces\nthese as `SchemaError` before any DDL runs and points at the\nreserve-and-readd workflow.\n\n`@[Flags]` enums are implicitly int-backed (any combination via `|` must\nround-trip through the integer wire form). Passing `as: :string` for a flags\nenum is a compile error.\n\n#### Wire format — `naming:` (ADR-0017)\n\nBy default, `enum_string` columns store the source-level member name\n(`\"BounceHard\"`). Pass `naming:` to translate to a different convention —\nuseful when the column is exposed verbatim to external surfaces (JSON\nAPIs, Prometheus labels, HTML `\u003coption value\u003e` markup) that already use\nsnake_case or lower_case:\n\n```crystal\nenum Reason\n  Active\n  BounceHard\n  ComplaintAbuse\nend\n\nclass Suppression \u003c Prostore::Model\n  field 1, :id,     Int64,  primary: true, auto_increment: true\n  field 2, :reason, Reason, naming: :snake_case\n  # → stored as \"active\" / \"bounce_hard\" / \"complaint_abuse\"\n  # → CHECK (reason IN ('active', 'bounce_hard', 'complaint_abuse'))\nend\n```\n\nSupported algorithms:\n\n| Symbol | `BounceHard` → |\n|---|---|\n| `:as_declared` (default) | `BounceHard` |\n| `:snake_case` | `bounce_hard` |\n| `:kebab_case` | `bounce-hard` |\n| `:lower_case` | `bouncehard` |\n\nThe Crystal source-level name remains the canonical identifier in your\ncode (`Reason::BounceHard`). The `naming:` option only changes how the\nvalue is *stored* and how it appears in the CHECK constraint. Reads and\nwrites via the ORM are transparent — you continue to assign and read\nCrystal enum values directly.\n\n**`naming:` only applies to `enum_string` columns** — int-backed and\n`@[Flags]` enums store integers, so there's no name to translate. The\nmacro rejects `naming:` on those at compile time.\n\n**Changing `naming:` on an existing column is a data migration.** The\nstored bytes encode the old wire form; switching the algorithm without\nrewriting existing rows would either fail the CHECK constraint or\nsilently corrupt reads. The validator detects this and raises a\n`SchemaError` with the recommended workflow (UPDATE the column to the\nnew wire values via raw SQL, *then* change the declaration). See\nADR-0017 for the full discipline.\n\n---\n\n## CRUD\n\nAll CRUD methods use `Prostore.default_connection`. Set it at boot via\n`Prostore.connect(url)` or `Prostore.setup(url)`.\n\n### Creating records\n\n```crystal\nu = User.allocate\nu.email = \"bob@example.com\"\nu.save        # INSERT; lambda defaults are evaluated here\nu.id          # assigned by the DB (auto_increment) or set before save\nu.persisted?  # true after a successful save\n```\n\n`save` decides INSERT vs UPDATE based on `persisted?`, not the PK value. A record\nis not persisted until `save` succeeds. This means user-assigned (non-auto-increment)\nprimary keys work correctly:\n\n```crystal\nclass Tag \u003c Prostore::Model\n  field 1, :id,    String, primary: true\n  field 2, :label, String\nend\n\nt = Tag.allocate\nt.id = \"tag:featured\"\nt.label = \"Featured\"\nt.save           # INSERT — persisted? was false\nt.save           # UPDATE — persisted? is now true\n```\n\n### Updating records\n\n```crystal\nuser = User.find(1_i64)\nuser.email = \"new@example.com\"\nuser.save        # UPDATE\n```\n\n### Deleting records\n\n```crystal\nuser.destroy     # DELETE WHERE id = \u003cpk\u003e\n```\n\n### Finding records\n\n```crystal\nUser.find(42_i64)     # returns User, raises Prostore::Error if not found\nUser.find?(42_i64)    # returns User?, nil if not found\n```\n\n### All records\n\n```crystal\nUser.all            # Builder(User) — not yet executed\nUser.all.to_a       # executes SELECT *\n```\n\n---\n\n## Querying\n\n### Query builder\n\nAll query methods return a new `Builder(T)` — each call is immutable and chainable.\nExecution happens when a terminal method is called.\n\n#### Filtering\n\n```crystal\n# Named-arg equality (shorthand for common cases)\nUser.where(status: \"active\")\nUser.where(tenant_id: 1_i64, status: \"active\")  # implicit AND\n\n# Range → BETWEEN / \u003c / \u003c=\nUser.where(score: 10..100)    # 10 \u003c= score \u003c= 100\nUser.where(score: 10...100)   # 10 \u003c= score \u003c 100\n\n# Array → IN (...)\nUser.where(status: [\"active\", \"pending\"])\n\n# Nil → IS NULL\nUser.where(deleted_at: nil)\n\n# Arbitrary predicate\nUser.where(Prostore::Q.gt(:score, 50))\n\n# Multiple where calls are ANDed together\nUser.where(tenant_id: 1_i64).where(Prostore::Q.gt(:score, 10))\n```\n\nPure-equality predicates collapse into a single `where` — kwargs are themselves\nANDed, so prefer the one-call form:\n\n```crystal\n# Verbose\nUser.where(id: id).where(token: token).where(status: \"active\")\n\n# Equivalent, preferred\nUser.where(id: id, token: token, status: \"active\")\n```\n\nChain only when one of the predicates can't live in kwargs (e.g., `Q.gt`, `Q.or`,\n`Q.not`).\n\n#### Ordering\n\n```crystal\nUser.order_by(:email)               # ASC\nUser.order_by(:score, desc: true)   # DESC\nUser.order_by(:tenant_id).order_by(:score, desc: true)  # multiple\n```\n\n#### Paging\n\n```crystal\nUser.limit(10)\nUser.offset(20).limit(10)\n```\n\n#### Column projection\n\n```crystal\nusers = User.all.select(:id, :email).to_a\n# Other ivars are nil; accessing a non-projected non-nullable field raises\n```\n\n#### Joins\n\nFK-resolved join (automatically determines join columns from declared FKs):\n\n```crystal\n# User has posts via Post.foreign_key 1, [:user_id], references: User\nUser.all.joins(Post).where(Prostore::Q.gt(:total, 100)).to_a\n```\n\nIf multiple FKs exist between two models, pass `fk_tag:` to disambiguate:\n\n```crystal\nUser.all.joins(Post, fk_tag: 2)\n```\n\nExplicit join with raw column lists:\n\n```crystal\nUser.all.joins(\"orders\", [\"id\"], [\"user_id\"])\n```\n\n#### Terminal methods\n\n| Method | Return type | Description |\n|--------|-------------|-------------|\n| `to_a` | `Array(T)` | Execute and return all rows. |\n| `each { \\|row\\| }` | `Nil` | Stream rows without building an array. |\n| `first` | `T?` | First row or nil. |\n| `first!` | `T` | First row, raises if none. |\n| `count` | `Int64` | `SELECT COUNT(*)`. |\n| `empty?` | `Bool` | `count.zero?`. |\n| `exists?` | `Bool` | `!empty?`. |\n\n### Predicates (Q module)\n\n`Prostore::Q` (aliased as `Q` at top level) provides predicate constructors for\nuse with `where(predicate)` or inside named query blocks.\n\n```crystal\n# Equality / comparison\nQ.eq(:status, \"active\")        # status = 'active'\nQ.ne(:status, \"deleted\")       # status != 'deleted'\nQ.lt(:score, 10)               # score \u003c 10\nQ.lte(:score, 10)              # score \u003c= 10\nQ.gt(:score, 100)              # score \u003e 100\nQ.gte(:score, 100)             # score \u003e= 100\n\n# Membership\nQ.in(:status, [\"active\", \"pending\"])\n\n# Null checks\nQ.null?(:deleted_at)           # deleted_at IS NULL\nQ.not_null?(:deleted_at)       # deleted_at IS NOT NULL\n\n# Pattern match\nQ.like(:email, \"%@example.com\")\n\n# Boolean combinators\nQ.all(Q.gt(:score, 10), Q.lt(:score, 100))   # AND\nQ.any(Q.eq(:status, \"active\"), Q.eq(:status, \"pending\"))  # OR\nQ.not(Q.eq(:status, \"deleted\"))              # NOT\n```\n\nPredicates compose arbitrarily:\n\n```crystal\nUser.where(\n  Q.all(\n    Q.eq(:tenant_id, 1_i64),\n    Q.any(Q.eq(:status, \"active\"), Q.eq(:status, \"trial\")),\n    Q.not(Q.is_null(:email))\n  )\n)\n```\n\n### Named queries\n\n```crystal\nclass User \u003c Prostore::Model\n  index 1, [:email],      unique: true\n  index 2, [:tenant_id]\n  index 3, [:status]\n  index 4, [:created_at]\n\n  query :by_email,    -\u003e(e : String)  { where(email: e) }\n  query :in_tenant,   -\u003e(t : Int64)   { where(tenant_id: t) }\n  query :active,      -\u003e              { where(status: \"active\") }\n  query :created_before, -\u003e(t : Time) { where(Q.lt(:created_at, t)).order_by(:created_at, desc: true) }\nend\n\nUser.by_email(\"alice@example.com\").first\nUser.in_tenant(7_i64).order_by(:email).to_a\nUser.active.count\nUser.created_before(1.week.ago).to_a\n```\n\nNamed queries are validated against index coverage at migrate time. Every\n`where` field (including comparison predicate fields like `Q.lt(:col, val)`)\nand `order_by` field must be covered by a declared index under the left-prefix\nrule.\n\n---\n\n## Default values, backfills, and lazy fields\n\n`default:` accepts four forms. They differ in *when* the value is computed\nand *who* computes it — pick the form that matches your need:\n\n| Form | DDL `DEFAULT` clause emitted? | Applied on `.new + .save`? | Applied on raw SQL `INSERT` (column omitted)? |\n|---|---|---|---|\n| Scalar literal (`true`, `42`, `\"active\"`) | yes | **yes** — ivar seeded at save | yes |\n| Symbol (`:CURRENT_TIMESTAMP`, `:gen_random_uuid`) | yes (verbatim) | no — DB-side only [†] | yes |\n| `SQL.expr(\"…\")` | yes (verbatim) | no — DB-side only [†] | yes |\n| Crystal lambda `-\u003e(_m) { … }` | no | **yes** — lambda runs before INSERT | no (lambda is ORM-only) |\n\n[†] The macro always lists every non-PK column in the ORM's INSERT, so the\nDDL `DEFAULT` is shadowed by the explicit NULL bound from the unset ivar.\nIf you need a DB-computed default to also apply on `.new + .save`, wrap it\nin a lambda (`default: -\u003e{ Time.utc }`).\n\n### Scalar literal defaults\n\nBool, Int, and String literals are auto-wrapped to their SQL equivalents *and*\ncaptured as Crystal values so `.new + .save` seeds the ivar before INSERT\n(otherwise the DDL DEFAULT would be shadowed by the explicit NULL):\n\n```crystal\nfield 3, :active, Bool,   default: false,    backfill: false\nfield 4, :count,  Int32,  default: 0,        backfill: 0\nfield 5, :role,   String, default: \"member\", backfill: \"member\"\n```\n\n```crystal\nu = User.allocate\nu.email = \"a@b.com\"\nu.save           # → INSERT (..., role) VALUES (..., 'member')\nUser.find(u.id).role   # =\u003e \"member\"\n```\n\n### Symbol and SQL.expr defaults (DB-side only)\n\nSymbol literals are emitted verbatim as SQL keywords or 0-argument functions\n— no quotes added:\n\n```crystal\nfield 6, :created_at, Time,  default: :CURRENT_TIMESTAMP\nfield 7, :uuid_col,   String, default: :gen_random_uuid   # PostgreSQL\n```\n\nUse `SQL.expr(\"...\")` when the default must be a full SQL expression with\noperators, arguments, or column references:\n\n```crystal\nfield 8, :created_at, Time,   default: SQL.expr(\"CURRENT_TIMESTAMP\")\nfield 9, :status,     String, default: SQL.expr(\"'active'\")\n```\n\nThe expression is emitted verbatim as a column-level `DEFAULT (...)` clause.\nExisting rows are unaffected when you add such a column — the default only applies\nto new inserts. **These forms do not seed the ivar on `.new + .save`** —\nthe macro emits every column in the INSERT, so the DB sees an explicit NULL\nand never falls back to the DEFAULT clause. Wrap in a lambda if you need\nthe value applied through the ORM:\n\n```crystal\nfield 6, :created_at, Time, default: -\u003e{ Time.utc }\n```\n\n### SQL expression backfills\n\nWhen adding a non-null column to a table that already has rows, declare both\n`default:` (for new rows) and `backfill:` (for existing rows):\n\n```crystal\nfield 8, :role, String,\n  default:  \"member\",\n  backfill: \"member\"\n```\n\nIf `default:` and `backfill:` are identical SQL expressions, the migration is a\nsingle `ADD COLUMN … NOT NULL DEFAULT (…)` step (SQLite and PostgreSQL both apply\nthe default to existing rows in this case).\n\nIf they differ, the migration uses three steps: add nullable → server-side UPDATE\n→ apply NOT NULL:\n\n```crystal\nfield 6, :verified, String,\n  default:  SQL.expr(\"'pending'\"),\n  backfill: SQL.expr(\"CASE WHEN email LIKE '%@verified.example' THEN 'verified' ELSE 'pending' END\")\n```\n\n### Crystal-lambda defaults\n\nA lambda runs in Crystal before the INSERT, not at the DB level:\n\n```crystal\nfield 7, :slug, String,\n  default: -\u003e(_u : User) { _u.email.split(\"@\").first }\n```\n\n0-arg lambdas are also supported:\n\n```crystal\nfield 8, :token, String,\n  default: -\u003e{ Random::Secure.hex(32) }\n```\n\n### Crystal-lambda backfills\n\nThe lambda receives each existing row as a model instance:\n\n```crystal\nfield 9, :score, Int32,\n  default:  SQL.expr(\"0\"),\n  backfill: -\u003e(row : User) { compute_score(row) }\n```\n\nThe runner materializes rows in chunks, calls the lambda for each, and writes\nthe result back. The `WHERE col IS NULL` filter makes the loop idempotent.\n\n### Lazy fields\n\nA lazy field is stored as NULL in the database. The lambda runs on the first\nread of the field and persists the value:\n\n```crystal\nfield 10, :badge, String?,\n  lazy: -\u003e(_u : User) { derive_badge(_u) }\n```\n\nLazy fields:\n- Must be nullable (`T?`).\n- Cannot be used in `where` or `order_by` (compile-time error via ADR-0006).\n- If a named query references a lazy field non-projectionally, the analyzer\n  overrides it to eager and emits a diagnostic at migrate time.\n- Are mutually exclusive with `default:` and `backfill:`.\n\n---\n\n## Schema evolution\n\nAll schema changes are expressed by editing the model. prostore computes the\ndiff at boot and plans the necessary DDL steps.\n\n### Add a nullable column\n\n```crystal\nfield 5, :nickname, String?\n```\n\nOne step: `ALTER TABLE users ADD COLUMN nickname TEXT`.\n\n### Add a non-null column (same default and backfill)\n\n```crystal\nfield 6, :role, String,\n  default:  \"member\",\n  backfill: \"member\"\n```\n\nOne step: `ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT ('member')`.\nExisting rows get the default.\n\n### Add a non-null column (different backfill)\n\n```crystal\nfield 7, :verified, String,\n  default:  \"pending\",\n  backfill: SQL.expr(\"CASE WHEN email LIKE '%@verified.example' THEN 'verified' ELSE 'pending' END\")\n```\n\nThree steps: add nullable → server-side `UPDATE` with backfill expression →\nrebuild to apply `NOT NULL` with default.\n\n\u003e **SQLite + foreign keys:** if the target table is referenced by a FK from\n\u003e another table, the three-step path fails on SQLite because `foreign_keys=ON`\n\u003e prevents the table rebuild. prostore raises a clear `MigrationError` at plan\n\u003e time listing the referencing tables and both fix strategies (declare the field\n\u003e nullable, or use identical `default:`/`backfill:` to take the single-step path).\n\n### Add a non-null column with Crystal-lambda backfill\n\n```crystal\nfield 8, :score, Int32,\n  default:  SQL.expr(\"0\"),\n  backfill: -\u003e(row : User) { compute_score(row) }\n```\n\nThree steps: add nullable → chunked Crystal-side backfill loop → apply NOT NULL.\n\n### Rename a field\n\nChange the symbol in the `field` declaration; the tag stays the same:\n\n```crystal\nfield 2, :handle, String   # was :email; tag 2 unchanged\n```\n\nOne step: `ALTER TABLE users RENAME COLUMN email TO handle`. Data is preserved.\n\n### Remove a field\n\nReserve the tag. The column is dropped:\n\n```crystal\nreserved 3   # column tenant_id (tag 3) will be dropped\n```\n\nOne step: `ALTER TABLE users DROP COLUMN tenant_id`. Tag 3 may never be reused.\n\n### Replace a field (old → new pattern)\n\nWhen you need a new type or default for an existing column, add a new tag and\nkeep both during a transition window:\n\n```crystal\nreserved 9                  # :legacy_email retired\nfield 10, :email_v2, String,\n  default:  -\u003e(u : User) { u.email },\n  backfill: SQL.expr(\"legacy_email\")\n```\n\n### Rename an index\n\nChange the `name:` option (or rename the columns — but columns must be reserved\nand re-added to change composition):\n\n```crystal\nindex 1, [:email], unique: true, name: \"users_email_unique\"  # was \"users_email_idx\"\n```\n\nOne step: `ALTER INDEX … RENAME TO …` (PostgreSQL) or rebuild (SQLite).\n\n### Remove an index\n\n```crystal\nreserved_index 2    # index tag 2 will be dropped\n```\n\n### Remove a foreign key\n\n```crystal\nreserved_foreign_key 1    # FK tag 1 will be dropped\n```\n\n---\n\n## Migration system\n\n### Running migrations\n\n```crystal\n# URL-based: opens a temporary connection, migrates, closes it.\n# For file-based databases, use this at startup before Prostore.connect.\nProstore.migrate(\"postgres://user:pass@host/dbname\")\n\n# Connection-based: migrate on a pre-opened connection.\nconn = Prostore::Connection.open(url)\nProstore.migrate(conn)\n\n# Combined: open, migrate, set as default connection, return it.\n# Use for sqlite3::memory: — all three operations share one connection.\nconn = Prostore.setup(\"sqlite3::memory:?max_pool_size=1\")\n```\n\n### Boot sequence\n\nWhen `migrate` or `setup` runs:\n\n1. Ensure bookkeeping tables (`prostore_migration`, `prostore_migration_step`,\n   `prostore_schema`) exist.\n2. Compute the target schema fingerprint over all registered models.\n3. Check for an in-progress migration:\n   - If yes: verify target fingerprint matches; resume from the first incomplete step.\n   - If no: validate model changes, diff, plan, persist the plan, start.\n4. Claim a time-bounded lease (`claimed_until`; 5-minute default). Another process\n   holding a live lease causes an immediate error.\n5. Execute each step in ordinal order: `mark_running → execute → mark_complete → heartbeat`.\n6. Mark migration complete and release the lease.\n\n### Resuming crashed migrations\n\nSteps are persisted before any DDL runs (plan-at-start). If the process crashes\nmid-migration, the next boot finds the in-progress migration and continues from\nthe first non-complete step. Each step is independently transactional and\nidempotent.\n\n### Concurrent safety\n\nThe lease mechanism (a time-bounded row lock in `prostore_migration`) ensures\nonly one runner executes at a time. A background heartbeat fiber renews the\nlease every 30 seconds. If the runner crashes without releasing the lease, the\nnext runner can steal it after the TTL expires.\n\nA version-skew check prevents a new deployment from attaching to a migration\nstarted by an old schema version: if the target fingerprints differ, the new\nrunner refuses to start.\n\n### Schema fingerprint\n\nThe fingerprint is a SHA-256 hash over field tags, types, nullability, primary\nkey, auto_increment, default SQL, backfill SQL, and lazy flags — but **not**\nover field names. A rename does not change the fingerprint. The fingerprint\nidentifies the *target state*, not the labeling.\n\n---\n\n## Drift detection\n\nIf the database is modified outside of prostore (a DBA renames a column, drops\nan index, etc.), the next `migrate` call detects the drift:\n\n| Drift type | Behavior |\n|-----------|----------|\n| Managed column renamed externally | Auto-fix: rename back. |\n| Managed column dropped externally | **Error** — data was lost; restore or reserve the tag. |\n| Managed column type/nullability changed externally | **Error** — in-place changes are forbidden (ADR-0003). |\n| Managed index dropped | Auto-fix: recreate. |\n| Managed index renamed externally | Auto-fix: rename back. |\n| Managed index UNIQUE flag changed | **Error** — definition change requires a new tag. |\n| Unmanaged column, index, or table | Tolerated. |\n\n---\n\n## Connection\n\n### Opening a connection\n\n```crystal\nconn = Prostore::Connection.open(\"sqlite3://app.db\")\nconn = Prostore::Connection.open(\"postgres://user:pass@host/dbname\")\n```\n\n### Default connection\n\n```crystal\nProstore.connect(\"sqlite3://app.db\")        # set default connection\nProstore.default_connection                 # retrieve it\nProstore.default_connection = conn          # set directly (useful in tests)\n```\n\nCRUD and query methods use the default connection automatically. The migration\nrunner also accepts an explicit `Connection`.\n\n### Connection methods\n\n`Connection` exposes the underlying pool via `.db` and also delegates the most\ncommon methods directly:\n\n```crystal\nconn.exec(\"INSERT INTO …\", arg1, arg2)\nconn.scalar(\"SELECT COUNT(*) FROM users\")\nconn.query_one(\"SELECT * FROM users WHERE id = ?\", 1_i64) { |rs| … }\nconn.query_one?(\"SELECT …\", id, as: String)  # nil-safe\nconn.query_each(\"SELECT * FROM …\") { |rs| … }\nconn.transaction { |tx| … }\nconn.with_connection { |db_conn| … }   # pins all DDL to one connection\nconn.db                                # DB::Database (full crystal-db API)\nconn.adapter                           # Prostore::Adapter::Base\nconn.close\n```\n\n### SQLite URL parameters\n\nSeveral SQLite pragmas can be passed as URL query parameters and are applied\nto every connection via `PRAGMA`:\n\n| Parameter | SQLite PRAGMA |\n|-----------|--------------|\n| `journal_mode` | `PRAGMA journal_mode = \u003cvalue\u003e` |\n| `synchronous` | `PRAGMA synchronous = \u003cvalue\u003e` |\n| `cache_size` | `PRAGMA cache_size = \u003cvalue\u003e` |\n| `temp_store` | `PRAGMA temp_store = \u003cvalue\u003e` |\n\n```crystal\n# Enable WAL mode\nProstore.setup(\"sqlite3://app.db?journal_mode=wal\u0026synchronous=normal\")\n```\n\nNote: `sqlite3::memory:` does not support WAL mode; the pragma is silently\nignored by SQLite in that case.\n\n### SQLite in production (concurrent access)\n\nSQLite's default journal mode (`delete`) serializes all access — readers and\nthe writer block each other, and connections fail immediately with\n`SQLITE_BUSY` when another connection holds a lock (`busy_timeout` defaults\nto 0). For any production workload with concurrent readers alongside a writer,\nenable **WAL mode** and set a non-zero **busy_timeout**:\n\n```crystal\nProstore.setup(\"sqlite3://app.db?journal_mode=wal\u0026synchronous=normal\u0026busy_timeout=5000\")\n```\n\n| Setting | Why |\n|---------|-----|\n| `journal_mode=wal` | Readers never block the writer; the writer never blocks readers |\n| `synchronous=normal` | Safe with WAL (checkpoints are still fsync'd); faster than `full` |\n| `busy_timeout=5000` | Writer retries for up to 5 s on lock contention instead of failing immediately |\n\nIncrease `max_pool_size` (via URL) to allow Crystal fibers to hold multiple\nconcurrent reader connections:\n\n```crystal\nProstore.setup(\"sqlite3://app.db?journal_mode=wal\u0026synchronous=normal\u0026busy_timeout=5000\u0026max_pool_size=10\")\n```\n\nThere is no separate read-replica configuration — all connections share the\nsame pool and adapter. WAL is the mechanism that makes concurrent reads safe.\n\n### In-memory SQLite\n\n`sqlite3::memory:` creates a fresh database per `DB.open` call. To use an\nin-memory database for tests, use `Prostore.setup` so migration and app queries\nshare the same connection:\n\n```crystal\nurl = \"sqlite3::memory:?max_pool_size=1\u0026initial_pool_size=1\u0026max_idle_pool_size=1\"\nconn = Prostore.setup(url, [User, Post] of Prostore::Model.class)\n# conn is now both migrated and set as default_connection\n```\n\n---\n\n## Backup\n\n`Prostore::Backup.run` writes a point-in-time backup of the database to a\ndestination path. The destination may contain strftime tokens (`%Y %m %d\n%H %M %S`), which are expanded to the current UTC time — so a single\n`cron` line produces naturally-rotated, timestamped files:\n\n```crystal\npath = Prostore::Backup.run(conn, \"/var/backups/app_%Y%m%d_%H%M%S.db\")\n# =\u003e \"/var/backups/app_20260514_103045.db\"\n```\n\nOr via the operator CLI:\n\n```sh\nprostore backup /var/backups/app_%Y%m%d_%H%M%S.db\n# Backup written to /var/backups/app_20260514_103045.db\n```\n\n### SQLite\n\nUses [`VACUUM INTO`](https://www.sqlite.org/lang_vacuum.html) — an online,\nnon-blocking backup that works under WAL mode and produces a defragmented\ncopy. Requires SQLite 3.27.0 or later (released 2019).\n\n### PostgreSQL\n\nShells out to `pg_dump` (plain-text format). `pg_dump` must be available in\n`PATH`. Connection parameters (host, port, user, database) are derived from\n`DATABASE_URL`; the password is passed via `PGPASSWORD`.\n\n### Cron examples\n\n```sh\n# SQLite — hourly backup (one file per hour, 24 kept by filename convention)\n0 * * * * DATABASE_URL=sqlite3:///var/app/app.db \\\n  /path/to/prostore backup /var/backups/app_%Y%m%d_%H.db\n\n# SQLite — every 6 hours\n0 */6 * * * DATABASE_URL=sqlite3:///var/app/app.db \\\n  /path/to/prostore backup /var/backups/app_%Y%m%d_%H.db\n\n# PostgreSQL — daily dump at 03:00\n0 3 * * * DATABASE_URL=postgres://user:pass@localhost/mydb \\\n  /path/to/prostore backup /var/backups/mydb_%Y%m%d.sql\n```\n\nUse OS-level tools (`find -mtime`, `logrotate`) to prune old backups.\n\n---\n\n## Operator CLI\n\nThe `prostore` binary exposes operator commands. Set `DATABASE_URL` in the\nenvironment:\n\n```sh\nexport DATABASE_URL=sqlite3://app.db\n\n# Show migration history and drift summary\nprostore migrate status\n\n# Dry-run drift check (no DDL applied)\nprostore drift check\n\n# Abort a stuck in-progress migration (does NOT revert completed steps)\nprostore migrate abort 42\n```\n\nThe same operations are available programmatically:\n\n```crystal\nconn = Prostore::Connection.open(ENV[\"DATABASE_URL\"])\nProstore::Migration::CLI.status(conn)\nProstore::Migration::CLI.drift_check(conn)\nProstore::Migration::CLI.abort(conn, migration_id: 42_i64)\n```\n\n---\n\n## Testing\n\n### SQLite in tests\n\nUse a temp file or in-memory database with `Prostore.setup`:\n\n```crystal\n# Temp file (supports WAL, isolated per process)\nTEST_DB = \"/tmp/myapp_test_#{Process.pid}.db\"\nconn = Prostore.setup(\"sqlite3://#{TEST_DB}\", [User, Post] of Prostore::Model.class)\n\n# In-memory (fastest, but requires setup to share the connection)\nconn = Prostore.setup(\"sqlite3::memory:?max_pool_size=1\", [User] of Prostore::Model.class)\n```\n\n### Resetting state between tests\n\n`Prostore.delete_all` (aliased as `Prostore.test_reset`) deletes all rows from\nthe given model set in reverse FK dependency order so child rows are always\nremoved before their parent rows, even with `foreign_keys=ON`:\n\n```crystal\nALL_MODELS = [User, Post, Comment] of Prostore::Model.class\n\nafter_each do\n  Prostore.test_reset(ALL_MODELS, conn)\nend\n```\n\nBoth methods accept an explicit `Connection`. If omitted, the process-wide\ndefault connection is used.\n\n### Setting the default connection in specs\n\n```crystal\nbefore_each do\n  conn = Prostore.setup(TEST_URL, MODELS)\nend\n\nafter_each do\n  Prostore.default_connection = nil\n  conn.close\nend\n```\n\n### Testing against PostgreSQL\n\nSet `POSTGRES_URL` in the environment:\n\n```sh\nPOSTGRES_URL=postgres://user:pass@localhost/test_db crystal spec\n```\n\n---\n\n## Architecture\n\n```\nDSL (macros + Schema types)\n  ↓ compile time\nSchema::Definition (Class.prostore_schema)\n  ↓ runtime planning\nDiff::Engine + Diff::Validator + Drift::Detector + Query::Analyzer\n  ↓ produces\nOperation list → Steps::Planner → Step list\n  ↓ persisted to prostore_migration_step\nSteps::Executor (per backend) — each step in its own transaction\n  ↓\nLive database; prostore_schema bookkeeping updated atomically\n```\n\n**Key invariants:**\n\n- Tags are the stable identity of fields, indexes, and foreign keys — not names.\n- In-place type changes and nullability changes are forbidden (ADR-0003).\n- The plan is persisted before any DDL runs; crashes resume from the last completed step (ADR-0009).\n- Every named query must have index coverage; the runner enforces this at boot (ADR-0006).\n- `prostore_` is a reserved prefix for table names and field names.\n\nSee [`doc/adr/`](doc/adr/) for the full design rationale.\n\n---\n\n## Development\n\n```sh\nshards install\ncrystal spec spec/unit/                        # unit tests\ncrystal spec spec/integration/sqlite/          # SQLite integration tests\nPOSTGRES_URL=postgres://... crystal spec       # full matrix\n```\n\n---\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fthreez%2Fprostore.cr","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fthreez%2Fprostore.cr","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fthreez%2Fprostore.cr/lists"}