Vendor dependencies

This commit is contained in:
2026-08-01 16:11:49 +03:00
parent 7f139a0241
commit 6b5e7f0f8b
29706 changed files with 9575646 additions and 0 deletions
@@ -0,0 +1,8 @@
+++
title = "Reference"
description = "Information-oriented technical descriptions of Loco's machinery — configuration, CLI, generators, and APIs."
template = "docs/section.html"
sort_by = "weight"
weight = 3
draft = false
+++
@@ -0,0 +1,115 @@
+++
title = "AppContext & prelude"
description = "The AppContext struct field-by-field, and everything use loco_rs::prelude::* brings into scope."
date = 2021-05-01T18:10:00+00:00
updated = 2021-05-01T18:10:00+00:00
draft = false
weight = 6
sort_by = "weight"
template = "docs/page.html"
[extra]
lead = ""
toc = true
top = false
+++
`AppContext` is the cloneable, `axum`-`State`-compatible struct that carries every shared resource of a Loco app (DB connection, cache, queue, mailer, storage, config, and an open-ended DI slot). `loco_rs::prelude` is the single-import surface app code pulls in instead of naming individual `loco_rs` and `axum` paths. Both are declared in `src/app.rs` and `src/prelude.rs`.
## `AppContext`
Defined at `src/app.rs:253-273`:
```rust
#[derive(Clone, FromRef)]
pub struct AppContext {
pub environment: Environment,
#[cfg(feature = "with-db")]
pub db: DatabaseConnection,
pub queue_provider: Option<Arc<bgworker::Queue>>,
pub config: Config,
pub mailer: Option<EmailSender>,
pub storage: Arc<Storage>,
pub cache: Arc<cache::Cache>,
pub shared_store: Arc<SharedStore>,
}
```
### Derives
`#[derive(Clone, FromRef)]` (`src/app.rs:253`). `FromRef` (from `axum`) auto-generates `impl FromRef<AppContext> for <FieldType>` for each field, so a handler can extract a single field directly — e.g. `State<DatabaseConnection>` or `State<Arc<cache::Cache>>` — instead of always taking the whole `State<AppContext>`.
### Fields
| Field | Type | Feature gate | Purpose |
|---|---|---|---|
| `environment` | `Environment` | none | Which profile the app booted under (`Production` / `Development` / `Test` / `Any(String)`); drives config-file selection. |
| `db` | `DatabaseConnection` (Sea-ORM 2.0) | `#[cfg(feature = "with-db")]` | The pooled Sea-ORM database connection used by every entity query and by `db::converge` migrations. Absent entirely from the struct when `with-db` is off. |
| `queue_provider` | `Option<Arc<bgworker::Queue>>` | none | The background-job queue (Redis / Postgres / SQLite / in-process), if the app was booted with one wired up. `None` for queue-less apps. |
| `config` | `Config` | none | The fully loaded, deserialized `config/<environment>.yaml` (+ `.local.yaml` overlay). |
| `mailer` | `Option<EmailSender>` | none | The configured email-sending backend (SMTP or stub), if the app enabled one. |
| `storage` | `Arc<Storage>` | none | The file/object storage abstraction (local disk or a cloud backend selected by the `storage_*` feature flags). |
| `cache` | `Arc<cache::Cache>` | none | The cache handle (in-memory, Redis, or null backend per `cache_*` flags / `CacheConfig`). |
| `shared_store` | `Arc<SharedStore>` | none | A `TypeId`-keyed, concurrent DI container (backed by `DashMap`) for stashing arbitrary app-defined services — see below. |
`db` is the only field that is compiled out (not just `None`-able) when its feature (`with-db`) is disabled — every other field is unconditionally present, with `Option`/empty-default standing in for "not configured."
### `SharedStore` — the generic DI slot
`shared_store: Arc<SharedStore>` (`src/app.rs:33-245, 272`) is a small heterogeneous store for services that don't have a dedicated `AppContext` field. Its API:
- `insert<T: 'static + Send + Sync>(&self, val: T)` (`:62`)
- `remove<T>(&self) -> Option<T>` (`:103`)
- `get_ref<T>(&self) -> Option<RefGuard<'_, T>>` (`:151`) — borrowed access via a `Deref<Target = T>` guard
- `get<T: Clone + 'static + Send + Sync>(&self) -> Option<T>` (`:195`) — cloning access
- `contains<T>(&self) -> bool` (`:221`)
To read a stashed value from inside a handler, use the extractor of the same name: `controller::extractor::shared_store::SharedStore<T>(pub T)`, which implements `FromRequestParts<AppContext>` and returns `Error::InternalServerError` if `T` was never inserted (`src/controller/extractor/shared_store.rs:6-29`).
**Naming note:** `app::SharedStore` (the container held on `AppContext`) and the extractor `controller::extractor::shared_store::SharedStore<T>` (the axum extractor) are two distinct types that share a name. Both are reachable through the prelude — see below — so disambiguate by context: the field type is the store, the tuple-struct-with-generic is the extractor.
## `loco_rs::prelude`
`use loco_rs::prelude::*;` (`src/prelude.rs`) is the standard single import for app code (controllers, models, workers, tasks). It re-exports, unconditionally unless noted:
**Async / axum plumbing**
- `async_trait::async_trait`
- `axum::debug_handler`
- `axum::extract::{Form, Multipart, Path, Query, State}`
- `axum::response::{IntoResponse, Response}`
- `axum::routing::{delete, get, head, options, patch, post, put, trace}`
- `axum_extra::extract::cookie`
**Third-party helpers**
- `chrono::NaiveDateTime as DateTime`
- `include_dir::{include_dir, Dir}`
- `serde_json::json as data` — sugar so controller/view code can write `data!({"item": ..})` instead of `json!`
- `validator::Validate`
**Core Loco types**
- `app::{AppContext, Initializer}`
- `bgworker::{BackgroundWorker, Queue}`
- `errors::Error` and `Result` (the crate's `Result<T, Error>` alias)
- `mailer` (the module itself) and `mailer::Mailer`
- `task::{self, Task, TaskInfo}`
- `validation::{self, Validatable, ValidatorTrait}`
**Controller layer**
- `controller::{bad_request, not_found, unauthorized}` — error-response constructor fns
- `controller::format` — the response-builder module (`format::json`, `format::render()`, ...)
- `controller::middleware::format::{Format, RespondTo}` — content-negotiation extractor/enum
- `controller::middleware::remote_ip::RemoteIP` — computed-client-IP extractor
- `controller::extractor::shared_store::SharedStore` — the DI extractor (see above)
- `controller::extractor::validate::{JsonValidate, JsonValidateWithMessage}` — validating-body extractors
- `controller::views::{engines::TeraView, ViewEngine, ViewRenderer}`
- `controller::{Json, Routes}`
**Feature-gated**
- `#[cfg(feature = "auth")] controller::extractor::auth` (the whole `auth` extractor module: `JWT`, `JWTWithUser`, `ApiToken`, `UserClaims`, token-extraction helpers)
- `#[cfg(feature = "with-db")]`:
- Sea-ORM traits and types: `ActiveModelBehavior, ActiveModelTrait, ActiveValue, ColumnTrait, ConnectionTrait, DatabaseConnection, DbErr, EntityTrait, IntoActiveModel, ModelTrait, QueryFilter, Set, TransactionTrait`
- Sea-ORM scalar re-exports: `sea_orm::prelude::{Date, DateTimeUtc, DateTimeWithTimeZone, Decimal, Uuid}`
- `model::{query, Authenticable, ModelError, ModelResult}` — plus a nested `pub mod model { pub use crate::model::query; }`, so `query` is reachable both as `loco_rs::prelude::query` and as `loco_rs::prelude::model::query`
- `#[cfg(feature = "testing")] crate::testing::prelude::*` — pulled in only for apps/tests built with the `testing` feature
Source: `src/prelude.rs` (verified against `HEAD` at authoring time).
@@ -0,0 +1,217 @@
+++
title = "CLI reference"
description = "Every flag and subcommand for the `loco` app generator and the `cargo loco` runtime CLI."
date = 2021-05-01T18:10:00+00:00
updated = 2021-05-01T18:10:00+00:00
draft = false
weight = 2
sort_by = "weight"
template = "docs/page.html"
aliases = ["/docs/getting-started/starters/"]
[extra]
lead = ""
toc = true
top = false
+++
Loco ships **two** command-line surfaces:
| Binary | Crate | Installed via | Purpose |
|---|---|---|---|
| `loco` | `loco-new` (binary crate, not `loco-rs`) | `cargo install loco` | Scaffolds a new app on disk. One subcommand: `new`. |
| `cargo loco` | generated into every app by `loco new`, backed by `loco_rs::cli` | built with your app | Runtime operations against *your* app: start the server, run migrations, generate code, run tasks, etc. |
`cargo loco` has **two `main()` implementations**, selected by the `with-db` Cargo feature (`src/cli.rs:712` when `with-db` is on, `src/cli.rs:872` when it is off). The non-`with-db` build has no `Db` subcommand and no DB-backed generators (`model`/`migration`/`scaffold`). Both variants share the same top-level `-e, --environment <ENV>` global flag (default `development`).
---
## 1. `loco new` — the app generator
Source: `loco-new/src/bin/main.rs:30-62`.
### 1.1 Global flag
| Flag | Type | Default | Purpose |
|---|---|---|---|
| `-l, --log <LEVEL>` | `LevelFilter` | `ERROR` | Verbosity of the generator's own logging (`main.rs:22-24`) |
### 1.2 `new` flags
| Flag | Type | Default | Purpose |
|---|---|---|---|
| `-p, --path <PATH>` | `PathBuf` | `.` | Local directory to generate into (`main.rs:34-36`) |
| `-n, --name <NAME>` | `Option<String>` | none — prompts | App name (`main.rs:38-40`) |
| `--db <DB>` | `wizard::DBOption` | none — prompts | DB provider: `sqlite` \| `postgres` \| `none` (`main.rs:42-44`) |
| `--bg <BG>` | `wizard::BackgroundOption` | none — prompts | Background-worker mode: `async` \| `queue-redis` \| `queue-postgres` \| `queue-sqlite` \| `blocking` (`main.rs:46-48`) |
| `--assets <ASSETS>` | `wizard::AssetsOption` | none — prompts | Asset serving: `serverside` \| `clientside` \| `none` (`main.rs:50-52`) |
| `--embedded-assets` | `bool` | `false` — prompts (interactive, serverside only) | Embed static assets into the binary (`embedded_assets` feature). Serverside only; combined with `--assets clientside` it is a hard error. Supplying it (or running fully flag-driven) skips the interactive prompt |
| `-a, --allow-in-git-repo` | `bool` | `false` | Skip the "you're inside a git repo, continue?" abort prompt (`main.rs:54-56`) |
| `--os <OS>` | `wizard::OS` | `linux` on Unix, `windows` otherwise | Generate a Unix- or Windows-optimized starter: `windows` \| `linux` \| `macos` (`main.rs:58-60`, `DEFAULT_OS` `main.rs:64-67`) |
There is **no `-t/--template`** and **no `-v/--verbose`** flag. The `Template` enum exists internally and derives `ValueEnum`, but it is not wired to any CLI argument — template choice is interactive-only (§1.4).
If `--db`, `--bg`, **and** `--assets` are all supplied, the wizard skips every prompt except app name (`wizard.rs:287-297`); app name still prompts unless `--name` is also given.
### 1.3 Interactive prompts (when flags are omitted)
Source: `loco-new/src/wizard.rs`.
1. **App name?** (default `myapp`) — non-empty, no leading digit, Unicode XID + `-`/`_` (`wizard.rs:201-267`).
2. **"You are inside a git repository. Do you wish to continue?"** — only if `cwd` is a git repo and `--allow-in-git-repo` was not passed; default No, aborts on decline (`wizard.rs:227-240`; `main.rs:91-94`).
3. **"What would you like to build?"** — template select (`wizard.rs:299-302`).
4. Conditional DB / background / assets follow-ups, depending on the chosen template.
### 1.4 Templates
Source: `wizard.rs:12-27`, branch logic `wizard.rs:304-333`.
| Template (enum) | Menu label | DB prompt? | BG prompt? | Assets |
|---|---|---|---|---|
| `SaasServerSideRendering` (default) | "Saas App with server side rendering" | yes | yes | forced `Serverside` |
| `SaasClientSideRendering` | "Saas App with client side rendering" | yes | yes | forced `Clientside` |
| `RestApi` | "Rest API (with DB and user auth)" | yes | yes | forced `None` |
| `Lightweight` | "lightweight-service (minimal, only controllers and views)" | no — forced `None` | no — forced `Async` | forced `None` |
| `Advanced` | "Advanced" | yes | yes | **asks** (only template that prompts for asset config) |
### 1.5 Option enums (clap values + menu labels)
**`DBOption`** — `wizard.rs:39-79` — clap `--db` values: `sqlite` (default), `postgres`, `none`.
| Value | Endpoint template | Notes |
|---|---|---|
| `sqlite` | `sqlite://NAME_ENV.sqlite?mode=rwc` | default |
| `postgres` | `postgres://loco:loco@localhost:5432/NAME_ENV` | warns a running Postgres instance is required |
| `none` | — | `enable()` is `false`; disables DB, auth, and mailer generation |
**`BackgroundOption`** — `wizard.rs:81-125` — clap `--bg` values: `async` (default), `queue-redis`, `queue-postgres`, `queue-sqlite`, `blocking`.
| Value | Menu label | Notes |
|---|---|---|
| `async` | "Async (in-process tokio async tasks)" | default |
| `queue-redis` | "Queue: Redis (standalone workers)" | warns the selected queue backend must be reachable; generates with the `worker_redis` feature |
| `queue-postgres` | "Queue: Postgres (standalone workers)" | warns the selected queue backend must be reachable; generates with the `worker` feature |
| `queue-sqlite` | "Queue: SQLite (standalone workers)" | warns the selected queue backend must be reachable; generates with the `worker` feature |
| `blocking` | "Blocking (run tasks in foreground)" | warns it **blocks requests** until the task completes |
**`AssetsOption`** — `wizard.rs:127-162` — clap `--assets` values: `serverside` (default), `clientside`, `none`.
| Value | Menu label | Notes |
|---|---|---|
| `serverside` | "Server (configures server-rendered views)" | default |
| `clientside` | "Client (configures assets for frontend serving)" | prints follow-up: `cd frontend/ && npm install && npm run build` |
| `none` | "None" | — |
**`OS`** — `loco-new/src/lib.rs:41-51` — clap `--os` values: `windows`, `linux`, `macos`. `windows` adds a second `tool` bin target to the generated `Cargo.toml` (`Cargo.toml.t:65-70`).
### 1.6 Derived generation settings
Source: `loco-new/src/settings.rs:58-89`.
- DB enabled → `Features::default()` (loco-rs default features apply to the generated app).
- DB **disabled** (Lightweight template, or `--db none`) → `default-features = false`, feature names = `["cli"]`; if background is `queue-redis`, `"worker_redis"` is appended; if background is `queue-postgres` or `queue-sqlite`, `"worker"` is appended.
- `auth` and `mailer` scaffolding are enabled iff DB is enabled.
- Serverside assets → generated `Initializers { view_engine: true }`.
- `loco_version_text`: normally `version = "0.17"`; when env var `LOCO_DEV_MODE_PATH` is set, becomes `version = "*", path = "<that path>"` — this is how the local framework checkout is dogfooded.
- Generated app's own edition is pinned in `loco-new/base_template/Cargo.toml.t` independent of the `loco-rs` framework edition.
---
## 2. `cargo loco` — the runtime CLI
Source: `src/cli.rs:64-171` (`enum Commands`).
### 2.1 Top-level subcommands
| Subcommand | Alias | Gated on | Flags | Purpose |
|---|---|---|---|---|
| `start` | `s` | — | `-w/--worker[=tags]`, `-s/--server-and-worker`, `-a/--all`, `--scheduler`, `-b/--binding <ADDR>`, `-p/--port <PORT>`, `-n/--no-banner` (`worker`/`server_and_worker`/`all` are mutually exclusive) | Boot the app in a start mode (`cli.rs:66-91`) |
| `db` | — | `#[cfg(feature = "with-db")]` | see §2.2 | Database operations (`cli.rs:92-97`) |
| `routes` | — | — | none | Print all application endpoints as a tree (`cli.rs:98-99`) |
| `middleware` | — | — | `-c/--config` | List middlewares (enabled first, then disabled); `--config` also prints each one's resolved config (`cli.rs:101-105`) |
| `task` | `t` | — | `[name]`, `key:val...` params | Run a custom task by name, with `key:value` params (`cli.rs:107-114`) |
| `jobs` | — | `#[cfg(feature = "worker")]` | see §2.3 | Manage the background jobs queue (`cli.rs:115-120`) |
| `scheduler` | — | — | `-n/--name <NAME>`, `-t/--tag <TAG>`, `-c/--config <PATH>`, `-l/--list` | Run or inspect the scheduler (`cli.rs:121-137`) |
| `generate` | `g` | `#[cfg(debug_assertions)]` | see §2.4 | Code generation (`cli.rs:138-146`) |
| `doctor` | — | — | `-c/--config`, `-p/--production` | Validate/diagnose the app; `--config` instead dumps the resolved config + environment and skips checks (`cli.rs:147-154`, `814-832`) |
| `version` | — | — | none | Print the app version (`cli.rs:155-156`) |
| `watch` | `w` | — | `-w/--worker[=tags]`, `-s/--server-and-worker`, `--scheduler` | Wraps `cargo-watch -s 'cargo loco start ...'` (`cli.rs:158-170`, `838-867`); requires `cargo-watch` installed |
**Start-mode resolution** (`cli.rs:736-750`): `--all` or (`--server-and-worker` **and** `--scheduler`) → `All`; `--server-and-worker` alone → `ServerAndWorker`; `--worker[=tags]` (+ `--scheduler`) → `WorkerAndScheduler` / `WorkerOnly`; `--scheduler` alone → `ServerAndScheduler`; none of the above → `ServerOnly`.
### 2.2 `db` subcommands
`enum DbCommands`, `src/cli.rs:483-523` — only present when `with-db` is enabled.
| Command | Flags | Purpose |
|---|---|---|
| `create` | — | Create the schema/database |
| `migrate` | — | Apply all pending up-migrations |
| `down` | `[steps]` (default `1`) | Roll back the given number of migrations |
| `reset` | — | Drop all tables, then reapply every migration |
| `status` | — | Show migration status |
| `entities` | — (`#[cfg(debug_assertions)]`) | Generate entity `.rs` files from the current DB schema |
| `truncate` | — | Truncate table data without dropping tables |
| `seed` | `-r/--reset`, `-d/--dump`, `--dump-tables <csv>`, `--from <DIR>` (default `src/fixtures`) | Seed the DB from files, or dump tables to files (`cli.rs:505-520`) |
| `schema` | — | Dump the database schema |
### 2.3 `jobs` subcommands
`enum JobsCommands`, `src/cli.rs:598-647` — only present when the `worker` feature is enabled (`worker_redis` implies `worker`).
| Command | Flags | Purpose |
|---|---|---|
| `cancel` | `--name <NAME>` (required) | Set jobs matching `<NAME>` to `cancelled` |
| `tidy` | — | Delete jobs that are `completed` or `cancelled` |
| `purge` | `--max-age <DAYS>` (default `90`), `--status <csv>`, `--dump <PATH>` | Delete old failed/cancelled jobs, optionally dumping them first |
| `dump` | `--status <csv>`, `-f/--folder <DIR>` (default `.`) | Save job details to files |
| `import` | `-f/--file <PATH>` | Import jobs from a file |
| `requeue` | `--from-age <MINS>` (default `0`) | Move `processing` jobs older than the given age back to `queued` |
### 2.4 `generate` subcommands
`enum ComponentArg`, `src/cli.rs:173-382` — only present in debug builds (`#[cfg(debug_assertions)]` on `Commands::Generate`). `model`/`migration`/`scaffold` are additionally gated on `#[cfg(feature = "with-db")]`. Full field-type syntax is covered in the [Generators & field types](@/docs/reference/generators.md) reference; this table lists CLI shape only.
| Command | Gated | Args / flags | Notes |
|---|---|---|---|
| `model` | `with-db` | `name`, `--without-tz`, `field:type ...` | Fields support `references` (e.g. `director:references`) |
| `migration` | `with-db` | `name`, `--without-tz`, `field:type ...` | Add/remove columns, join tables, empty migrations, references |
| `scaffold` | `with-db` | `name`, `--without-tz`, `field:type ...` | Adaptive, no kind flag; JSON API controller + typed React hooks/pages when the app has a `frontend/`. `--api`/`--html`/`--htmx` accepted for compat |
| `controller` | — | `name`, `actions...` | JSON API controller, no kind flag; `--api`/`--html`/`--htmx` accepted for compat |
| `task` | — | `name` | |
| `scheduler` | — | none | Scaffolds a scheduler config template |
| `worker` | — | `name` | |
| `mailer` | — | `name` | |
| `data` | — | `name` | Data loader |
| `deployment` | — | `kind` = `docker` \| `nginx` (`DeploymentKind`, `cli.rs:554-558`) | `docker` inspects static-assets config + `frontend/package.json`; `nginx` uses configured host/port |
| `override` | — | `[template_path]`, `--info` | Copies a built-in generator template locally for customization; no path lists all available templates |
The `-k/--kind` flags and the `ScaffoldKind` enum were **removed** in 1.0. For back-compat, `scaffold` and `controller` still accept `--api` (a no-op) and `--html`/`--htmx` (which error with a pointer to the React SPA frontend), so pre-1.0 commands don't break.
---
## 3. `cargo loco --help` (verified output)
The top-level help, matching `enum Commands` exactly:
```sh
The one-person framework for Rust
Usage: demo_app-cli [OPTIONS] <COMMAND>
Commands:
start Start an app
db Perform DB operations
routes Describe all application endpoints
middleware Describe all application middlewares
task Run a custom task
jobs Managing jobs queue
scheduler Run the scheduler
generate code generation creates a set of files and code templates based on a predefined set of rules
doctor Validate and diagnose configurations
version Display the app version
watch Watch and restart the app
help Print this message or the help of the given subcommand(s)
Options:
-e, --environment <ENVIRONMENT> Specify the environment [default: development]
-h, --help Print help
-V, --version Print version
```
@@ -0,0 +1,374 @@
+++
title = "Configuration"
description = "Exhaustive reference for every YAML key in a Loco app's config file: loading precedence, environment resolution, and every sub-config struct."
date = 2021-05-01T18:10:00+00:00
updated = 2021-05-01T18:10:00+00:00
draft = false
weight = 1
sort_by = "weight"
template = "docs/page.html"
[extra]
lead = ""
toc = true
top = false
+++
This page is a dictionary of every key Loco's configuration loader understands. It documents `struct Config` (`src/config/mod.rs`) and its sub-structs (`src/config/{auth,server,database,logger,mailer,queue,cache}.rs`). For a narrative walkthrough of settings and environments, see the-app/your-project.
## Loading & precedence
- Default config folder: `config/` (`Config::new`, `src/config/mod.rs:128-131`). Override with the `LOCO_CONFIG_FOLDER` env var (read by `Environment::load`, `src/environment.rs:59-64`).
- `Config::from_folder(env, path)` (`src/config/mod.rs:153-174`) resolves the file to load with this precedence — **first file that exists wins**:
1. `{path}/{env}.local.yaml`
2. `{path}/{env}.yaml`
If neither exists, loading fails with `Error::Message("no configuration file found in folder: ...")`.
- Before parsing, the entire YAML file is rendered as a **Tera template** (`src/config/mod.rs:170`, `src/tera.rs:5-8`, `Tera::one_off(.., autoescape=false)`). `get_env(name=.., default=..)` used throughout the shipped config files is Tera's own built-in function — it is **not** a Loco-registered function.
- Parse failures raise `Error::YAMLFile(err, path)` (`src/config/mod.rs:172-173`).
- `Config` implements `Display` by dumping itself back to YAML (`src/config/mod.rs:191-196`).
- `Config::get_jwt_config(&self) -> Result<&JWT>` (`src/config/mod.rs:180-188`) returns an error if `auth` or `auth.jwt` is absent.
### Environment resolution
`environment::resolve_from_env()` (`src/environment.rs:32-38`) picks the active environment name with this precedence:
1. `LOCO_ENV`
2. `RAILS_ENV`
3. `NODE_ENV`
4. fallback: `"development"` (`DEFAULT_ENVIRONMENT`, `src/environment.rs:21`)
## Top-level `Config`
`struct Config` — `src/config/mod.rs:62-92`. Every field is a top-level YAML key.
| Key | Type | Required? | Notes |
|---|---|---|---|
| `logger` | [`Logger`](#logger) | required | `mod.rs:64` |
| `server` | [`Server`](#server) | required | `mod.rs:65` |
| `database` | [`Database`](#database) | required, only when the `with-db` feature is enabled | `#[cfg(feature = "with-db")]`, `mod.rs:66-67` |
| `cache` | [`CacheConfig`](#cache) | optional — `#[serde(default)]`, defaults to `Null` | `mod.rs:68-69` |
| `queue` | `Option<`[`QueueConfig`](#queue)`>` | optional | `mod.rs:70` |
| `auth` | `Option<`[`Auth`](#auth)`>` | optional | `mod.rs:71` |
| `workers` | [`Workers`](#workers) | optional — `#[serde(default)]` | `mod.rs:72-73` |
| `mailer` | `Option<`[`Mailer`](#mailer)`>` | optional | `mod.rs:74` |
| `initializers` | `Option<Initializers>` (= `Option<BTreeMap<String, serde_json::Value>>`) | optional | `mod.rs:75`, type alias at `mod.rs:106` |
| `settings` | `Option<serde_json::Value>` | optional — `#[serde(default)]` | `mod.rs:88-89`; free-form app settings, surfaced at `ctx.config.settings` |
| `scheduler` | `Option<scheduler::Config>` | optional | `mod.rs:91`; struct owned by the scheduler area, not detailed here |
## `auth`
`struct Auth` — `src/config/auth.rs:13-17`.
```yaml
auth:
jwt:
location: # optional, default: Bearer
from: Bearer # or: {from: Query, name: <string>} / {from: Cookie, name: <string>}
secret: <base64 secret> # required — must be valid base64
expiration: 604800 # required, u64 seconds (e.g. 7 days)
```
| Key | Type | Required? | Notes |
|---|---|---|---|
| `auth.jwt` | `Option<JWT>` | optional | `auth.rs:16` |
| `auth.jwt.location` | `Option<JWTLocationConfig>` | optional, default: `Bearer` (resolved by `get_jwt_locations`, `src/controller/extractor/auth.rs:181-189`) | `auth.rs:24` |
| `auth.jwt.secret` | `String` | required | `auth.rs:26`. **Must be valid base64** — the JWT signer/verifier call `EncodingKey`/`DecodingKey::from_base64_secret` (`src/auth/jwt.rs:83,108`); a non-base64 string fails at token generation/validation time, not at config load time |
| `auth.jwt.expiration` | `u64` (seconds) | required | `auth.rs:28` |
`JWTLocationConfig` (`#[serde(untagged)]`, `auth.rs:47-54`) accepts either form:
- `Single(JWTLocation)` — a single location map
- `Multiple(Vec<JWTLocation>)` — a YAML list of location maps, tried in order until one yields a token
`JWTLocation` (`#[serde(tag = "from")]`, `auth.rs:35-44`):
| Variant | YAML shape | Notes |
|---|---|---|
| `Bearer` | `from: Bearer` | reads the `Authorization: Bearer <token>` header |
| `Query { name }` | `from: Query`<br>`name: <string>` | reads a query-string parameter |
| `Cookie { name }` | `from: Cookie`<br>`name: <string>` | reads a cookie |
Related, not YAML-configurable: default signing algorithm is **HS512** (`JWT_ALGORITHM`, `src/auth/jwt.rs:13`), overridable in code via `JWT::algorithm(..)` (`jwt.rs:51`), not via config.
## `server`
`struct Server` — `src/config/server.rs:29-45`.
```yaml
server:
binding: localhost # optional, default "localhost"
port: 5150 # required
host: http://localhost # required
ident: <string> # optional — overrides the `Server` response header
middlewares: {} # optional, default {} — see the middleware catalog reference
```
| Key | Type | Required? | Notes |
|---|---|---|---|
| `server.binding` | `String` | optional — `#[serde(default = "default_binding")]` → `"localhost"` | `server.rs:33-34,47-49` |
| `server.port` | `i32` | required | `server.rs:36` |
| `server.host` | `String` | required | `server.rs:38` |
| `server.ident` | `Option<String>` | optional | `server.rs:40`. When set, replaces the `Server` header value |
| `server.middlewares` | `middleware::Config` | optional — `#[serde(default)]` | `server.rs:44`. Struct is owned by the middleware area; see the middleware catalog reference for every middleware's keys |
`Server::full_url() -> String` returns `"{host}:{port}"` (`server.rs:52-55`).
## `workers`
`struct Workers` — `src/config/server.rs:64-68`.
```yaml
workers:
mode: BackgroundQueue # optional, default BackgroundQueue
```
| Key | Type | Required? | Notes |
|---|---|---|---|
| `workers.mode` | `WorkerMode` | optional — `Workers` derives `Default` | `server.rs:67` |
`WorkerMode` (`server.rs:71-83`):
| Variant | Default? | Notes |
|---|---|---|
| `BackgroundQueue` | yes | Workers run asynchronously via a queue backend. **Requires a configured `queue`** |
| `ForegroundBlocking` | no | Workers run in-process and block the caller until the task completes |
| `BackgroundAsync` | no | Workers run asynchronously in-process (async task, no external queue) |
## `database`
`struct Database` — `src/config/database.rs:22-84`. Only present/required when the `with-db` feature is enabled.
```yaml
database:
uri: postgres://root:12341234@localhost:5432/myapp_development # required
enable_logging: true # required — SQLx statement logging
min_connections: 1 # required
max_connections: 1 # required
connect_timeout: 500 # required, milliseconds
idle_timeout: 500 # required, milliseconds
acquire_timeout: 500 # optional, milliseconds
auto_migrate: true # optional, default false
dangerously_truncate: false# optional, default false
dangerously_recreate: false# optional, default false
run_on_start: <sql/pragma> # optional
```
| Key | Type | Required? | Notes |
|---|---|---|---|
| `database.uri` | `String` | required | `database.rs:28`. E.g. `postgres://...` or `sqlite://db.sqlite?mode=rwc` |
| `database.enable_logging` | `bool` | required | `database.rs:31` — enables SQLx statement logging |
| `database.min_connections` | `u32` | required | `database.rs:34` |
| `database.max_connections` | `u32` | required | `database.rs:37` |
| `database.connect_timeout` | `u64` (ms) | required | `database.rs:40` |
| `database.idle_timeout` | `u64` (ms) | required | `database.rs:43` |
| `database.acquire_timeout` | `Option<u64>` (ms) | optional | `database.rs:46` |
| `database.auto_migrate` | `bool` | optional — `#[serde(default)]` | `database.rs:51-52`. Runs pending migrations on boot; recommended for development, discouraged in production |
| `database.dangerously_truncate` | `bool` | optional — `#[serde(default)]` | `database.rs:56-57`. Deletes row data on boot; typically used in `test` |
| `database.dangerously_recreate` | `bool` | optional — `#[serde(default)]` | `database.rs:63-64`. Drops and recreates schema on boot |
| `database.run_on_start` | `Option<String>` | optional | `database.rs:83`. Arbitrary SQL/PRAGMA statements executed once the connection is established. For SQLite, if unset, Loco applies its own PRAGMA defaults (`foreign_keys=ON`, `journal_mode=WAL`, `synchronous=NORMAL`, `mmap_size=134217728`, `journal_size_limit=67108864`, `cache_size=2000`, `busy_timeout=5000`) |
Note: the `db_min_conn()=1` / `db_max_conn()=20` / `db_connect_timeout()=500` / `db_idle_timeout()=500` helper functions in `database.rs:86-100` are **not** defaults for `Database` itself (whose numeric fields have no `#[serde(default)]` and are required) — they are reused as the `#[serde(default = ...)]` values for the Postgres/Sqlite [`queue`](#queue) configs below.
## `logger`
`struct Logger` — `src/config/logger.rs:21-49`.
```yaml
logger:
enable: true # required
pretty_backtrace: false # optional, default false
level: debug # required — off|trace|debug|info|warn|error
format: compact # required — compact|pretty|json
override_filter: <str> # optional — EnvFilter directive string
file_appender: # optional
enable: true # required within block
non_blocking: false # optional, default false, within block
level: info # required within block
format: json # required within block
rotation: daily # required within block — minutely|hourly|daily|never
dir: ./logs # optional, default "./logs"
filename_prefix: <s> # optional
filename_suffix: <s> # optional
max_log_files: 7 # required within block
```
| Key | Type | Required? | Notes |
|---|---|---|---|
| `logger.enable` | `bool` | required | `logger.rs:24` |
| `logger.pretty_backtrace` | `bool` | optional — `#[serde(default)]` | `logger.rs:28-29`. When `true`, forces nicely-formatted backtraces (development-friendly); turn off in performance-sensitive production deployments |
| `logger.level` | `logger::LogLevel` | required | `logger.rs:34`. Variants: `off`, `trace`, `debug`, `info` (enum's own `#[default]`, but the field itself has no `#[serde(default)]` so it must be present in YAML), `warn`, `error` (`src/logger.rs:15-36`) |
| `logger.format` | `logger::Format` | required | `logger.rs:39`. Variants: `compact` (`#[default]`), `pretty`, `json` (`src/logger.rs:39-48`) |
| `logger.override_filter` | `Option<String>` | optional | `logger.rs:45`. A `tracing-subscriber` `EnvFilter` directive string |
| `logger.file_appender` | `Option<LoggerFileAppender>` | optional | `logger.rs:48` |
| `logger.file_appender.enable` | `bool` | required within block | `logger.rs:54` |
| `logger.file_appender.non_blocking` | `bool` | optional — `#[serde(default)]` | `logger.rs:57-58` |
| `logger.file_appender.level` | `logger::LogLevel` | required within block | `logger.rs:63` |
| `logger.file_appender.format` | `logger::Format` | required within block | `logger.rs:68` |
| `logger.file_appender.rotation` | `logger::Rotation` | required within block | `logger.rs:71`. Variants: `minutely`, `hourly` (`#[default]`), `daily`, `never` (`src/logger.rs:51-62`) |
| `logger.file_appender.dir` | `Option<String>` | optional, defaults to `"./logs"` when unset (applied at file-appender init, `src/logger.rs:113`) | `logger.rs:76` |
| `logger.file_appender.filename_prefix` | `Option<String>` | optional | `logger.rs:79` |
| `logger.file_appender.filename_suffix` | `Option<String>` | optional | `logger.rs:82` |
| `logger.file_appender.max_log_files` | `usize` | required within block | `logger.rs:85` |
## `mailer`
`struct Mailer` — `src/config/mailer.rs:29-35`.
```yaml
# development: capture instead of sending
mailer:
stub: false # optional, default false
smtp:
enable: true # required
host: localhost # required
port: 1025 # required
secure: false # required — legacy shorthand, see below
# production: implicit TLS on port 465 (SMTPS)
mailer:
smtp:
enable: true
host: smtp.example.com
port: 465
tls: implicit # overrides `secure`; see below
auth:
user: postmaster@mg.example.com
password: "{{ get_env(name='SMTP_PASSWORD') }}"
hello_name: <string> # optional — EHLO client id
```
| Key | Type | Required? | Notes |
|---|---|---|---|
| `mailer.stub` | `bool` | optional — `#[serde(default)]` | `mailer.rs:33-34`. When `true`, mail is captured rather than sent |
| `mailer.smtp` | `Option<SmtpMailer>` | optional | `mailer.rs:31` |
| `mailer.smtp.enable` | `bool` | required | `mailer.rs:55` |
| `mailer.smtp.host` | `String` | required | `mailer.rs:57` |
| `mailer.smtp.port` | `u16` | required | `mailer.rs:59` |
| `mailer.smtp.secure` | `bool` | required | `mailer.rs:65`. Legacy shorthand: `true` selects `STARTTLS` (port 587), `false` selects cleartext |
| `mailer.smtp.tls` | `Option<MailerTls>` | optional — `#[serde(default)]` | `mailer.rs:69`. **When set, overrides `secure`.** Variants (`#[serde(rename_all = "lowercase")]`, `mailer.rs:38-50`): `starttls` (opportunistic TLS, port 587 — what `secure: true` selects), `implicit` (TLS from the first byte, SMTPS, port 465 — required by providers that only accept implicit TLS), `none` (cleartext) |
| `mailer.smtp.auth` | `Option<MailerAuth>` | optional | `mailer.rs:71` |
| `mailer.smtp.auth.user` | `String` | required within block | `mailer.rs:93` |
| `mailer.smtp.auth.password` | `String` | required within block | `mailer.rs:95` |
| `mailer.smtp.hello_name` | `Option<String>` | optional | `mailer.rs:73`. `EHLO` client identifier, sent instead of the hostname |
Effective TLS mode is resolved by `SmtpMailer::tls_mode()` (`mailer.rs:76-87`): if `tls` is set, it wins outright; otherwise `secure: true` → `Starttls`, `secure: false` → `None`.
## `queue`
`enum QueueConfig` — `src/config/queue.rs:5-14`, `#[serde(tag = "kind")]` with variants `Redis`, `Postgres`, `Sqlite`.
```yaml
# kind: Redis
queue:
kind: Redis
uri: redis://127.0.0.1 # required
dangerously_flush: false # optional, default false
queues: [high, low] # optional — priority order, first = most important
num_workers: 2 # optional, default 2
# reaper: # optional, disabled by default (opt-in)
# age_minutes: 10 # requeue jobs stuck in `processing` for longer than this
# interval_seconds: 60 # optional, default 60 — how often to sweep
# kind: Postgres
queue:
kind: Postgres
uri: postgres://... # required
dangerously_flush: false # optional, default false
enable_logging: false # optional, default false
max_connections: 20 # optional, default 20
min_connections: 1 # optional, default 1
connect_timeout: 500 # optional, default 500 (ms)
idle_timeout: 500 # optional, default 500 (ms)
poll_interval_sec: 1 # optional, default 1
num_workers: 2 # optional, default 2
# reaper: # optional, disabled by default (opt-in)
# age_minutes: 10 # requeue jobs stuck in `processing` for longer than this
# interval_seconds: 60 # optional, default 60 — how often to sweep
# kind: Sqlite (same shape as Postgres)
queue:
kind: Sqlite
uri: sqlite://...
poll_interval_sec: 1 # optional, default 1 (own default fn)
# ...remaining keys identical to Postgres, including the optional `reaper`
```
| Key | Type | Required? | Notes |
|---|---|---|---|
| `queue.kind` | tag: `Redis` \| `Postgres` \| `Sqlite` | required | `queue.rs:6-14` |
| **Redis** (`RedisQueueConfig`, `queue.rs:16-28`) | | | |
| `queue.uri` | `String` | required | `queue.rs:18` |
| `queue.dangerously_flush` | `bool` | optional — `#[serde(default)]` | `queue.rs:20` |
| `queue.queues` | `Option<Vec<String>>` | optional | `queue.rs:24`. Declares named priority queues; first entry is most important |
| `queue.num_workers` | `u32` | optional, default `2` (`num_workers()`) | `queue.rs:26-27` |
| `queue.reaper` | `Option<ReaperConfig>` | optional, default `None` (disabled) | `queue.rs:29-31`. See below |
| **Postgres** (`PostgresQueueConfig`, `queue.rs:30-57`) | | | |
| `queue.uri` | `String` | required | `queue.rs:32` |
| `queue.dangerously_flush` | `bool` | optional, default `false` | `queue.rs:34-35` |
| `queue.enable_logging` | `bool` | optional, default `false` | `queue.rs:37-38` |
| `queue.max_connections` | `u32` | optional, default `20` (`db_max_conn()`) | `queue.rs:40-41` |
| `queue.min_connections` | `u32` | optional, default `1` (`db_min_conn()`) | `queue.rs:43-44` |
| `queue.connect_timeout` | `u64` (ms) | optional, default `500` (`db_connect_timeout()`) | `queue.rs:46-47` |
| `queue.idle_timeout` | `u64` (ms) | optional, default `500` (`db_idle_timeout()`) | `queue.rs:49-50` |
| `queue.poll_interval_sec` | `u32` | optional, default `1` (`pgq_poll_interval()`) | `queue.rs:52-53` |
| `queue.num_workers` | `u32` | optional, default `2` | `queue.rs:55-56` |
| `queue.reaper` | `Option<ReaperConfig>` | optional, default `None` (disabled) | `queue.rs:57-59`. See below |
| **Sqlite** (`SqliteQueueConfig`, `queue.rs:59-86`) | | | |
| — | identical fields to Postgres, including `queue.reaper` | | `poll_interval_sec` defaults via its own `sqlt_poll_interval()=1` (`queue.rs:81-82,92-94`); all other defaults are shared with Postgres via the same `db_*` helper functions |
| **`ReaperConfig`** (`queue.rs`, all three backends) | | | Opt-in visibility-timeout reaper: when set, the queue provider spawns a background task that periodically requeues jobs stuck in `processing` (e.g. after a worker crash), reusing the same logic as `cargo loco jobs requeue`. Leaving it unset keeps the previous behavior — no automatic requeue. |
| `queue.reaper.age_minutes` | `i64` | required (only if `reaper` is set) | Requeue jobs that have been `processing` for longer than this many minutes |
| `queue.reaper.interval_seconds` | `u64` | optional, default `60` (`default_reaper_interval_seconds()`) | How often the reaper sweeps for stale jobs |
## `cache`
`enum CacheConfig` — `src/config/cache.rs:4-16`, `#[serde(tag = "kind")]`. **Default variant: `Null`** (`#[default]`, `cache.rs:14-15`) — this is what `Config.cache`'s `#[serde(default)]` produces when the `cache` key is omitted entirely.
```yaml
cache:
kind: InMem # requires the `cache_inmem` feature
max_capacity: 33554432 # optional, default 33554432 bytes (32 MiB)
# --- or ---
cache:
kind: Redis # requires the `cache_redis` feature
uri: redis://... # required
max_size: 100 # required — max pool connections
# --- or (default) ---
cache:
kind: Null # no-op cache; used when `cache` key is omitted
```
| Key | Type | Required? | Notes |
|---|---|---|---|
| `cache.kind` | tag: `InMem` \| `Redis` \| `Null` | required if `cache` present | `cache.rs:6-16` |
| **InMem** (`InMemCacheConfig`, `cache.rs:18-22`) — feature-gated on `cache_inmem` | | | |
| `cache.max_capacity` | `u64` | optional, default `33554432` (`32 * 1024 * 1024`, `cache_in_mem_max_capacity()`) | `cache.rs:20-21,24-26` |
| **Redis** (`RedisCacheConfig`, `cache.rs:28-33`) — feature-gated on `cache_redis` | | | |
| `cache.uri` | `String` | required | `cache.rs:30` |
| `cache.max_size` | `u32` | required — max pool connections | `cache.rs:32` |
| **Null** | (no fields) | — | default no-op cache |
If the corresponding feature (`cache_inmem` / `cache_redis`) is not compiled in, that `kind` value will fail to deserialize.
## `initializers`, `settings`, `scheduler`
- `initializers: Option<BTreeMap<String, serde_json::Value>>` (`mod.rs:75,106`) — a free-form map consumed by app initializers (e.g. an `oauth2` initializer reading `initializers.oauth2`). Keys and shapes are defined by whichever initializer reads them, not by `Config` itself.
- `settings: Option<serde_json::Value>` (`mod.rs:88-89`) — arbitrary app-defined settings, deserialize your own type from `ctx.config.settings`.
- `scheduler: Option<scheduler::Config>` (`mod.rs:91`) — struct and keys owned by the scheduler area; not detailed on this page.
## Environment variables
| Variable | Purpose | Source |
|---|---|---|
| `LOCO_ENV` | Selects the active environment; highest precedence | `src/environment.rs:22,34` |
| `RAILS_ENV` | Falls back to this if `LOCO_ENV` is unset | `src/environment.rs:23,35` |
| `NODE_ENV` | Falls back to this if both above are unset | `src/environment.rs:24,36` |
| `LOCO_CONFIG_FOLDER` | Overrides the `config/` folder Loco loads from | `src/env_vars.rs:16`, read in `Environment::load` (`src/environment.rs:59-64`) |
| `LOCO_DATA` | Data folder path | `src/env_vars.rs:20` |
| `LOCO_POSTGRES_DB_OPTIONS` | Extra Postgres connection options (only meaningful with `with-db`) | `src/env_vars.rs:8` |
| `SCHEDULER_CONFIG` | Path to the scheduler config file | `src/env_vars.rs:18` |
| `RUST_BACKTRACE` | Effectively forced to `1` when `logger.pretty_backtrace: true` | logger init |
| any name passed to `get_env(name=.., default=..)` in a config YAML file | Injected into the rendered YAML by Tera's built-in `get_env` function at load time | `src/tera.rs:5-8` (Tera, not Loco code) |
Secrets (JWT `secret`, SMTP `password`, database `uri` credentials) are plain `String` fields with no dedicated vault type; the convention is to inject them via `{{ get_env(name="...") }}` at config-load time rather than hardcoding them in the YAML file.
@@ -0,0 +1,142 @@
+++
title = "Error model"
description = "The loco-rs Error enum, its HTTP status mapping, the ErrorDetail JSON body, and the wrap/msg/string/bt constructors."
date = 2021-05-01T18:10:00+00:00
updated = 2021-05-01T18:10:00+00:00
draft = false
weight = 8
sort_by = "weight"
template = "docs/page.html"
[extra]
lead = ""
toc = true
top = false
+++
`loco-rs` uses a single crate-wide error type. This page documents its shape, how it becomes an HTTP response, and the helpers for constructing it.
## `Result` and `Error`
- `pub type Result<T, E = Error> = std::result::Result<T, E>` (`src/lib.rs:52`).
- `pub use self::errors::Error` (`src/lib.rs:5`) — re-exported at the crate root and in `loco_rs::prelude`.
- `Error` is declared `#[non_exhaustive]` (`src/errors.rs:31`). Any `match Error { .. }` outside the crate **must** include a wildcard `_ =>` arm — the compiler enforces this, and the crate itself relies on it (see the status map below).
## Variant → HTTP status map
`impl IntoResponse for Error` (`src/controller/mod.rs:180-253`) matches on the error and produces `(StatusCode, ErrorDetail)`, then serializes `ErrorDetail` as the JSON response body. Only seven variants are matched explicitly; **every other variant** falls through to the trailing `_ =>` arm.
| Variant | Produced when | HTTP status | Response `ErrorDetail` |
|---|---|---|---|
| `NotFound` | Returned by the `not_found()` helper, or directly. | 404 | `{"error":"not_found","description":"Resource was not found"}` |
| `Unauthorized(String)` | Returned by the `unauthorized(msg)` helper, or directly. Also logs `tracing::warn!(err)` with the original message (the message itself is **not** sent to the client). | 401 | `{"error":"unauthorized","description":"You do not have permission to access this resource"}` |
| `CustomError(StatusCode, ErrorDetail)` | Constructed directly when the caller wants an arbitrary status and body. | passthrough — whatever `StatusCode` was supplied | passthrough — whatever `ErrorDetail` was supplied |
| `WithBacktrace { inner, backtrace }` | Wraps another error variant; produced by calling `.bt()` on an `Error` (backtrace is only captured when `RUST_BACKTRACE` is set — see Constructors below). Also prints the inner error (red, underlined) and the filtered backtrace to stdout via `backtrace::print_backtrace`. | 400 | `{"error":"Bad Request"}` (only the `error` reason is set, no `description`) |
| `BadRequest(String)` | Returned by the `bad_request(msg)` helper, or directly. | 400 | `{"error":"Bad Request","description":"<msg>"}` |
| `JsonRejection(JsonRejection)` | Axum's `Json` extractor rejects a malformed/missing request body (surfaced via the `Json<T>` wrapper's `#[from_request(rejection(Error))]`). Logs `tracing::debug!(err = err.body_text(), ...)`. | `err.status()` — axum's own rejection status (commonly 400/415/422) | `{"error":"Bad Request"}` |
| `Validation(ModelValidationErrors)` | A `validator`-crate validation failure converted `#[from] ModelValidationErrors`. | 400 | `{"errors": <serde_json::Value of the field errors>}` — note `error`/`description` are `None` here; only `errors` is populated |
| **everything else** (all other variants, current and future — this is what the `#[non_exhaustive]` catch-all covers) | Any of the ~28 remaining variants (`DB`, `Model`, `IO`, `Redis`, `Sqlx`, `Tera`, `YAML`, `Message`, `Any`, `InternalServerError`, etc. — see the full list below) | **500** | `{"error":"internal_server_error","description":"Internal Server Error"}` |
Every response, regardless of variant, is first logged at `tracing::error!` with `error.msg` / `error.details` fields before the match runs (`src/controller/mod.rs:184-202`).
## `ErrorDetail` — the response body shape
```rust
#[derive(Debug, Serialize)]
pub struct ErrorDetail {
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub errors: Option<serde_json::Value>,
}
```
(`src/controller/mod.rs:133-142`)
Constructors:
| Fn | Signature | Behavior |
|---|---|---|
| `ErrorDetail::new` | `new<T1, T2>(error: T1, description: T2) -> Self` (`:147`) | Sets `error`; sets `description` to `None` if the passed description is an empty string, `Some(..)` otherwise. `errors` is always `None`. |
| `ErrorDetail::with_reason` | `with_reason<T>(error: T) -> Self` (`:161`) | Sets only `error`; `description`/`errors` are `None`. |
The body is always wrapped in the crate's own `Json<T>` type (`src/controller/mod.rs:170-178`, a thin `axum::Json` newtype), not raw `axum::Json`.
## Constructor helpers on `Error`
`src/errors.rs:153-177`:
| Fn | Signature | Notes |
|---|---|---|
| `Error::wrap` | `wrap(err: impl std::error::Error + Send + Sync + 'static) -> Self` (`:154`) | Boxes any error into `Error::Any(Box::new(err))`. Does **not** call `.bt()` (backtrace capture is commented out). |
| `Error::msg` | `msg(err: impl std::error::Error + Send + Sync + 'static) -> Self` (`:158`) | Stringifies the error's `Display` into `Error::Message(err.to_string())`. Also does not capture a backtrace. |
| `Error::string` | `string(s: &str) -> Self` (`:162`) | Builds `Error::Message(s.to_string())` directly from a string slice. `#[must_use]`. |
| `Error::bt` | `bt(self) -> Self` (`:166`) | Captures `std::backtrace::Backtrace::capture()`. If the backtrace status is `Disabled` or `Unsupported` (i.e. `RUST_BACKTRACE` is unset), returns `self` unchanged — no allocation, no wrapping. Otherwise wraps `self` in `Error::WithBacktrace`. `#[must_use]`. |
Both `wrap`/`msg` are cheap-conversion helpers for turning a foreign `std::error::Error` into the crate's `Error` at a call site (e.g. inside a handler using `.map_err(Error::wrap)`); `bt` is the opt-in backtrace wrapper used internally (e.g. the hand-written `From<serde_json::Error>` impl at `src/errors.rs:24-28` does `Self::JSON(val).bt()`).
## Controller helper functions
Free functions in `src/controller/mod.rs` for the common HTTP-facing variants, each returning `Result<U>` (i.e. always `Err(..)`):
| Fn | Signature | file:line |
|---|---|---|
| `unauthorized` | `unauthorized<T: Into<String>, U>(msg: T) -> Result<U>` | `:112` |
| `bad_request` | `bad_request<T: Into<String>, U>(msg: T) -> Result<U>` | `:121` |
| `not_found` | `not_found<T>() -> Result<T>` | `:130` |
All three are re-exported from `loco_rs::prelude`.
## Full variant list
The complete `#[non_exhaustive] enum Error` (`src/errors.rs:32-151`), with feature gates where present:
| Variant | Feature gate |
|---|---|
| `WithBacktrace { inner: Box<Self>, backtrace: Box<Backtrace> }` | — |
| `Message(String)` | — |
| `QueueProviderMissing` | — |
| `TaskNotFound(String)` | — |
| `Scheduler(#[from] crate::scheduler::Error)` | — |
| `Axum(#[from] axum::http::Error)` | — |
| `Tera(#[from] tera::Error)` | — |
| `JSON(serde_json::Error)` | — (hand-rolled `From`, not `#[from]`, so it can call `.bt()`) |
| `JsonRejection(#[from] JsonRejection)` | — |
| `YAMLFile(#[source] serde_yaml::Error, String)` | — |
| `YAML(#[from] serde_yaml::Error)` | — |
| `EmailSender(#[from] lettre::error::Error)` | — |
| `Smtp(#[from] smtp::Error)` | — |
| `Worker(String)` | — |
| `IO(#[from] std::io::Error)` | — |
| `DB(#[from] sea_orm::DbErr)` | `with-db` |
| `ParseAddress(#[from] AddressError)` | — |
| `Unauthorized(String)` | — |
| `NotFound` | — |
| `BadRequest(String)` | — |
| `CustomError(StatusCode, ErrorDetail)` | — |
| `InternalServerError` | — |
| `InvalidHeaderValue(#[from] InvalidHeaderValue)` | — |
| `InvalidHeaderName(#[from] InvalidHeaderName)` | — |
| `InvalidMethod(#[from] InvalidMethod)` | — |
| `Model(#[from] crate::model::ModelError)` | `with-db` |
| `Redis(#[from] redis::RedisError)` | `worker_redis` |
| `Sqlx(#[from] sqlx::Error)` | `worker` |
| `Storage(#[from] crate::storage::StorageError)` | — |
| `Cache(#[from] crate::cache::CacheError)` | — |
| `Generators(#[from] loco_gen::Error)` | `debug_assertions` |
| `VersionCheck(#[from] depcheck::VersionCheckError)` | — |
| `Any(#[from] Box<dyn std::error::Error + Send + Sync>)` | — |
| `Validation(#[from] ModelValidationErrors)` | — |
| `AxumFormRejection(#[from] axum::extract::rejection::FormRejection)` | — |
## Removed variants (breaking as of the 1.0 error-enum narrowing)
Commit `4a4a84ee` ("narrow the Error enum — drop 4 low-value/leaky variants") removed four variants that are **confirmed absent** from current source (`src/errors.rs`):
- `EnvVar(#[from] std::env::VarError)`
- `Hash(String)`
- `SemVer(#[from] semver::Error)`
- `TaskJoinError(#[from] tokio::task::JoinError)`
Any code that constructs or `match`es these four no longer compiles. Combined with `#[non_exhaustive]`, every downstream `match Error { .. }` must carry a `_ =>` arm — this was already required before the removal, but the removal is a reminder that new/removed variants must not break exhaustive matches, and user code must not attempt to rely on exhaustiveness.
@@ -0,0 +1,64 @@
+++
title = "Feature flags"
description = "The complete loco-rs Cargo feature matrix: defaults, what each flag enables, and how flags interact."
date = 2021-05-01T18:10:00+00:00
updated = 2021-05-01T18:10:00+00:00
draft = false
weight = 4
sort_by = "weight"
template = "docs/page.html"
[extra]
lead = ""
toc = true
top = false
+++
`loco-rs` gates most of its optional functionality behind Cargo features, declared in root `Cargo.toml:27-64`. This page is the exhaustive matrix — every flag, its default state, what it turns on, and how flags interact with each other and with `cargo loco`.
## Defaults
```toml
default = ["auth", "cli", "with-db", "cache_inmem", "worker"]
```
A plain `loco-rs = "..."` dependency (no `default-features = false`) pulls in JWT auth, the `cargo loco` CLI, Sea-ORM database support, the in-memory cache, and the Postgres/SQLite-backed queue workers. The Redis-backed queue worker (`worker_redis`) is *not* in the default set — opt in explicitly if your app uses a Redis queue.
## The matrix
| Flag | Default | Enables (deps / sub-features) | Purpose |
|---|---|---|---|
| `auth` | **ON** | `dep:jsonwebtoken`, `jsonwebtoken/rust_crypto` | JWT authentication. Selects `jsonwebtoken`'s pure-Rust `rust_crypto` backend (jsonwebtoken 10 no longer bundles a crypto backend by default), so the flag stays self-contained and needs no C toolchain, even when enabled alone with `default-features = false`. |
| `cli` | **ON** | `dep:clap` | Enables the `cargo loco` runtime CLI (`src/cli.rs`). |
| `with-db` | **ON** | `dep:sea-orm`, `dep:sea-orm-migration`, `dep:sqlx`, `loco-gen/with-db` | Sea-ORM 2.0.0-rc database support. Gates the `db` CLI subcommand and the DB-dependent generators (`model`, `migration`, `scaffold`). |
| `testing` | off | `dep:axum-test`, `dep:scraper`, `dep:tree-fs` | Test harness utilities. Also the feature set built for docs.rs (`[package.metadata.docs.rs] features = ["testing"]`, `Cargo.toml:211-212`) and used by the crate's own `dev-dependencies`. |
| `cache_inmem` | **ON** | `dep:moka` | In-memory cache backend. |
| `cache_redis` | off | `dep:bb8-redis`, `dep:bb8` | Redis-backed cache pool. |
| `worker` | **ON** | `dep:sqlx`, `dep:ulid` | Background job queue/workers, Postgres and SQLite backends. Which one runs is chosen at runtime by `queue.kind` in config (`Postgres` or `Sqlite`), not by a separate feature per database. |
| `worker_redis` | off | `worker`, `dep:redis` | Adds the Redis-backed queue backend on top of `worker` (implies it). Enable this if your app's `queue.kind` is `Redis`. |
| `all_storage` | off | `storage_aws_s3` + `storage_azure` + `storage_gcp` | Umbrella flag — turns on every cloud storage backend at once. |
| `storage_aws_s3` | off | `opendal/services-s3` | AWS S3 storage backend. |
| `storage_azure` | off | `opendal/services-azblob` | Azure Blob storage backend. |
| `storage_gcp` | off | `opendal/services-gcs` | Google Cloud Storage backend. |
| `embedded_assets` | off | (empty — build-time flag) | Embeds the app's `assets/` directory into the compiled binary and swaps the view-engine's asset-loading path accordingly, instead of reading assets from disk at runtime. |
Source: root `Cargo.toml:27-64`.
## Interactions
- **`worker` unlocks the `jobs` subcommand.** `cargo loco jobs` (and its `cancel`/`tidy`/`purge`/`dump`/`import`/`requeue` subcommands) is compiled whenever the `worker` feature is enabled (`#[cfg(feature = "worker")]`, `src/cli.rs:27`). The same cfg gates the `JobStatus` import used by the jobs machinery. Since `worker_redis` implies `worker`, the `jobs` CLI is available for any queue backend — Redis, Postgres, or SQLite.
- **`debug_assertions` (not a Cargo feature) gates `generate` and `db entities`.** The `cargo loco generate` subcommand and the `db entities` subcommand are compiled only in debug builds (`#[cfg(debug_assertions)]`, `src/cli.rs:29, 140, 173`). They are unavailable in `--release` builds regardless of which Cargo features are on.
- **`all_storage` is a pure umbrella.** It has no dependency of its own; it just turns on `storage_aws_s3`, `storage_azure`, and `storage_gcp` together.
- **`auth` selects `jsonwebtoken/rust_crypto`.** Because jsonwebtoken 10 unbundled its crypto backend, `auth` explicitly enables the `rust_crypto` sub-feature so JWT support keeps working without requiring a system C toolchain (e.g. OpenSSL).
- **`with-db` is a prerequisite, not an implication.** Enabling `worker` does not itself pull in `with-db`; the two are independent flags that happen to share the `sqlx` dependency.
- **The queue backend is chosen at runtime, not by feature flag.** `worker` builds in the Postgres and SQLite queue providers; which one actually runs is decided by `queue.kind` (`Postgres` or `Sqlite`) in your app config. `worker_redis` adds the Redis provider, selected the same way with `queue.kind: Redis`. See [Choose a queue backend](@/docs/how-to/choose-queue-backend.md).
## Disabling defaults
To opt out of the default set (e.g. a DB-less app), depend with `default-features = false` and re-list only the flags you want:
```toml
loco-rs = { version = "...", default-features = false, features = ["cli"] }
```
This is the pattern the `loco new` generator itself uses when the app is created without a database (see the CLI reference's app-creation flow): it emits `default-features = false` with `features = ["cli"]`, plus `worker_redis` if a Redis-backed queue was selected, or `worker` if a Postgres- or SQLite-backed queue was selected.
@@ -0,0 +1,195 @@
+++
title = "Generators & field types"
description = "Every `cargo loco generate <kind>` component, its exact CLI syntax and output files, plus the complete field-type mini-language used by model/migration/scaffold generators."
date = 2021-05-01T18:10:00+00:00
updated = 2021-05-01T18:10:00+00:00
draft = false
weight = 3
sort_by = "weight"
template = "docs/page.html"
[extra]
lead = ""
toc = true
top = false
+++
`cargo loco generate <kind>` (alias `cargo loco g <kind>`) scaffolds application code from templates baked into the `loco-gen` crate. The `generate` subcommand itself is compiled only under `#[cfg(debug_assertions)]` (`src/cli.rs:140`) — it is available in ordinary (dev/debug) builds but is compiled out of `--release` binaries. The kinds that touch the database (`model`, `migration`, `scaffold`) are additionally gated behind the `with-db` Cargo feature (on by default) — see [feature flags](@/docs/reference/feature-flags.md).
This page is the exhaustive dictionary of generator kinds and the field-type mini-language (`name:type`) they all share. It transcribes `loco-gen/src/lib.rs` (the `Component` enum), `loco-gen/src/column.rs` (the field-type/column model), `loco-gen/src/infer.rs` (naming/inflection conventions), and `src/cli.rs` (the CLI surface), re-verified against `HEAD`.
## Generator kinds
`Component` enum: `loco-gen/src/lib.rs:237`. CLI subcommand enum `ComponentArg`: `src/cli.rs:175` (also gated `#[cfg(debug_assertions)]`).
| Kind | CLI syntax | Feature gate | Notes |
|---|---|---|---|
| **model** | `cargo loco generate model <name> [field:type ...] [--without-tz]` | `with-db` | Creates a Sea-ORM entity + model file + migration + tests. `lib.rs:239`, `cli.rs:197` |
| **migration** | `cargo loco generate migration <name> [field:type ...] [--without-tz]` | `with-db` | Standalone migration file; no model/entity. Name-based operation inference (create/add/remove/join) — see [Migration-name inference](#migration-name-inference). `lib.rs:250`, `cli.rs:250` |
| **scaffold** | `cargo loco generate scaffold <name> [field:type ...] [--without-tz]` | `with-db` | Adaptive full CRUD: entity, migration, JSON API controller, routes, tests — plus typed React hooks/pages when the app has a `frontend/`. No kind flag. `lib.rs:261`, `cli.rs:268` |
| **controller** | `cargo loco generate controller <name> [action ...]` | none | JSON API controller + routes + tests only — no model/migration, no kind flag. `lib.rs:274`, `cli.rs:307` |
| **task** | `cargo loco generate task <name>` | none | One-off/CLI task stub, registered in `src/tasks/mod.rs`. `lib.rs:284` |
| **scheduler** | `cargo loco generate scheduler` | none | Writes `config/scheduler.yaml`. `lib.rs:288` |
| **worker** | `cargo loco generate worker <name>` | none | Background worker stub in `src/workers/`, registered in `src/workers/mod.rs`. `lib.rs:289` |
| **mailer** | `cargo loco generate mailer <name>` | none | Mailer struct in `src/mailers/<name>.rs` + embedded `welcome/{subject,html,text}.t` templates. `lib.rs:293` |
| **data** | `cargo loco generate data <name>` | none | Data-loader struct + a `data/<name>/data.json` static file. `lib.rs:297` |
| **deployment** | `cargo loco generate deployment <docker\|nginx>` | none | `kind` is a **positional** value, not a `--kind` flag (see [Deployment](#deployment)). `lib.rs:301` |
| **override** | `cargo loco generate override [template_path] [--info]` | none | Copies built-in templates into the app's `.loco-templates/` so you can customize them. `cli.rs:373` |
### Model, migration, scaffold
All three take `name` and a list of `field:type` pairs (the [field-type mini-language](#field-type-mini-language) below), and accept `--without-tz` to omit the `created_at`/`updated_at` timestamp columns. `created_at`, `updated_at`, `create_at`, `update_at` field names are silently skipped if you pass them explicitly (`IGNORE_FIELDS`, `loco-gen/src/model.rs:16`) — they're generated automatically.
```bash
# empty model
cargo loco generate model posts
# model with fields
cargo loco generate model posts title:string! content:text
# model with a belongs-to reference (adds a `director_id` FK column on `movies`)
cargo loco generate model movies long_title:string director:references award:references:prize_id
# migration adding columns to an existing table
cargo loco generate migration AddNameAndAgeToUsers name:string age:int
# scaffold (model + controller + routes + tests; adds React hooks/pages when a frontend/ exists)
cargo loco generate scaffold posts title:string! user:references
```
After generating a `migration`, apply it and regenerate entities: `cargo loco db migrate && cargo loco db entities`.
### Scaffold / controller kind (adaptive — no kind flag)
There is **no kind flag**. Both generators are adaptive:
- `controller` always generates a **JSON API** controller.
- `scaffold` generates a JSON API controller and, when the app has a `frontend/`, **also** emits typed React hooks and pages for the resource.
The pre-1.0 `--api`/`--html`/`--htmx`/`-k`/`--kind` flags and the `ScaffoldKind` enum were **removed** in 1.0. For back-compat the CLI still *accepts* `--api` (a no-op) and `--html`/`--htmx` (which now error with a pointer to the React SPA frontend), so pre-1.0 commands don't hit a clap `unexpected argument` error.
### Deployment
`DeploymentKind` in `loco-gen` carries generator data (`loco-gen/src/lib.rs:224`):
```rust
pub enum DeploymentKind {
Docker { copy_paths: Vec<PathBuf>, is_client_side_rendering: bool },
Nginx { host: String, port: i32 },
}
```
but the **CLI-facing** enum (`src/cli.rs:555`) is a plain `clap::ValueEnum { Docker, Nginx }` taken as a positional argument — `copy_paths`/`is_client_side_rendering`/`host`/`port` are derived from the app's own `config/*.yaml` and filesystem at generation time (`src/cli.rs:562-596`), not passed on the command line:
```bash
cargo loco generate deployment docker # writes Dockerfile, .dockerignore
cargo loco generate deployment nginx # writes nginx/default.conf
```
### Override
Copies a built-in `.t` template (or a whole folder) into the local `.loco-templates/` directory (`DEFAULT_LOCAL_TEMPLATE`, `loco-gen/src/template.rs:8`) so subsequent generation runs use your copy instead of the built-in one. Delete the local copy to revert to the built-in template.
```bash
# list all overridable templates
cargo loco generate override
# override one file
cargo loco generate override scaffold/api/controller.t
# override every template under a folder
cargo loco generate override scaffold/htmx
# preview what --info would show for a folder, without copying
cargo loco generate override scaffold/htmx --info
# override everything
cargo loco generate override .
```
## Field-type mini-language
Every `field:type` argument to `model`/`migration`/`scaffold` is resolved in `loco-gen/src/column.rs` — the `parse_column` function and the `ScalarType` enum. Transcribed in full below (re-verified against `HEAD`).
**Suffix convention:** no suffix = nullable `Option<T>`; **`!`** = required (non-null); **`^`** = unique (implies non-null). Not every base type has all three variants — `bool`, `tstz`, and `json` have no `^` (unique) form.
**1.0 change:** `int` now maps to **`i64` / `BIGINT`** (`big_integer`), matching the framework's i64 primary keys. Pre-1.0, `int` was `i32`. `unsigned` is an alias of `big_unsigned` (also i64). Use `small_int`/`small_unsigned` if you specifically need 16-bit columns.
| `type` (suffix variants) | Rust type | `ColType` variant | Arity |
|---|---|---|---|
| `uuid` / `uuid!` / `uuid^` | `Option<Uuid>` / `Uuid` / `Uuid` | `UuidNull` / `Uuid` / `UuidUniq` | — |
| `string` / `string!` / `string^` | `Option<String>` / `String` / `String` | `StringNull` / `String` / `StringUniq` | — |
| `text` / `text!` / `text^` | `Option<String>` / `String` / `String` | `TextNull` / `Text` / `TextUniq` | — |
| `small_int` / `!` / `^` | `Option<i16>` / `i16` / `i16` | `SmallIntegerNull` / `SmallInteger` / `SmallIntegerUniq` | — |
| `small_unsigned` / `!` / `^` | `Option<i16>` / `i16` / `i16` | `SmallUnsignedNull` / `SmallUnsigned` / `SmallUnsignedUniq` | — |
| `int` / `!` / `^` **(⚠ i64, was i32 pre-1.0)** | `Option<i64>` / `i64` / `i64` | `BigIntegerNull` / `BigInteger` / `BigIntegerUniq` | — |
| `big_int` / `!` / `^` (alias of `int`) | `Option<i64>` / `i64` / `i64` | `BigIntegerNull` / `BigInteger` / `BigIntegerUniq` | — |
| `unsigned` / `!` / `^` (alias of `big_unsigned`) | `Option<i64>` / `i64` / `i64` | `BigUnsignedNull` / `BigUnsigned` / `BigUnsignedUniq` | — |
| `big_unsigned` / `!` / `^` | `Option<i64>` / `i64` / `i64` | `BigUnsignedNull` / `BigUnsigned` / `BigUnsignedUniq` | — |
| `float` / `!` / `^` | `Option<f32>` / `f32` / `f32` | `FloatNull` / `Float` / `FloatUniq` | — |
| `double` / `!` / `^` | `Option<f64>` / `f64` / `f64` | `DoubleNull` / `Double` / `DoubleUniq` | — |
| `decimal` / `!` / `^` | `Option<Decimal>` / `Decimal` / `Decimal` | `DecimalNull` / `Decimal` / `DecimalUniq` | — |
| `decimal_len` / `!` / `^` | `Option<Decimal>` / `Decimal` / `Decimal` | `DecimalLenNull` / `DecimalLen` / `DecimalLenUniq` | **2** (precision, scale) |
| `bool` / `!` (no `^`) | `Option<bool>` / `bool` | `BooleanNull` / `Boolean` | — |
| `tstz` / `!` (no `^`) | `Option<DateTimeWithTimeZone>` / `DateTimeWithTimeZone` | `TimestampWithTimeZoneNull` / `TimestampWithTimeZone` | — |
| `date` / `!` / `^` | `Option<Date>` / `Date` / `Date` | `DateNull` / `Date` / `DateUniq` | — |
| `date_time` / `!` / `^` | `Option<DateTime>` / `DateTime` / `DateTime` | `DateTimeNull` / `DateTime` / `DateTimeUniq` | — |
| `json` / `!` (no `^`) | `Option<serde_json::Value>` / `serde_json::Value` | `JsonNull` / `Json` | — |
| `jsonb` / `!` / `^` | `Option<serde_json::Value>` / `serde_json::Value` / `serde_json::Value` | `JsonBinaryNull` / `JsonBinary` / `JsonBinaryUniq` | — |
| `blob` / `!` / `^` | `Option<Vec<u8>>` / `Vec<u8>` / `Vec<u8>` | `BlobNull` / `Blob` / `BlobUniq` | — |
| `money` / `!` / `^` | `Option<Decimal>` / `Decimal` / `Decimal` | `MoneyNull` / `Money` / `MoneyUniq` | — |
| `binary_len` / `!` / `^` | `Option<Vec<u8>>` / `Vec<u8>` / `Vec<u8>` | `BinaryLenNull` / `BinaryLen` / `BinaryLenUniq` | **1** (length) |
| `var_binary` / `!` / `^` | `Option<Vec<u8>>` / `Vec<u8>` / `Vec<u8>` | `VarBinaryNull` / `VarBinary` / `VarBinaryUniq` | **1** (length) |
| `array` / `!` / `^` | `Option<Vec<T>>` (see below) | `array_null` / `array` / `array_uniq` (generator emits `ColType::array(ArrayColType::…)` etc.) | **1** (element type) |
`decimal`, `money`, and `decimal_len` all resolve to the same Rust type (`rust_decimal::Decimal`); the `ColType` distinguishes the SQL representation.
### Arrays
`array`/`array!`/`array^` take one parameter — the element type — written as a second colon segment: `tags:array:string`, `scores:array!:int`. Valid element types (per `array_inner_from_name` in `loco-gen/src/column.rs`) are `string`, `int`, `big_int`, `float`, `double`, `bool`, generating `Option<Vec<T>>` where `T` is:
| element | Rust `T` |
|---|---|
| `string` | `String` |
| `int` | `i64` |
| `big_int` | `i64` |
| `float` | `f32` |
| `double` | `f64` |
| `bool` | `bool` |
**Note:** array element types are consistent with the scalars in 1.0 — `array:int` generates a 64-bit `BigInt` array (element type `i64`), matching the scalar `int` → `i64` change, per `array_col_type_name` in `loco-gen/src/column.rs` (`ScalarType::Int | ScalarType::BigInt => "BigInt"`). A `column.rs` unit test pins this, asserting `array:big_int!` → `array(ArrayColType::BigInt)`.
### References (belongs-to foreign keys)
A field typed `references` (not in the table above — handled separately in `loco-gen/src/infer.rs:29-54`) generates a belongs-to foreign-key column instead of a regular column:
| Syntax | Meaning |
|---|---|
| `name:references` | Required FK to the `names` table, column `name_id` |
| `name:references:custom_id` | Required FK, explicit FK column name `custom_id` |
| `name:references?` | Nullable FK to the `names` table |
| `name:references?:custom_id` | Nullable FK, explicit FK column name |
Example: `director:references award:references:prize_id` on a `movies` model adds a required `director_id` FK to `directors` and a required `prize_id` FK to `awards`.
## Migration-name inference
For `cargo loco generate migration <Name> ...`, `guess_migration_type` (`loco-gen/src/infer.rs:56`) pattern-matches the **snake_cased** migration name to decide what to scaffold:
| Name pattern | Inferred operation |
|---|---|
| `Create<Table>` | `CreateTable` |
| `Add<ref>RefTo<Table>` | `AddReference` |
| `Add<Columns>To<Table>` | `AddColumns` |
| `Remove<Columns>From<Table>` | `RemoveColumns` |
| `CreateJoinTable<A>And<B>` | `CreateJoinTable` (join table `a_b`, both sides singularized) |
| anything else | `Empty` (blank migration stub) |
## Inflection conventions (`cruet` vs `heck`)
Documented at `loco-gen/src/infer.rs:1-14`: **`cruet`** is used *only* for pluralization/singularization (`to_plural`/`to_singular` — table names); **`heck`** is used for *all* case conversion (snake_case columns, PascalCase entity/struct names). The two crates disagree on acronym/digit casing (e.g. `i32`→`i_32` under `cruet` vs `i32` under `heck`; `HTTPServer`→`Httpserver` under `cruet` vs `HttpServer` under `heck`), so mixing them corrupts generated identifiers. The one deliberate exception: `guess_migration_type` normalizes the raw migration command name with `cruet`'s snake-casing before splitting it into keyword parts, because the parser is tuned to that specific behavior.
## Related reference pages
- [Feature flags](@/docs/reference/feature-flags.md) — `with-db` and the other Cargo features gating generators.
- Schema/`ColType` migration DSL and query pagination reference pages cover the migration-writer side (`add_column`, `add_reference`, `ColType`) in depth.
@@ -0,0 +1,113 @@
+++
title = "Hooks trait"
description = "The complete Hooks trait surface: every required and provided method, its signature, and when it runs."
date = 2021-05-01T18:10:00+00:00
updated = 2021-05-01T18:10:00+00:00
draft = false
weight = 7
sort_by = "weight"
template = "docs/page.html"
[extra]
lead = ""
toc = true
top = false
+++
`Hooks` (`#[async_trait]`, `Send`, `src/app.rs:281-443`) is the single trait every Loco application implements — typically on a `struct App` in `src/app.rs` — to wire routing, workers, tasks, database seed/truncate, and lifecycle callbacks. `cargo loco generate` scaffolds an `impl Hooks for App` for you; this page is the exhaustive reference for what that `impl` can and must contain.
## Required methods
No default implementation. The trait will not compile without these.
| Method | Signature | Purpose |
|---|---|---|
| `app_name` | `fn app_name() -> &'static str` (`:296`) | Returns the app's crate name (conventionally `env!("CARGO_CRATE_NAME")`). |
| `boot` | `async fn boot(mode: StartMode, environment: &Environment, config: Config) -> Result<BootResult>` (`:323`) | Initializes and boots the application for the given `StartMode` and `Environment`. Typically delegates to `create_app::<Self, Migrator>(mode, environment, config)` (with DB) or `create_app::<Self>(mode, environment, config)` (without DB). |
| `routes` | `fn routes(_ctx: &AppContext) -> AppRoutes` (`:413`) | Defines the application's routing configuration. |
| `connect_workers` | `async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()>` (`:422`) | Registers background-job workers against the provided `Queue`. |
| `register_tasks` | `fn register_tasks(tasks: &mut Tasks)` (`:425`) | Registers custom `cargo loco task` entries with the `Tasks` registry. |
| `truncate` | `#[cfg(feature = "with-db")] async fn truncate(_ctx: &AppContext) -> Result<()>` (`:433`) | Truncates application tables. Invoked when `config.database.dangerously_truncate` is `true`; useful before tests. |
| `seed` | `#[cfg(feature = "with-db")] async fn seed(_ctx: &AppContext, path: &Path) -> Result<()>` (`:437`) | Seeds the database with initial data from `path`. |
`truncate` and `seed` only exist on the trait when the `with-db` Cargo feature is enabled.
## Provided methods
Have a default implementation; override to change behavior.
| Method | Signature | Default behavior |
|---|---|---|
| `app_version` | `fn app_version() -> String` (`:285`) | Returns `"dev".to_string()`. |
| `serve` | `async fn serve(app: AxumRouter, ctx: &AppContext, serve_params: &ServeParams) -> Result<()>` (`:331-351`) | Binds a `tokio::net::TcpListener` on `serve_params.binding:serve_params.port` and runs `axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())` with graceful shutdown; on shutdown, calls `Self::on_shutdown(&ctx)`. |
| `init_logger` | `fn init_logger(_ctx: &AppContext) -> Result<bool>` (`:360-362`) | Returns `Ok(false)`, meaning Loco initializes its own tracing/logging stack. |
| `load_config` | `async fn load_config(env: &Environment) -> Result<Config>` (`:368-370`) | Returns `env.load()` — the standard `config/{env}.yaml` (+ `.local.yaml` overlay) loading path. |
| `before_routes` | `async fn before_routes(_ctx: &AppContext) -> Result<AxumRouter<AppContext>>` (`:378`) | Returns `Ok(AxumRouter::new())` — an empty router. |
| `after_routes` | `async fn after_routes(router: AxumRouter, _ctx: &AppContext) -> Result<AxumRouter>` (`:388`) | Returns `Ok(router)` unchanged. |
| `initializers` | `async fn initializers(_ctx: &AppContext) -> Result<Vec<Box<dyn Initializer>>>` (`:395`) | Returns `Ok(vec![])` — no initializers. |
| `middlewares` | `fn middlewares(ctx: &AppContext) -> Vec<Box<dyn MiddlewareLayer>>` (`:401-403`) | Returns `middleware::default_middleware_stack(ctx)`. |
| `before_run` | `async fn before_run(_app_context: &AppContext) -> Result<()>` (`:408`) | Returns `Ok(())` — no-op. |
| `after_context` | `async fn after_context(ctx: AppContext) -> Result<AppContext>` (`:416`) | Returns `Ok(ctx)` unchanged. |
| `on_shutdown` | `async fn on_shutdown(_ctx: &AppContext)` (`:442`) | No-op. |
## Override points
The methods below are the least-documented parts of `Hooks`. Each entry states exactly what overriding changes.
### `init_logger` — own your tracing stack
```rust
fn init_logger(_ctx: &AppContext) -> Result<bool>
```
Runs once during boot, before the rest of the app context is wired up. Returning `Ok(true)` tells Loco **not** to initialize its own logger — the app is then responsible for setting up a complete tracing/logging stack itself. Returning `Ok(false)` (the default) leaves Loco's built-in logger in place.
### `load_config` — replace the config loader
```rust
async fn load_config(env: &Environment) -> Result<Config>
```
Runs during boot to produce the `Config` passed into `boot`. The default is `env.load()` (the standard `config/{env}.yaml` file resolution). Override to load configuration from a different source (e.g. a remote config service) while still returning a `Config`.
### `after_context` — post-process `AppContext`
```rust
async fn after_context(ctx: AppContext) -> Result<AppContext>
```
Runs after `AppContext` has been fully constructed (db, cache, storage, mailer, queue provider all present) but before routes are built. Takes `ctx` by value and must return a (possibly modified) `AppContext` — the only hook that lets you replace fields on the context itself.
### `before_run` — pre-run resource loading
```rust
async fn before_run(_app_context: &AppContext) -> Result<()>
```
Runs before the app starts serving/running (applies to the server and to other run modes such as tasks/jobs, not only HTTP serve). Use it to load or warm resources that don't belong on `AppContext` itself.
### `serve` — the HTTP serve loop
```rust
async fn serve(app: AxumRouter, ctx: &AppContext, serve_params: &ServeParams) -> Result<()>
```
Runs when the app is started in server mode. The default binds a `TcpListener` and calls `axum::serve` with `app.into_make_service_with_connect_info::<SocketAddr>()` — the `connect_info` layer is required for `remote_ip`/client-address extraction in controllers — wrapped in graceful shutdown that calls `on_shutdown`. Override only to change the transport/serve mechanics (e.g. custom TLS termination); overriding without preserving `into_make_service_with_connect_info` will break connect-info extraction.
### `app_version` — composite version string
```rust
fn app_version() -> String
```
Called wherever Loco reports its version (e.g. `cargo loco version`, `/_ping`/`/_health` style diagnostics). Default is the literal `"dev"`; override to compose a real version string, e.g. from `CARGO_PKG_VERSION` plus a git SHA.
## `boot` signature note
`boot`'s second parameter is `environment: &Environment` — a reference to the `Environment` enum, **not** `&str`:
```rust
async fn boot(mode: StartMode, environment: &Environment, config: Config) -> Result<BootResult>
```
Some existing docs and snippets show `environment: &str`; that signature is stale (the rustdoc example inside `src/app.rs:308` and `:315` itself still shows `&str` and should not be copied). `src/controller/mod.rs:47` is a correct reference example using `&Environment`.
@@ -0,0 +1,250 @@
+++
title = "Middleware catalog"
description = "Every built-in middleware: config key, struct, default state, and knobs."
date = 2026-07-03T00:00:00+00:00
updated = 2026-07-03T00:00:00+00:00
draft = false
weight = 5
sort_by = "weight"
template = "docs/page.html"
[extra]
lead = ""
toc = true
top = false
+++
Loco ships 13 built-in middlewares, all implementing the `MiddlewareLayer`
trait (`src/controller/middleware/mod.rs:46-72`). Each is configured under
`server.middlewares.<key>` in your environment YAML (`src/config/server.rs:44`)
and is optional (`Option<T>`) — omit the key entirely to get the framework's
own default; supply the key (even as `{}`) to take over its `serde` defaults
instead (see the callout below).
## The `MiddlewareLayer` trait
```rust
pub trait MiddlewareLayer {
fn name(&self) -> &'static str;
fn is_enabled(&self) -> bool { true } // default
fn config(&self) -> serde_json::Result<serde_json::Value>;
fn apply(&self, app: AXRouter<AppContext>) -> Result<AXRouter<AppContext>>;
}
```
`src/controller/middleware/mod.rs:46-72`.
> **Config-key-present flips the default.** For every middleware below whose
> "default enabled" is `true` (`catch_panic`, `etag`, `logger`, `request_id`,
> and — outside Production — `fallback`), that default comes from
> `default_middleware_stack`'s own fallback value, used only when the key is
> **absent** from config (`Option` is `None`,
> `src/controller/middleware/mod.rs:76-171`). If you write the key at all —
> even as an empty mapping (`etag: {}`) — the struct's own `#[serde(default)]`
> takes over, which resolves `enable` to `false` unless you set
> `enable: true` explicitly. In short: don't write a middleware's key in
> config unless you intend to also set `enable`.
## Stack ordering: build order vs. request order (LIFO)
`default_middleware_stack(ctx)` (`mod.rs:76-171`) returns middlewares as a
`Vec` in the coding order below (limit_payload → … → powered_by).
`AppRoutes::to_router` applies them in that same order, one `app.layer(...)`
call at a time (`src/controller/app_routes.rs:305-309`). Axum's
`Router::layer` wraps the *existing* router with each new layer as the
**outer** layer, so:
> "the LAST middleware is the FIRST to meet the outside world (a user request
> starting), or 'LIFO' order" — `src/controller/app_routes.rs:283-286`.
So an inbound request actually passes through the stack in the **reverse**
of the table order — `powered_by` first, `limit_payload` last (right before
the route handler) — and the response flows back out the opposite way.
## Full middleware set
Table order = coding/config order (`default_middleware_stack`, `mod.rs:80-169`).
### 1. `limit_payload`
- **Config key:** `limit_payload` · **Struct:** `limit_payload::LimitPayload` (`limit_payload.rs:26`)
- **Default:** effectively always enabled — `is_enabled()` is hard-coded `true` (`limit_payload.rs:70-72`); there is no `enable` field. To turn it off, set `body_limit: disable`.
- **Purpose:** caps the request body size via Axum's `DefaultBodyLimit`.
- **Knobs:**
| Name | Type | Default |
|---|---|---|
| `body_limit` | `DefaultBodyLimitKind` (`"<size>"` e.g. `"5mb"`, or `"disable"`) | `2mb` (2,000,000 bytes) — `limit_payload.rs:43-45` |
### 2. `cors`
- **Config key:** `cors` · **Struct:** `cors::Cors` (`cors.rs:19-42`)
- **Default:** **disabled**.
- **Purpose:** Cross-Origin Resource Sharing headers.
- **Knobs:**
| Name | Type | Default |
|---|---|---|
| `enable` | `bool` | `false` |
| `allow_origins` | `Vec<String>` | `["*"]` |
| `allow_headers` | `Vec<String>` | `["*"]` |
| `allow_methods` | `Vec<String>` | `["*"]` |
| `expose_headers` | `Vec<String>` | `[]` (empty) |
| `allow_credentials` | `bool` | `false` |
| `max_age` | `Option<u64>` (seconds) | `None` |
| `vary` | `Vec<String>` | `["origin", "access-control-request-method", "access-control-request-headers"]` |
> The field is `expose_headers` (plural) — `cors.rs:32`.
### 3. `catch_panic`
- **Config key:** `catch_panic` · **Struct:** `catch_panic::CatchPanic { enable }` (`catch_panic.rs:18-22`)
- **Default:** **enabled**.
- **Purpose:** catches panics in request handlers, logs them, and returns `500 Internal Server Error` instead of dropping the connection.
- **Knobs:**
| Name | Type | Default |
|---|---|---|
| `enable` | `bool` | `true` (framework default when key absent) |
### 4. `etag`
- **Config key:** `etag` · **Struct:** `etag::Etag { enable }` (`etag.rs:27-31`)
- **Default:** **enabled**.
- **Purpose:** compares `If-None-Match` against the response `ETag` and returns `304 Not Modified` on a match.
- **Knobs:**
| Name | Type | Default |
|---|---|---|
| `enable` | `bool` | `true` (framework default when key absent) |
### 5. `remote_ip`
- **Config key:** `remote_ip` · **Struct:** `remote_ip::RemoteIpMiddleware { enable, source }` (`remote_ip.rs`)
- **Default:** **disabled**.
- **Purpose:** resolves the client IP from a single, trusted source (a proxy header, or the raw socket address). Implemented as a thin wrapper over the [`axum-client-ip`](https://docs.rs/axum-client-ip) crate.
- **Knobs:**
| Name | Type | Default |
|---|---|---|
| `enable` | `bool` | `false` |
| `source` | `axum_client_ip::ClientIpSource` | `RightmostXForwardedFor` |
`source` selects exactly one trusted source — there is no proxy-chain walking and no CIDR trust list. Valid values (serialized as the bare variant name, e.g. `source: XRealIp`): `RightmostXForwardedFor` (last value of the last `X-Forwarded-For` header, taken verbatim), `RightmostForwarded` (RFC 7239 `Forwarded` header), `CfConnectingIp` (Cloudflare), `CloudFrontViewerAddress` (AWS CloudFront), `FlyClientIp` (Fly.io), `TrueClientIp` (Akamai/Cloudflare), `XEnvoyExternalAddress` (Envoy/Istio), `XRealIp` (nginx), or `ConnectInfo` (the raw socket peer address, no header involved).
> **BREAKING (was `trusted_proxies: Option<Vec<String>>`):** the old middleware hand-rolled `X-Forwarded-For` parsing, walking the header right-to-left and skipping any IP in a configurable trusted-proxy CIDR list (or a built-in RFC-1918 + loopback list) — i.e. it could see through a chain of one or more trusted proxies. The new `source` field trusts exactly **one** hop and applies no CIDR filtering at all. If you run multiple hops (CDN → load balancer → ingress), configure your innermost hop to compute and set the correct client IP itself, and point `source` at whatever header it writes (or pick a provider-specific source like `CfConnectingIp`).
### 6. `compression`
- **Config key:** `compression` · **Struct:** `compression::Compression { enable }` (`compression.rs:14-18`)
- **Default:** **disabled**.
- **Purpose:** compresses response bodies (`tower_http::compression::CompressionLayer`).
- **Knobs:**
| Name | Type | Default |
|---|---|---|
| `enable` | `bool` | `false` |
### 7. `timeout_request`
- **Config key:** `timeout_request` · **Struct:** `timeout::TimeOut { enable, timeout }` (`timeout.rs:23-30`)
- **Default:** **disabled**.
- **Purpose:** aborts a request and returns `408 Request Timeout` if it runs longer than `timeout`.
- **Knobs:**
| Name | Type | Default |
|---|---|---|
| `enable` | `bool` | `false` |
| `timeout` | `u64` (milliseconds) | `5000` (`timeout.rs:38-40`) |
### 8. `static`
- **Config key:** `static` (Rust field `static_assets`, `#[serde(rename = "static")]`, `mod.rs:197-199`) · **Struct:** `static_assets::StaticAssets` (`static_assets.rs:24-43`)
- **Default:** **disabled**.
- **Purpose:** serves a static-file folder, with an optional fallback file for SPA routing.
- **Knobs:**
| Name | Type | Default |
|---|---|---|
| `enable` | `bool` | `false` |
| `must_exist` | `bool` | `true` |
| `folder.uri` | `String` | `"/static"` |
| `folder.path` | `PathBuf` | `"assets/static"` |
| `fallback` | `PathBuf` | `"assets/static/404.html"` |
| `precompressed` | `bool` | `false` (serves `.gz` variants when `true`) |
| `cache_control` | `Option<String>` | `None` (e.g. `"max-age=31536000"`) |
> Under the `embedded_assets` feature, this swaps at compile time for `static_assets_embedded::StaticAssets` (`mod.rs:21-27`) — same config key (`"static"`) and knob surface, assets baked into the binary instead of read from disk.
### 9. `secure_headers`
- **Config key:** `secure_headers` · **Struct:** `secure_headers::SecureHeader { enable, preset, overrides }` (`secure_headers.rs:78-86`)
- **Default:** **disabled**.
- **Purpose:** injects a preset bundle of security headers (CSP, X-Frame-Options, etc.), individually overridable.
- **Knobs:**
| Name | Type | Default |
|---|---|---|
| `enable` | `bool` | `false` |
| `preset` | `String` | `"github"` (`secure_headers.rs:94-96`) — other presets: `owasp`, `empty` (`secure_headers.json`) |
| `overrides` | `Option<BTreeMap<String, String>>` | `None` |
### 10. `logger`
- **Config key:** `logger` · **Struct:** `logger::Config { enable }` → `logger::Middleware` via `logger::new(config, &env)` (`logger.rs:21-25, 36-42`)
- **Default:** **enabled**.
- **Purpose:** `TraceLayer`-based request logging (method, URI, version, user agent, request ID, environment).
- **Knobs:**
| Name | Type | Default |
|---|---|---|
| `enable` | `bool` | `true` (framework default when key absent) |
### 11. `request_id`
- **Config key:** `request_id` · **Struct:** `request_id::RequestId { enable }` (`request_id.rs:28-32`)
- **Default:** **enabled**.
- **Purpose:** ensures every request has an `x-request-id` header (sanitizes an incoming one or generates a UUID v4), and exposes it to handlers as `LocoRequestId(String)` via `.get()` (`request_id.rs:63-72`).
- **Knobs:**
| Name | Type | Default |
|---|---|---|
| `enable` | `bool` | `true` (framework default when key absent) |
### 12. `fallback`
- **Config key:** `fallback` · **Struct:** `fallback::Fallback { enable, code, file, not_found }` (`fallback.rs:17-37`); `StatusCodeWrapper(pub StatusCode)` (`fallback.rs:15`)
- **Default:** enabled **only when `environment != Production`** (`mod.rs:158-167`).
- **Purpose:** serves a response for unmatched routes — a file, a plain message, or the bundled `fallback.html` — instead of Axum's bare 404.
- **Knobs:**
| Name | Type | Default |
|---|---|---|
| `enable` | `bool` | `true` outside Production, `false` in Production (framework default when key absent) |
| `code` | `StatusCode` (as `u16`) | `200` (`OK`) — `fallback.rs:39-41`; set to `404` explicitly if that's what you want |
| `file` | `Option<String>` | `None` — path to a file served as the fallback body |
| `not_found` | `Option<String>` | `None` — a plain-text message served as the fallback body |
If neither `file` nor `not_found` is set, the bundled `fallback.html` is served.
### 13. `powered_by`
- **Config key:** none — **not** part of `middleware::Config`; controlled by `server.ident: Option<String>` (`src/config/server.rs:40`). Struct: `powered_by::Middleware` via `powered_by::new(ctx.config.server.ident.as_deref())` (`powered_by.rs:27-58`)
- **Default:** **enabled**, sets `Server`-identifying header `X-Powered-By: loco.rs`.
- **Purpose:** sets an `X-Powered-By` response header.
- **Knobs (via `server.ident`, not `enable`):**
| `server.ident` value | Effect |
|---|---|
| absent / `None` | `X-Powered-By: loco.rs` (default) |
| `""` (empty string) | middleware disabled — no header |
| any other string | `X-Powered-By: <string>` |
## Introspecting the stack
```
cargo loco middleware # list every middleware and its enabled state
cargo loco middleware --config # also print each middleware's JSON config
```
`src/cli.rs:100-104`, backed by `list_middlewares` (`src/boot.rs:588-597`), which calls each middleware's `name()`, `is_enabled()`, and `config()`.
@@ -0,0 +1,250 @@
+++
title = "Query DSL & pagination"
description = "The ConditionBuilder fluent filter DSL, date-range helper, pagination API, and the model-layer error/Authenticable types."
date = 2021-05-01T18:10:00+00:00
updated = 2021-05-01T18:10:00+00:00
draft = false
weight = 10
sort_by = "weight"
template = "docs/page.html"
[extra]
lead = ""
toc = true
top = false
+++
`loco_rs::model::query` (reachable as `loco_rs::prelude::query` or `loco_rs::prelude::model::query`) is a small fluent DSL over Sea-ORM's `Condition`, plus pagination helpers that wrap Sea-ORM's `PaginatorTrait`. This page documents `ConditionBuilder`'s full operator surface, `DateRangeBuilder`, `SortDirection`, the pagination types/functions, and the model-layer `ModelError`/`ModelResult`/`Authenticable` types the query and model code return.
All of this module is gated behind the `with-db` feature (`Cargo.toml:44-49` pulls `sea-orm`/`sea-orm-migration`/`sqlx`); `prelude.rs:29-30` and `prelude.rs:52-55` gate `query`, `ModelError`, `ModelResult`, `Authenticable` on the same feature.
## `ConditionBuilder` — fluent filter DSL
Module: `src/model/query/dsl/mod.rs`, re-exported at `src/model/query/mod.rs:4`.
```rust
pub struct ConditionBuilder {
condition: Condition, // sea_orm::Condition
}
```
Two entry points build a `ConditionBuilder`:
| Fn | Signature | Behavior | Anchor |
|---|---|---|---|
| `condition()` | `condition() -> ConditionBuilder` | Starts from `Condition::all()` (AND-combined). | `dsl/mod.rs:38` |
| `with(condition)` | `const fn with(condition: Condition) -> ConditionBuilder` | Wraps an existing Sea-ORM `Condition` (used internally to chain builder calls). | `dsl/mod.rs:45` |
`ConditionBuilder` implements `From<ConditionBuilder> for Condition` (`dsl/mod.rs:167`), and every builder method below returns `Self` (consuming `self`) so calls chain; `.build()` finalizes to a `sea_orm::Condition`:
```rust
pub fn build(&self) -> Condition // dsl/mod.rs:701
```
### Operators
Every operator exists twice: as a **free function** `query::<op>(col, ..)` that starts a new builder (shorthand for `condition().<op>(..)`), and as a **method** on `ConditionBuilder` for chaining. Both forms accept a Sea-ORM `ColumnTrait` (an entity's generated `Column` enum) as the column argument.
| Operator | Free fn (anchor) | Builder method (anchor) | Args | SQL produced |
|---|---|---|---|---|
| Equals | `eq` `dsl/mod.rs:51` | `eq` `:235` | `col: T, value: V: Into<Value>` | `col = value` |
| Not equals | `not_equal` `:57` | `ne` `:260` | `col, value` | `col <> value` |
| Greater than | `gt` `:63` | `gt` `:285` | `col, value` | `col > value` |
| Greater than or equal | `gt_equal` `:69` | `gte` `:311` | `col, value` | `col >= value` |
| Less than | `lt` `:75` | `lt` `:337` | `col, value` | `col < value` |
| Less than or equal | `lt_equal` `:81` | `lte` `:363` | `col, value` | `col <= value` |
| Between | `between` `:87` | `between` `:389` | `col, a: V, b: V` | `col BETWEEN a AND b` |
| Not between | `not_between` `:93` | `not_between` `:415` | `col, a, b` | `col NOT BETWEEN a AND b` |
| Like | `like` `:99` | `like` `:441` | `col, pattern: V: Into<String>` | `col LIKE pattern` (caller supplies wildcards) |
| Not like | `not_like` `:105` | `not_like` `:467` | `col, pattern` | `col NOT LIKE pattern` |
| Starts with | `starts_with` `:111` | `starts_with` `:493` | `col, s: V: Into<String>` | `col LIKE 's%'` |
| Ends with | `ends_with` `:117` | `ends_with` `:519` | `col, s` | `col LIKE '%s'` |
| Contains | `contains` `:123` | `contains` `:545` | `col, s` | `col LIKE '%s%'` |
| Is null | `is_null` `:130` | `is_null` `:572` | `col` | `col IS NULL` |
| Is not null | `is_not_null` `:137` | `is_not_null` `:599` | `col` | `col IS NOT NULL` |
| Is in | `is_in` `:144` | `is_in` `:626` | `col, values: I: IntoIterator<Item = V>` | `col IN (values...)` |
| Is not in | `is_not_in` `:154` | `is_not_in` `:657` | `col, values` | `col NOT IN (values...)` |
| Date range | `date_range` `:163` | `date_range` `:696` | `col` — returns a `DateRangeBuilder<T>`, not `Self` | see [Date range](#daterangebuilder-date-range-filtering) below |
That is 17 comparison/pattern/membership operators plus `date_range`, matching the ~18-operator surface of the module.
Example (from the module's own doctest, `dsl/mod.rs:194-233`):
```rust
use loco_rs::prelude::*;
use sea_orm::{EntityTrait, QueryFilter};
let cond = query::condition().eq(test_db::Column::Id, 1).build();
test_db::Entity::find().filter(cond);
// WHERE "loco"."id" = 1
```
`like`/`not_like` pass the pattern through verbatim (the caller writes `%` wildcards); `starts_with`/`ends_with`/`contains` add the wildcard(s) for you and always compile down to `LIKE`.
### `DateRangeBuilder` — date-range filtering
`date_range(col)` (either the free fn or the `ConditionBuilder` method) returns a `DateRangeBuilder<T>` instead of `Self`, because a date range needs 0, 1, or 2 bounds before it can become a condition. Struct and impl: `src/model/query/dsl/date_range.rs:7-66`.
| Method | Signature | Anchor |
|---|---|---|
| `new` | `const fn new(condition_builder: ConditionBuilder, col: T) -> Self` | `:15` |
| `dates` | `fn dates(self, from: Option<&NaiveDateTime>, to: Option<&NaiveDateTime>) -> Self` | `:25` |
| `from` | `fn from(self, from: &NaiveDateTime) -> Self` | `:35` |
| `to` | `fn to(self, to: &NaiveDateTime) -> Self` | `:45` |
| `build` | `fn build(self) -> ConditionBuilder` | `:54` |
**Boundary behavior is asymmetric** (`date_range.rs:55-63`) — this is the one non-obvious semantic in the DSL, worth knowing before using it:
| Bounds set | SQL |
|---|---|
| neither `from` nor `to` | no condition added (passthrough) |
| `to` only | `col < to` (strict) |
| `from` only | `col > from` (strict) |
| both `from` and `to` | `col BETWEEN from AND to` (inclusive) |
So a single-ended range is exclusive at the bound, but a double-ended range is inclusive at both bounds — `date_range(col).from(&d).build()` will *not* include rows exactly at `d`, but `date_range(col).dates(Some(&d), Some(&d2)).build()` *will* include rows exactly at `d` or `d2`.
### `SortDirection`
`src/model/query/dsl/mod.rs:17-35`:
```rust
pub enum SortDirection {
Desc, // serde "desc"
Asc, // serde "asc"
}
```
- Derives `Deserialize, Serialize` with `#[serde(rename = "desc"/"asc")]` on each variant — meant to deserialize directly from a query-string sort param.
- `order(&self) -> Order` (`:29`, `#[must_use] const fn`) converts to `sea_orm::sea_query::Order::Desc`/`Order::Asc` for use with `.order_by(col, direction.order())`.
This enum is not itself a `ConditionBuilder` operator — it pairs with Sea-ORM's own `QueryOrder::order_by`, orthogonal to filtering.
## Pagination
Module: `src/model/query/paginate/mod.rs`, re-exported at `src/model/query/mod.rs:5`. Reached as `query::paginate`, `query::fetch_page`, `query::PaginationQuery`.
### `PaginationQuery`
```rust
pub struct PaginationQuery {
pub page_size: u64, // default 25
pub page: u64, // default 1, 1-based
}
```
(`paginate/mod.rs:31-45`)
| Field | Type | Default | Notes |
|---|---|---|---|
| `page_size` | `u64` | `25` (`default_page_size`, `:5-7`) | Rows per page. |
| `page` | `u64` | `1` (`default_page`, `:9-11`) | **1-based.** `paginate`/`fetch_page` internally `saturating_sub(1)` to reach Sea-ORM's 0-based `fetch_page`. |
- Both fields use a custom `deserialize_pagination_filter` (`:69-75`) that parses a **string** into `u64` — a workaround for a `serde_urlencoded` bug where numeric query-string params don't deserialize directly to integers. This makes `PaginationQuery` safe to use as a `#[serde(flatten)]` field inside an axum `Query<T>` extractor struct, e.g.:
```rust
#[derive(Debug, Deserialize)]
pub struct ListQueryParams {
pub title: Option<String>,
pub content: Option<String>,
#[serde(flatten)]
pub pagination: query::PaginationQuery,
}
```
(doctest at `paginate/mod.rs:19-30`)
- `PaginationQuery::page(page: u64) -> Self` (`:49`) — constructs with the given page and `page_size` defaulted via `..Default::default()`.
- `impl Default for PaginationQuery` (`:58-65`) — `page_size = 25`, `page = 1`.
### `PageResponse<T>` and `PagerMeta`
```rust
pub struct PageResponse<T> {
pub page: Vec<T>,
pub meta: PagerMeta,
}
```
(`paginate/mod.rs:80-83`; `PagerMeta` is `crate::controller::views::pagination::PagerMeta`, imported at `:77`)
`PagerMeta` (`src/controller/views/pagination.rs:13-22`) — not re-derived by the inventory, verified directly from source for this page:
```rust
pub struct PagerMeta {
pub page: u64, // serializes as "page"
pub page_size: u64, // serializes as "page_size"
pub total_pages: u64,// serializes as "total_pages"
pub total_items: u64,// serializes as "total_items"
}
```
### `paginate` and `fetch_page`
| Fn | Signature | Anchor |
|---|---|---|
| `paginate` | `async fn paginate<E>(db: &DatabaseConnection, entity: Select<E>, condition: Option<Condition>, pagination_query: &PaginationQuery) -> LocoResult<PageResponse<E::Model>> where E: EntityTrait, E::Model: Sync` | `paginate/mod.rs:146` |
| `fetch_page` | `async fn fetch_page<'db, C, S>(db: &'db C, selector: S, pagination_query: &PaginationQuery) -> LocoResult<PageResponse<...>> where C: ConnectionTrait + Sync, S: PaginatorTrait<'db, C> + Send` | `paginate/mod.rs:204` |
Both:
- Take the caller's 1-based `pagination_query.page` and internally do `.saturating_sub(1)` (`:156`, `:213`) before calling Sea-ORM's `Paginator::fetch_page` (which is 0-based).
- Call `query.num_items_and_pages().await?` to populate `PagerMeta.total_pages`/`total_items`, then `query.fetch_page(page).await?` for the row data.
- Return `Ok(PageResponse { page, meta })` — the crate's `LocoResult<T>` (i.e. `crate::Result<T, crate::errors::Error>`).
`paginate` takes a `Select<E>` (an entity query builder) plus an optional pre-built `Condition` — it applies `.filter(condition)` itself if one is given, so you don't chain `.filter()` before calling it. `fetch_page` is the more generic form: it accepts anything implementing Sea-ORM's `PaginatorTrait` directly (so you can pre-build arbitrary selects, including `.order_by(..)`, and just page over the result), but does not take a separate `Condition` argument — filter and ordering must already be applied to the selector you pass in.
```rust
// paginate: entity + optional condition + pagination query
let condition = query::condition().contains(Column::Name, "loco").build();
let res = query::paginate(&db, Entity::find(), Some(condition), &pagination_query).await;
// fetch_page: pre-built selector (any PaginatorTrait), no separate condition arg
let res = query::fetch_page(&db, Entity::find(), &query::PaginationQuery::page(2)).await;
```
(adapted from doctests at `paginate/mod.rs:92-140` and `:185-197`)
## Model-layer error types
`src/model/mod.rs` — the error type returned by model/authn code (distinct from the crate-wide `loco_rs::errors::Error`; see the [error model reference](@/docs/reference/errors.md)).
### `ModelError` / `ModelResult`
```rust
pub enum ModelError {
EntityAlreadyExists,
EntityNotFound,
Validation(ModelValidationErrors), // #[from]
#[cfg(feature = "auth")]
Jwt(jsonwebtoken::errors::Error), // #[from]
DbErr(sea_orm::DbErr), // #[from]
Any(Box<dyn std::error::Error + Send + Sync>), // #[from]
Message(String),
}
pub type ModelResult<T, E = ModelError> = std::result::Result<T, E>;
```
(`src/model/mod.rs:13-35` for the enum, `:38` for the alias)
Note: `ModelError` is **not** `#[non_exhaustive]` (unlike the crate-wide `Error`) — this is the model layer's own, smaller error enum, not the one documented on the [error model reference](@/docs/reference/errors.md) page. `Jwt` only exists when the `auth` feature is enabled (`mod.rs:23-25`).
Constructors:
| Fn | Signature | Anchor |
|---|---|---|
| `ModelError::wrap` | `#[must_use] fn wrap(err: impl std::error::Error + Send + Sync + 'static) -> Self` — builds `Any(Box::new(err))` | `:42-44` |
| `ModelError::to_msg` | `#[must_use] fn to_msg(err: impl std::error::Error + Send + Sync + 'static) -> Self` — builds `Message(err.to_string())` | `:47-49` |
| `ModelError::msg` | `#[must_use] fn msg(s: &str) -> Self` — builds `Message(s.to_string())` | `:52-54` |
`loco_rs::errors::Error` itself has a `Model(#[from] crate::model::ModelError)` variant (`with-db`-gated), so a `ModelError` returned from a model method converts automatically into the crate-wide `Error` at a controller boundary via `?`.
### `Authenticable`
```rust
#[async_trait]
pub trait Authenticable: Clone {
async fn find_by_api_key(db: &DatabaseConnection, api_key: &str) -> ModelResult<Self>;
async fn find_by_claims_key(db: &DatabaseConnection, claims_key: &str) -> ModelResult<Self>;
}
```
(`src/model/mod.rs:56-60`)
A user model (typically the `users` entity) implements `Authenticable` so the auth extractors (`JWT`, `JWTWithUser`, `ApiToken` under `prelude::auth`, feature `auth`) can look the caller up: `find_by_claims_key` resolves a JWT's claims subject to a model instance; `find_by_api_key` resolves a bearer/API-key header the same way. Both are `async` (the trait itself is `#[async_trait]`) and return `ModelResult<Self>`, so a lookup failure surfaces as a `ModelError` (typically `EntityNotFound` or `DbErr`).
All four items (`query`, `ModelError`, `ModelResult`, `Authenticable`) are re-exported from `loco_rs::prelude` (`prelude.rs:29-30`, `with-db`-gated).
@@ -0,0 +1,260 @@
+++
title = "Schema & ColType DSL"
description = "The full migration schema DSL: the ColType column-type enum, table-level operations, references/enums, and the i64 auto-PK default."
date = 2021-05-01T18:10:00+00:00
updated = 2021-05-01T18:10:00+00:00
draft = false
weight = 9
sort_by = "weight"
template = "docs/page.html"
[extra]
lead = ""
toc = true
top = false
+++
`loco_rs::schema` is the Rails-like migration DSL migrations author against — a thin, ergonomic layer over Sea-ORM's `sea_query`/`SchemaManager`. It is reached as `loco_rs::schema::*` (the generated migration template does `use loco_rs::schema::*;`) and re-exports all of `sea_orm_migration::schema::*` alongside its own additions, so lower-level column-def primitives (`string`, `integer`, `pk_auto`, …) are available directly if `ColType` doesn't cover a case.
The module is gated behind the `with-db` feature (`src/lib.rs:20-21`).
## The i64 auto primary key (1.0 default)
`ColType::PkAuto` builds an auto-increment **64-bit** (`BIGINT`) primary key, not a 32-bit one:
```rust
// src/schema.rs:330-332
Self::PkAuto => big_pk_auto(name),
```
`big_pk_auto` (re-exported from `sea_orm_migration::schema`) is `big_integer(name).auto_increment().primary_key()` — a `BigInteger` column, i.e. Rust-side `i64`. This is a deliberate 1.0 change from the previous 32-bit default (comment at `schema.rs:330-331`: Sea-ORM 2.0 maps SQLite integers to `i64`, and a `BIGINT` PK is the modern default, matching Rails 5.1+).
The consequence propagates to foreign keys: every FK column generated by `create_table`/`create_join_table`/`add_reference` is typed `ColType::BigInteger` (or `BigIntegerNull` when nullable) so it matches the `id` column it points to (`schema.rs:677-683`, `schema.rs:781-782`). `ColType::PkUuid` is the alternative — a `Uuid` primary key with no auto-increment (`pk_uuid`, wraps `uuid(name).primary_key()`).
| Variant | Builds | Rust type | Anchor |
|---|---|---|---|
| `ColType::PkAuto` | `big_pk_auto(name)` — auto-increment `BIGINT` PK | `i64` | `schema.rs:332` |
| `ColType::PkUuid` | `pk_uuid(name)` — `UUID` PK, no auto-increment | `Uuid` | `schema.rs:333` |
## `ColType` — column type enum
`enum ColType` (`schema.rs:161-284`) is the value half of every `(name, ColType)` tuple passed to `create_table`/`add_column`. Each family below generally follows a modifier convention — but coverage is not uniform per family (e.g. `Boolean` has no `*Uniq`, `TimestampWithTimeZone` has no `*Uniq`, `Text` has no `*Len`):
- **(bare)** — `NOT NULL`, no default, no unique constraint.
- **`*Null`** — nullable.
- **`*Uniq`** — `NOT NULL` + unique index.
- **`*WithDefault(v)`** — `NOT NULL` + a default value.
- **`*Len(n)`** — fixed/max length, for `Char`/`String`/`Decimal`(precision, scale)/binary/varbit families.
`ColType::to_def(&self, name) -> ColumnDef` (`schema.rs:328-470`) matches every variant to a Sea-ORM `ColumnDef` builder call; the tables below are transcribed from that match plus the enum declaration.
### Primary keys — see [above](#the-i64-auto-primary-key-1-0-default)
### Char / String / Text
| Variant | Notes |
|---|---|
| `Char`, `CharNull`, `CharUniq`, `CharWithDefault(char)` | fixed single-char-typed column |
| `CharLen(u32)`, `CharLenNull(u32)`, `CharLenUniq(u32)`, `CharLenWithDefault(u32, char)` | fixed length `n` |
| `String`, `StringNull`, `StringUniq`, `StringWithDefault(String)` | variable-length string, no length cap |
| `StringLen(u32)`, `StringLenNull(u32)`, `StringLenUniq(u32)`, `StringLenWithDefault(u32, String)` | variable-length string with max length `n` |
| `Text`, `TextNull`, `TextUniq`, `TextWithDefault(String)` | unbounded text; no `Len` variant |
### Numeric — integers & unsigned
| Variant | Rust type | Notes |
|---|---|---|
| `Integer`, `IntegerNull`, `IntegerUniq`, `IntegerWithDefault(i32)` | `i32` | 32-bit signed |
| `SmallInteger`, `SmallIntegerNull`, `SmallIntegerUniq`, `SmallIntegerWithDefault(i16)` | `i16` | |
| `BigInteger`, `BigIntegerNull`, `BigIntegerUniq`, `BigIntegerWithDefault(i64)` | `i64` | also the type auto-generated for FK columns |
| `Unsigned`, `UnsignedNull`, `UnsignedUniq`, `UnsignedWithDefault(u32)` | `u32` | |
| `SmallUnsigned`, `SmallUnsignedNull`, `SmallUnsignedUniq`, `SmallUnsignedWithDefault(u16)` | `u16` | |
| `BigUnsigned`, `BigUnsignedNull`, `BigUnsignedUniq`, `BigUnsignedWithDefault(u64)` | `u64` | |
### Numeric — decimal / float / money
| Variant | Notes |
|---|---|
| `Decimal`, `DecimalNull`, `DecimalUniq`, `DecimalWithDefault(f64)` | unconstrained precision |
| `DecimalLen(u32, u32)`, `DecimalLenNull(u32, u32)`, `DecimalLenUniq(u32, u32)`, `DecimalLenWithDefault(u32, u32, f64)` | `(precision, scale)` |
| `Float`, `FloatNull`, `FloatUniq`, `FloatWithDefault(f32)` | `f32` |
| `Double`, `DoubleNull`, `DoubleUniq`, `DoubleWithDefault(f64)` | `f64` |
| `Money`, `MoneyNull`, `MoneyUniq`, `MoneyWithDefault(f64)` | currency-typed column |
### Boolean
| Variant | Notes |
|---|---|
| `Boolean`, `BooleanNull`, `BooleanWithDefault(bool)` | no `*Uniq` variant |
### Date / time
| Variant | Notes |
|---|---|
| `Date`, `DateNull`, `DateUniq`, `DateWithDefault(String)` | |
| `Time`, `TimeNull`, `TimeUniq`, `TimeWithDefault(String)` | |
| `DateTime`, `DateTimeNull`, `DateTimeUniq`, `DateTimeWithDefault(String)` | naive datetime, no timezone |
| `TimestampWithTimeZone`, `TimestampWithTimeZoneNull`, `TimestampWithTimeZoneWithDefault(String)` | timezone-aware; no `*Uniq` variant |
| `Interval(Option<PgInterval>, Option<u32>)`, `IntervalNull(..)`, `IntervalUniq(..)` | Postgres interval; args are an optional `PgInterval` field-qualifier and optional precision |
### Binary
| Variant | Notes |
|---|---|
| `Binary`, `BinaryNull`, `BinaryUniq` | unbounded, no default variant |
| `BinaryLen(u32)`, `BinaryLenNull(u32)`, `BinaryLenUniq(u32)` | fixed length `n` |
| `VarBinary(u32)`, `VarBinaryNull(u32)`, `VarBinaryUniq(u32)` | variable, max length `n` |
| `Blob`, `BlobNull`, `BlobUniq` | |
### JSON
| Variant | Notes |
|---|---|
| `Json`, `JsonNull`, `JsonUniq` | text-stored JSON |
| `JsonBinary`, `JsonBinaryNull`, `JsonBinaryUniq` | binary JSON (`jsonb` on Postgres) |
### UUID
| Variant | Notes |
|---|---|
| `Uuid`, `UuidNull`, `UuidUniq` | |
| `UuidWithDefault(String)`, `UuidUniqWithDefault(String)` | default is a raw SQL expression string, e.g. `"gen_random_uuid()"` (via `Expr::cust`) |
### Bit strings
| Variant | Notes |
|---|---|
| `VarBitLen(u32)`, `VarBitLenNull(u32)`, `VarBitLenUniq(u32)` | Postgres `VARBIT(n)` |
### Array
| Item | Signature | Anchor |
|---|---|---|
| `ColType::Array(ColumnType)` / `ArrayNull(ColumnType)` / `ArrayUniq(ColumnType)` | wrap a Sea-ORM `ColumnType` for the element type | `schema.rs:276-278` |
| `ColType::array(kind: ArrayColType) -> Self` | builds `Array(..)` | `schema.rs:298-300` |
| `ColType::array_uniq(kind: ArrayColType) -> Self` | builds `ArrayUniq(..)` | `schema.rs:304-306` |
| `ColType::array_null(kind: ArrayColType) -> Self` | builds `ArrayNull(..)` | `schema.rs:310-312` |
| `enum ArrayColType { String, Int, BigInt, Float, Double, Bool }` | element-type selector for the `array*` constructors | `schema.rs:286-293` |
`array_col_type` maps each `ArrayColType` to a `sea_orm::ColumnType`: `String` → `ColumnType::string(None)`, `Int` → `Integer`, `BigInt` → `BigInteger`, `Float` → `Float`, `Double` → `Double`, `Bool` → `Boolean` (`schema.rs:314-323`).
### Enum
| Variant | Notes |
|---|---|
| `Enum(enum_name: String, variants: Vec<String>)` | `NOT NULL` |
| `EnumNull(enum_name, variants)` | nullable |
| `EnumWithDefault(enum_name, variants, default_value: String)` | `NOT NULL` + default |
| `EnumNullWithDefault(enum_name, variants, default_value: String)` | nullable + default |
(`schema.rs:280-283`)
Enum creation is backend-dependent and handled automatically by `create_table`/`create_join_table` (see [Enum type semantics](#enum-type-semantics-per-backend) below) — you don't call `CREATE TYPE` yourself.
## Column-def helper functions (schema.rs's own additions)
Beyond `ColType`, `schema.rs` defines a handful of standalone helpers used to build raw `ColumnDef`/`TableCreateStatement`/`TableAlterStatement` values, on top of everything re-exported from `sea_orm_migration::schema`:
| Fn | Signature | Behavior | Anchor |
|---|---|---|---|
| `alter` | `fn alter<T: IntoIden + 'static>(name: T) -> TableAlterStatement` | `Table::alter().table(name)` | `schema.rs:19-21` |
| `table_auto_tz` | `fn table_auto_tz<T>(name: T) -> TableCreateStatement` | `Table::create().table(name).if_not_exists()` **with** `created_at`/`updated_at` timestamptz columns already added (via `timestamps_tz`) | `schema.rs:24-29` |
| `timestamps_tz` | `fn timestamps_tz(t: TableCreateStatement) -> TableCreateStatement` | adds `created_at`/`updated_at` as `timestamp_with_time_zone` columns defaulting to `Expr::current_timestamp()` | `schema.rs:34-39` |
| `timestamptz` | `fn timestamptz<T>(name: T) -> ColumnDef` | non-nullable timestamptz column | `schema.rs:53-61` |
| `timestamptz_null` | `fn timestamptz_null<T>(name: T) -> ColumnDef` | nullable timestamptz column | `schema.rs:42-50` |
| `enum_type` | `fn enum_type<T>(name: T, enum_name: &str) -> ColumnDef` | non-nullable enum column | `schema.rs:64-72` |
| `enum_type_null` | `fn enum_type_null<T>(name: T, enum_name: &str) -> ColumnDef` | nullable enum column | `schema.rs:75-83` |
| `enum_type_with_default` | `fn enum_type_with_default<T>(name: T, enum_name: &str, default_value: &str) -> ColumnDef` | non-nullable enum column + default | `schema.rs:93-102` |
| `enum_type_null_with_default` | `fn enum_type_null_with_default<T>(name: T, enum_name: &str, default_value: &str) -> ColumnDef` | nullable enum column + default | `schema.rs:112-121` |
`table_auto_tz` is the timezone-aware counterpart to `sea_orm_migration::schema::table_auto` (which uses naive, non-tz timestamps) — `create_table`/`create_join_table` use `table_auto_tz` internally, so tables built through the DSL always get timezone-aware `created_at`/`updated_at`.
## Table-level operations
All are `async fn(m: &SchemaManager<'_>, ...) -> Result<(), DbErr>`, called from a migration's `up`/`down`.
| Fn | Signature | Anchor |
|---|---|---|
| `create_table` | `create_table(m, table: &str, cols: &[(&str, ColType)], refs: &[(&str, &str)])` | `schema.rs:490-497` |
| `create_join_table` | `create_join_table(m, table, cols, refs)` — composite primary key over the reference columns | `schema.rs:512-519` |
| `create_table_without_timestamps` | `create_table_without_timestamps(m, table, cols, refs)` — no auto `created_at`/`updated_at` | `schema.rs:537-544` |
| `create_join_table_without_timestamps` | `create_join_table_without_timestamps(m, table, cols, refs)` — join table, no timestamps | `schema.rs:559-566` |
| `add_column` | `add_column(m, table: &str, name: &str, atype: ColType)` | `schema.rs:721-735` |
| `remove_column` | `remove_column(m, table: &str, name: &str)` | `schema.rs:745-754` |
| `add_reference` | `add_reference(m, fromtbl: &str, totbl: &str, refname: &str)` | `schema.rs:764-839` |
| `remove_reference` | `remove_reference(m, fromtbl: &str, totbl: &str, refname: &str)` | `schema.rs:849-892` |
| `drop_table` | `drop_table(m, table: &str)` | `schema.rs:902-906` |
| `add_enum_values` | `add_enum_values(m, enum_name: &str, new_values: Vec<String>)` | `schema.rs:916-952` |
| `drop_enum_type` | `drop_enum_type(m, enum_name: &str)` | `schema.rs:962-987` |
All four `create_*` functions share one implementation (`create_table_impl`, `schema.rs:568-701`), parameterized by `is_join: bool` and `add_timestamps: bool`.
```rust
// schema.rs:474-497 (doc example)
create_table(m, "movies", vec![
("title", ColType::String)
], vec![]).await;
```
```sh
loco g migration CreateMovies title:string user:references
loco g migration CreateMovies title:string user:references:admin_id
```
### `cols` and `refs` parameters
- `cols: &[(&str, ColType)]` — ordinary columns, in order, each turned into a `ColumnDef` via `ColType::to_def`.
- `refs: &[(&str, &str)]` — one entry per foreign-key reference the new/altered table should carry. The **first** element names the *referenced* table (it is pluralized/snake-cased the same way as any table name); the **second** element is an optional custom FK column name — pass `""` to use the default `<singular(referenced_table)>_id` (computed by `reference_id`, `schema.rs:708-711`).
- Suffix the referenced-table name with `?` to make the FK column **nullable**: `refs: &[("user?", "")]` — `create_table_impl` strips the `?` before normalizing the table name (`schema.rs:664-669`).
- Nullable references get `ON DELETE SET NULL` / `ON UPDATE NO ACTION`; non-nullable references get `ON DELETE CASCADE` / `ON UPDATE CASCADE` (`schema.rs:685-696`).
- The generated FK column is always `ColType::BigInteger`/`BigIntegerNull` (matching the i64 `PkAuto` default), unless a column of that name already exists in `cols` (`schema.rs:676-683`).
- FK constraint name is deterministic: `fk-{referenced_table}-{ref_column}-to-{table}` (`schema.rs:687`) — note this is **not** the same naming order `add_reference`/`remove_reference` use (see next section): a FK added through `create_table`'s `refs` parameter is named `fk-users-user_id-to-movies`, whereas `add_reference(m, "movies", "users", "")` names it `fk-movies-user_id-to-users`. Calling `remove_reference` against a FK that was created via `create_table`'s `refs` (rather than via `add_reference` itself) will look for the wrong constraint name and not find it.
### `add_reference` / `remove_reference`
Unlike the `refs` tuples above, `add_reference`/`remove_reference` take table names in natural "reads as" order — `add_reference(m, "movies", "users", "")` reads *"movies belongs-to users"*: `fromtbl` is the table being altered (`movies`), `totbl` is the table referenced (`users`).
```rust
// schema.rs:756-764 (doc example)
add_reference(m, "movies", "users", "").await;
// ...
remove_reference(m, "movies", "users", "").await;
```
- `add_reference` always builds a `ColType::BigInteger` FK column, adds it via `ALTER TABLE ... ADD COLUMN`, and — on MySQL/Postgres only — also `ADD FOREIGN KEY` in the same statement. On **SQLite it adds the column but skips the FK constraint** (SQLite doesn't allow adding FKs to an existing table; per Rails 5.2 convention, this is a documented no-op — `schema.rs:817-830`). Any other backend returns `DbErr::BackendNotSupported { ctx: "add_reference" }`.
- `remove_reference` drops the named FK constraint on MySQL/Postgres; on **SQLite it is a no-op** for the same reason (`schema.rs:879-883`). Any other backend returns `DbErr::BackendNotSupported { ctx: "remove_reference" }`.
## Enum type semantics per backend
`create_table_impl` scans `cols` for any `ColType::Enum*` variant and, for each distinct `enum_name` not yet seen, checks whether the type already exists (`check_enum_exists`, `schema.rs:124-159`, Postgres-only `pg_type` lookup) before creating it:
| Backend | Behavior |
|---|---|
| Postgres | Creates a native `CREATE TYPE ... AS ENUM (...)` if it doesn't already exist. |
| SQLite | No native enum type; the column is created as `TEXT` with the enum behavior enforced via the column definition (no `CREATE TYPE` step). |
| MySQL | Not created as a separate type; MySQL enums are inline in the column definition. |
| other | No-op. |
`add_enum_values(m, enum_name, new_values)` extends an existing enum: on Postgres it runs `ALTER TYPE {enum_name} ADD VALUE '{value}'` per new value; on SQLite/MySQL it's a logged no-op (`schema.rs:916-952`). `drop_enum_type(m, enum_name)` runs `DROP TYPE IF EXISTS {enum_name} CASCADE` on Postgres (guarded by the same existence check) and is a no-op elsewhere (`schema.rs:962-987`).
## Table naming
`normalize_table(table: &str) -> String` (`schema.rs:704-706`) pluralizes and snake-cases every table name passed to the DSL: `cruet::to_plural(table).to_snake_case()` — e.g. `"person"` → `"people"`, `"Movie"` → `"movies"`. This runs on every table-name argument across `create_table`, `add_column`, `add_reference`, etc., so callers pass singular or plural, either case, and get the same normalized table.
## Timestamps: default vs `_without_timestamps`
`create_table`/`create_join_table` add `created_at`/`updated_at` (via `table_auto_tz`) unless you use the `_without_timestamps` variant (`create_table_without_timestamps`/`create_join_table_without_timestamps`), which builds a bare `Table::create().if_not_exists()` with no timestamp columns — full control over the schema.
The generator CLI flag that maps to the `_without_timestamps` functions is **`--without-tz`** (not `--without-timestamps`):
```sh
loco g migration CreatePosts title:string --without-tz
loco g migration CreateJoinTableUsersAndGroups count:int --without-tz
loco g scaffold posts title:string! user:references --without-tz
```
(`src/cli.rs:193`, `:237`, `:241`, `:267`)
> An internal doc-comment inside `schema.rs` (`schema.rs:533`, on `create_table_without_timestamps`) still shows the old flag spelling `--without-timestamps` in its example — that comment is stale; the real CLI flag, wired in `src/cli.rs`, is `--without-tz`.
## Related: generator field-type mapping
The `loco g model|migration|scaffold` field-type shorthand (e.g. `title:string!`, `count:int^`, `user:references`) maps onto this same `ColType` surface via `loco-gen/src/column.rs` (the `parse_column` function and `ScalarType` enum). Notably, the generator's `int`/`unsigned` shorthand also produces 64-bit columns (`int` → `ColType::BigIntegerNull` / `Option<i64>`, `int!` → `ColType::BigInteger` / `i64`, `unsigned` family → `BigUnsigned*` / `i64`) — consistent with the `PkAuto` 64-bit default on this page. The full field-type table (all ~50 shorthand entries) belongs on the generators reference page, not yet published as of this writing.