diff --git a/README.md b/README.md
index 1dcc160..4c008ca 100644
--- a/README.md
+++ b/README.md
@@ -71,6 +71,21 @@ iii trigger migrate::codegen --json '{}'
# -> ./db.types.ts — one interface per table
```
+**Payloads with quotes.** SQL regularly contains single quotes and `$$`
+blocks, which fight the shell when inlined into `--json '…'` (the iii CLI
+has no `@file`/stdin form). Put the payload in a file and let the shell do
+the reading — no escaping needed:
+
+```sh
+cat > /tmp/payload.json <<'EOF'
+{ "db": "primary", "sql": "UPDATE users SET motto = 'don''t panic' WHERE id = $1", "params": [1] }
+EOF
+iii trigger database::execute --json "$(cat /tmp/payload.json)"
+```
+
+Simple scalar fields can also skip JSON entirely: `iii trigger
+migrate::create name=add_users` (key=value pairs merge over `--json`).
+
Three rules keep it simple and safe:
- **Forward-only.** Never edit an applied file — write a new migration to
diff --git a/docs/changelog.md b/docs/changelog.md
index ae3d7b2..1510ca3 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -1,5 +1,40 @@
# Changelog
+## Unreleased
+
+- README: how to pass payloads containing quotes to the iii CLI (read the
+ JSON from a file with `--json "$(cat …)"`; key=value pairs for scalars) —
+ the CLI has no native `@file`/stdin form.
+- Skill: states that `future_dated` entries never block schema work — the
+ resolution is automatic (`migrate::create` timestamps are monotonic), so
+ an agent must not stall on an anomaly that is already handled.
+- Skill: documents that `database::query` is read-only (writes fail with
+ SQLSTATE 25006) — ad-hoc writes go through `database::execute` or
+ `database::transaction`.
+- Docs: canonical Postgres recreate-pattern recipe (column reorder,
+ incompatible type change) in the workflows, with the checklist of classic
+ omissions — incoming foreign keys, the serial sequence's `OWNED BY`,
+ indexes, triggers, defaults.
+- New function `migrate::schema`: read-only structured report of the live
+ schema — ordered columns with defaults, primary keys, foreign keys
+ (multi-column pairs included), indexes, triggers, and Postgres enums —
+ for verifying what a migration actually did without hand-writing
+ `information_schema` queries. Optional `table` payload filter.
+- New function `migrate::check`: static validation of the migrations
+ directory — statement splitting, empty files, naming scheme, checksum
+ drift — without executing any SQL. Reports every problem at once instead
+ of failing on the first, and keeps working while the database worker is
+ down (`db_checked: false`). Forward-only migrations cannot be rolled
+ back, so validate before you apply.
+- Breaking (0.x): `migrate::status`'s `future_dated` entries are now objects
+ `{ name, applied, hint }` instead of bare names — the hint states the
+ remediation explicitly (applied: harmless; pending: re-scaffold via
+ `migrate::create`, whose timestamps are monotonic).
+- Database errors now name the driver-native code in their message —
+ `… (SQLSTATE 42703)` on Postgres, `… (sqlite error code 1555)` on SQLite —
+ instead of burying it inside `database_error`. The full structured body is
+ still attached unchanged.
+
## 0.1.3 — 2026-07-23
- `migrate::create` keeps timestamps monotonic with the files already on
diff --git a/docs/errors.md b/docs/errors.md
index fe5b329..248afd1 100644
--- a/docs/errors.md
+++ b/docs/errors.md
@@ -7,7 +7,7 @@ Every handler error body carries a stable `code` field plus context fields.
| Code | Meaning | Recovery |
|---|---|---|
| `CHECKSUM_MISMATCH` | An applied migration's file changed on disk. Nothing was applied. | Restore the original file content (checksums normalize CRLF, so line endings are never the cause). To change the schema, write a new migration instead. |
-| `MIGRATION_FAILED` | A migration's SQL failed. The batch rolled back; earlier migrations in the same run stay applied. | Read `name`, `statement_index` (0-based), and the database worker's structured error under `database_error`. Fix the SQL in the failing file — it was never recorded as applied, so a plain re-run of `migrate::up` picks it up again. |
+| `MIGRATION_FAILED` | A migration's SQL failed. The batch rolled back; earlier migrations in the same run stay applied. | Read `name`, `statement_index` (0-based), and the database worker's structured error under `database_error`. When the driver reported a native code, `message` names it — `… (SQLSTATE 42703)` on Postgres — so the cause is searchable without digging into the body. Fix the SQL in the failing file — it was never recorded as applied, so a plain re-run of `migrate::up` picks it up again. |
| `CONFIG_ERROR` | Bad or missing configuration — unknown `db`, codegen without `types_out` or `out`, etc. | Fix the `configuration` entry `miiigrate` and restart the worker (config is read once at startup). |
| `DIR_NOT_FOUND` | The `dir` folder does not exist. | Only `migrate::create` creates the directory; run it once, or create the folder. |
| `INVALID_MIGRATION_NAME` | A `.sql` file does not match `YYYYMMDDHHMMSS_slug.sql`, or a `create` slug is not `[A-Za-z0-9_-]+`. | Rename the file or fix the slug. |
diff --git a/docs/functions.md b/docs/functions.md
index 1e31e1f..649f5c0 100644
--- a/docs/functions.md
+++ b/docs/functions.md
@@ -59,7 +59,7 @@ time, including while `migrate::up` is failing.
"pending": [{ "name": "…", "checksum": "…" }],
"mismatched": [{ "name": "…", "applied_checksum": "…", "file_checksum": "…", "applied_at": "…" }],
"missing": [{ "name": "…", "checksum": "…", "applied_at": "…" }],
- "future_dated": ["…"]
+ "future_dated": [{ "name": "…", "applied": false, "hint": "pending: recreate it via migrate::create …" }]
}
```
@@ -69,9 +69,10 @@ time, including while `migrate::up` is failing.
`migrate::up` refuses to run while this list is non-empty.
- `missing` — recorded as applied but the file no longer exists on disk.
- `future_dated` — files on disk whose timestamp prefix is ahead of the wall
- clock beyond a 5-minute skew tolerance: hand-written names. Harmless once
- applied (`migrate::create` keeps new timestamps monotonic with them), but a
- pending one should be renamed before apply.
+ clock beyond a 5-minute skew tolerance: hand-written names. Each entry says
+ whether the file is `applied` and carries an explicit `hint`: applied ones
+ are harmless (`migrate::create` keeps new timestamps monotonic with them),
+ pending ones should be re-scaffolded via `migrate::create` before apply.
## `migrate::create`
@@ -102,6 +103,75 @@ One `export interface` per table, plus an aggregate `Database` type. The
`_iii_migrations` tracking table is excluded. See [codegen.md](codegen.md)
for the exact type mapping.
+## `migrate::check`
+
+Statically validate the migrations directory — "would `migrate::up`
+succeed?" — without executing any SQL. Read-only; the only network call is
+the tracking-table read, and it degrades gracefully.
+
+- **Payload**: `{}`
+- **Returns**:
+
+```json
+{
+ "ok": true,
+ "dir": "./migrations",
+ "pending_checked": [{ "name": "…", "ok": true, "statements": 3 }],
+ "invalid_names": [{ "file": "notes.sql", "reason": "…" }],
+ "future_dated": [{ "name": "…", "applied": false, "hint": "…" }],
+ "mismatched": [],
+ "missing": [],
+ "db_checked": true
+}
+```
+
+- Every pending file is parsed with the same splitter as `migrate::up`;
+ `pending_checked[].error` carries the exact message `up` would fail with
+ (unsplittable SQL, empty file).
+- Unlike `migrate::up`/`status`, an ill-named `.sql` file does not abort the
+ report — all problems are listed at once under `invalid_names`.
+- `ok` is false when anything would block or break `migrate::up`: an invalid
+ name, a broken pending file, or checksum drift. `future_dated` and
+ `missing` are warnings and do not flip it.
+- Works while the database worker is down: `db_checked: false`, drift lists
+ are then unknown (empty).
+
+## `migrate::schema`
+
+Read-only structured report of the live schema — the verification companion
+of `migrate::up`. Everything an agent or human needs to confirm what a
+migration actually did, without hand-writing `information_schema` queries.
+
+- **Payload**: `{}` or `{ "table": "users" }` (exact name; an unknown table
+ yields an empty `tables` list, not an error)
+- **Returns**:
+
+```json
+{
+ "db": "primary",
+ "dialect": "postgres",
+ "tables": [{
+ "name": "reservations",
+ "columns": [{ "name": "id", "data_type": "bigint", "nullable": false,
+ "default": "nextval('…')", "position": 1 }],
+ "primary_key": ["id"],
+ "foreign_keys": [{ "name": "…_fkey", "columns": ["event_id"],
+ "references_table": "events", "references_columns": ["id"],
+ "on_delete": "CASCADE", "on_update": "NO ACTION" }],
+ "indexes": [{ "name": "…", "unique": true, "columns": ["…"], "definition": "CREATE …" }],
+ "triggers": [{ "name": "…", "timing": "AFTER", "events": ["INSERT", "UPDATE"] }]
+ }],
+ "enums": [{ "name": "mood", "labels": ["happy", "sad", "curious"] }]
+}
+```
+
+- Columns come in ordinal (DDL) order with defaults; multi-column foreign
+ keys are paired column-by-column; `enums` is Postgres-only.
+- `indexes[].columns` is best-effort: empty for expression indexes — read
+ `definition` there. SQLite auto-indexes backing PRIMARY KEY / UNIQUE have
+ no `definition`.
+- The `_iii_migrations` tracking table is excluded.
+
## Checksums
Checksums are SHA-256 over the file content with line endings normalized
diff --git a/docs/workflows.md b/docs/workflows.md
index c2a185d..9c34a8b 100644
--- a/docs/workflows.md
+++ b/docs/workflows.md
@@ -111,3 +111,59 @@ INSERT INTO users_new (id, name, score) SELECT id, name, CAST(score AS REAL) FRO
DROP TABLE users;
ALTER TABLE users_new RENAME TO users;
```
+
+### Postgres recreate pattern (column reorder, incompatible type change)
+
+Postgres has no `ALTER TABLE … REORDER`; when the column order matters or a
+type change is beyond `ALTER COLUMN TYPE`, recreate the table **inside one
+migration** (single atomic batch — the swap is all-or-nothing). The DDL is
+straightforward; what bites are the objects attached to the *old* table
+that silently die with it. Canonical example, reordering `users`:
+
+```sql
+-- 1. New shape, everything declared up front (defaults, constraints).
+CREATE TABLE users_new (
+ id bigint PRIMARY KEY DEFAULT nextval('users_id_seq'),
+ first_name text,
+ last_name text,
+ email text UNIQUE,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+
+-- 2. Copy the data, naming columns explicitly on both sides.
+INSERT INTO users_new (id, first_name, last_name, email, created_at)
+SELECT id, first_name, last_name, email, created_at FROM users;
+
+-- 3. Re-point the sequence: it is OWNED BY the old table and would be
+-- dropped with it otherwise.
+ALTER SEQUENCE users_id_seq OWNED BY users_new.id;
+
+-- 4. Incoming foreign keys (other tables referencing users) must be
+-- dropped before the old table can go, and recreated against the new.
+ALTER TABLE reservations DROP CONSTRAINT reservations_user_id_fkey;
+
+-- 5. Swap.
+DROP TABLE users;
+ALTER TABLE users_new RENAME TO users;
+
+-- 6. Recreate what lived on (or pointed at) the old table:
+ALTER TABLE reservations
+ ADD CONSTRAINT reservations_user_id_fkey
+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE;
+CREATE INDEX idx_users_mood ON users (mood);
+CREATE TRIGGER users_audit AFTER INSERT OR UPDATE ON users
+ FOR EACH ROW EXECUTE FUNCTION audit_users();
+```
+
+Checklist of the classic omissions — each dies silently with `DROP TABLE`:
+
+- **Incoming foreign keys** from other tables (step 4/6) — list them first:
+ they do not show up on the table you are recreating.
+- **The sequence** behind `bigserial`/`serial` (step 3) — without
+ `OWNED BY` it is dropped and inserts start failing.
+- **Indexes** beyond the primary key, **triggers**, and non-inline
+ **constraints** (step 6).
+- **Column defaults** — declare them in step 1, they are not copied.
+
+Verify the result with `migrate::schema` (columns in order, foreign keys,
+indexes, triggers) before writing the next migration.
diff --git a/skills/SKILL.md b/skills/SKILL.md
index b221603..ccc58c0 100644
--- a/skills/SKILL.md
+++ b/skills/SKILL.md
@@ -40,8 +40,12 @@ migration as skipped, not failed.
- Apply with `migrate::up`, then check the response: when `types_path` is
present the TypeScript types were already regenerated; when it is absent
and the project has a `types_out`, run `migrate::codegen` yourself.
-- Before any schema work, call `migrate::status` and resolve `mismatched`,
- `missing`, or `future_dated` entries before adding new migrations.
+- Before any schema work, call `migrate::status`. `mismatched` and
+ `missing` entries must be resolved first. `future_dated` entries do NOT
+ block you: an applied one is harmless (it expires once the clock catches
+ up), and a pending one is fixed by scaffolding through `migrate::create`
+ as usual — its timestamps are monotonic with existing files, so the new
+ file lands after the future-dated one. Never stall on `future_dated`.
## When to Use
@@ -50,6 +54,12 @@ migration as skipped, not failed.
`migrate::up`.
- You need the current schema state: applied, pending, drifted, or
future-dated files (`migrate::status`, read-only, safe anytime).
+- You wrote migration SQL and want it validated before applying —
+ `migrate::check` runs the exact `migrate::up` parsing without executing
+ anything, and lists every problem at once.
+- You need to verify what a migration actually did — `migrate::schema`
+ reports ordered columns, primary keys, foreign keys, indexes, and
+ triggers; never hand-write `information_schema` queries for that.
- TypeScript code reads rows from `database::query` and needs types that
match the JSON wire format (`migrate::codegen`).
- A `migrate::up` run failed and you need the failing file and statement
@@ -63,6 +73,9 @@ migration as skipped, not failed.
- Dialect-specific SQL is your responsibility (e.g. SQLite cannot
`ALTER COLUMN TYPE`; use the table-recreate pattern inside one migration —
the file runs as a single atomic batch).
+- `database::query` is read-only — a write through it fails with SQLSTATE
+ 25006. Route any ad-hoc write through `database::execute` or
+ `database::transaction`; schema changes always go through migrations.
- For ad-hoc queries or data edits, call the `database` worker directly; for
file or shell operations, use the `shell` worker.
@@ -74,6 +87,10 @@ migration as skipped, not failed.
`types_out` is set), regenerates types after a run that applied anything.
- `migrate::status` — read-only report `{ applied, pending, mismatched,
missing, future_dated }` with names, checksums, and apply dates.
+- `migrate::check` — static validation of pending migrations (splitting,
+ empty files, naming, checksum drift) without executing any SQL; returns
+ `{ ok, pending_checked, invalid_names, mismatched, … }`. Run it after
+ writing SQL, before `migrate::up`.
- `migrate::create` — payload `{ name: "add_users" }`; creates
`
/_add_users.sql` with a unique, monotonic timestamp
and returns `{ name, path }`. Write the SQL into that file before calling
@@ -81,6 +98,10 @@ migration as skipped, not failed.
- `migrate::codegen` — introspect the live schema through `database::query`
and write TypeScript types to the configured `types_out` (or payload
`out`); returns `{ path, tables, enums }`.
+- `migrate::schema` — read-only structured report of the live schema
+ (ordered columns with defaults, primary keys, foreign keys, indexes,
+ triggers, Postgres enums); payload `{}` or `{ table: "users" }`. Use it
+ to verify a migration's effect instead of raw `information_schema` SQL.
Configuration (`db`, `dir`, `auto`, `types_out`, `codegen_on_up`, `dialect`)
lives in the `configuration` worker under id `miiigrate` and is read once at
diff --git a/src/codegen.rs b/src/codegen.rs
index 553e88b..3d12a68 100644
--- a/src/codegen.rs
+++ b/src/codegen.rs
@@ -335,14 +335,16 @@ fn ts_property(name: &str) -> String {
}
}
-fn str_field(row: &Map, key: &str) -> Result {
+pub(crate) fn str_field(row: &Map, key: &str) -> Result {
row.get(key)
.and_then(Value::as_str)
.map(String::from)
.ok_or_else(|| format!("introspection row missing string field `{key}`: {row:?}"))
}
-fn int_field(row: &Map, key: &str) -> Option {
+/// Integer field tolerant of the wire format: int8/cardinal values may
+/// arrive as JSON strings through `database::query`.
+pub(crate) fn int_field(row: &Map, key: &str) -> Option {
let v = row.get(key)?;
v.as_i64()
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
diff --git a/src/db.rs b/src/db.rs
index f4f67b1..991c618 100644
--- a/src/db.rs
+++ b/src/db.rs
@@ -64,11 +64,51 @@ pub enum DbCallError {
message: String,
/// `failed_index` from `DRIVER_ERROR` during a transaction batch.
failed_index: Option,
+ /// Driver-native error code from the body (`inner_code`): the
+ /// Postgres SQLSTATE, or the SQLite extended result code.
+ inner_code: Option,
+ /// `driver` from the body (`postgres`, `sqlite`).
+ driver: Option,
/// Full parsed error body, for embedding in `MIGRATION_FAILED`.
body: Option,
},
}
+/// Build a `Worker` error from a parsed database-worker body, surfacing the
+/// driver-native code (`inner_code`, e.g. a Postgres SQLSTATE) in the message
+/// so callers can diagnose without digging into `database_error`.
+fn worker_error_from_body(body: Value, fallback_message: &str) -> DbCallError {
+ let code = body.get("code").and_then(Value::as_str).map(String::from);
+ let driver = body.get("driver").and_then(Value::as_str).map(String::from);
+ let inner_code = body
+ .get("inner_code")
+ .and_then(Value::as_str)
+ .map(String::from);
+ let failed_index = body
+ .get("failed_index")
+ .and_then(Value::as_u64)
+ .map(|i| i as usize);
+ let base = body
+ .get("message")
+ .and_then(Value::as_str)
+ .unwrap_or(fallback_message);
+ let message = match (&inner_code, driver.as_deref()) {
+ // Postgres inner codes are SQLSTATEs; name them as such — the
+ // five-character code is what the docs and search engines index.
+ (Some(c), Some("postgres") | None) => format!("{base} (SQLSTATE {c})"),
+ (Some(c), Some(d)) => format!("{base} ({d} error code {c})"),
+ (None, _) => base.to_string(),
+ };
+ DbCallError::Worker {
+ code,
+ message,
+ failed_index,
+ inner_code,
+ driver,
+ body: Some(body),
+ }
+}
+
impl DbCallError {
/// Map to the caller-facing error for non-migration contexts (status,
/// introspection). Migration application maps `Worker` variants to
@@ -117,27 +157,13 @@ fn map_sdk_error(e: iii_sdk::errors::Error) -> DbCallError {
// the first `{`.
let json_part = message.find('{').map(|i| &message[i..]).unwrap_or("");
match serde_json::from_str::(json_part) {
- Ok(body) if body.get("code").is_some() => {
- let worker_code = body["code"].as_str().map(String::from);
- let failed_index = body
- .get("failed_index")
- .and_then(Value::as_u64)
- .map(|i| i as usize);
- DbCallError::Worker {
- code: worker_code,
- message: body
- .get("message")
- .and_then(Value::as_str)
- .unwrap_or(&message)
- .to_string(),
- failed_index,
- body: Some(body),
- }
- }
+ Ok(body) if body.get("code").is_some() => worker_error_from_body(body, &message),
_ => DbCallError::Worker {
code: Some(code),
message,
failed_index: None,
+ inner_code: None,
+ driver: None,
body: None,
},
}
@@ -182,6 +208,8 @@ pub async fn query(
code: None,
message: format!("unexpected database::query response shape: {e}"),
failed_index: None,
+ inner_code: None,
+ driver: None,
body: None,
})
}
@@ -225,26 +253,41 @@ pub async fn transaction(
// paths, as `{ committed: false, error, failed_index }` inside an Ok
// response. Normalize the latter to DbCallError::Worker.
if resp.get("committed").and_then(Value::as_bool) == Some(false) {
- let body = resp.get("error").cloned();
- let failed_index = resp
+ let outer_failed_index = resp
.get("failed_index")
.and_then(Value::as_u64)
.map(|i| i as usize);
- let (code, message) = match &body {
- Some(b) => (
- b.get("code").and_then(Value::as_str).map(String::from),
- b.get("message")
- .and_then(Value::as_str)
- .unwrap_or("transaction rolled back")
- .to_string(),
- ),
- None => (None, "transaction rolled back".to_string()),
- };
- return Err(DbCallError::Worker {
- code,
- message,
- failed_index,
- body,
+ return Err(match resp.get("error").cloned() {
+ Some(body) => {
+ // The batch index may live next to `error` rather than inside
+ // it — keep whichever the body itself did not provide.
+ match worker_error_from_body(body, "transaction rolled back") {
+ DbCallError::Worker {
+ code,
+ message,
+ failed_index,
+ inner_code,
+ driver,
+ body,
+ } => DbCallError::Worker {
+ code,
+ message,
+ failed_index: failed_index.or(outer_failed_index),
+ inner_code,
+ driver,
+ body,
+ },
+ other => other,
+ }
+ }
+ None => DbCallError::Worker {
+ code: None,
+ message: "transaction rolled back".to_string(),
+ failed_index: outer_failed_index,
+ inner_code: None,
+ driver: None,
+ body: None,
+ },
});
}
Ok(resp)
@@ -300,6 +343,66 @@ mod tests {
}
}
+ #[test]
+ fn postgres_sqlstate_is_surfaced_in_the_message() {
+ let e = iii_sdk::errors::Error::Remote {
+ code: "invocation_failed".into(),
+ message: r#"handler error: {"code":"DRIVER_ERROR","driver":"postgres","message":"db error","inner_code":"42703"}"#.into(),
+ stacktrace: None,
+ };
+ match map_sdk_error(e) {
+ DbCallError::Worker {
+ message,
+ inner_code,
+ driver,
+ body,
+ ..
+ } => {
+ assert_eq!(message, "db error (SQLSTATE 42703)");
+ assert_eq!(inner_code.as_deref(), Some("42703"));
+ assert_eq!(driver.as_deref(), Some("postgres"));
+ // The raw body keeps everything for MIGRATION_FAILED.
+ assert_eq!(body.unwrap()["inner_code"], "42703");
+ }
+ other => panic!("expected Worker, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn non_postgres_inner_code_is_labeled_with_the_driver() {
+ let e = iii_sdk::errors::Error::Remote {
+ code: "invocation_failed".into(),
+ message: r#"handler error: {"code":"DRIVER_ERROR","driver":"sqlite","message":"constraint failed","inner_code":"1555"}"#.into(),
+ stacktrace: None,
+ };
+ match map_sdk_error(e) {
+ DbCallError::Worker { message, .. } => {
+ assert_eq!(message, "constraint failed (sqlite error code 1555)");
+ }
+ other => panic!("expected Worker, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn message_is_untouched_without_inner_code() {
+ let e = iii_sdk::errors::Error::Remote {
+ code: "invocation_failed".into(),
+ message: r#"handler error: {"code":"UNKNOWN_DB","message":"no such db"}"#.into(),
+ stacktrace: None,
+ };
+ match map_sdk_error(e) {
+ DbCallError::Worker {
+ message,
+ inner_code,
+ ..
+ } => {
+ assert_eq!(message, "no such db");
+ assert!(inner_code.is_none());
+ }
+ other => panic!("expected Worker, got {other:?}"),
+ }
+ }
+
#[test]
fn function_not_found_maps_to_unavailable() {
let e = iii_sdk::errors::Error::Remote {
diff --git a/src/handlers/check.rs b/src/handlers/check.rs
new file mode 100644
index 0000000..6a003a6
--- /dev/null
+++ b/src/handlers/check.rs
@@ -0,0 +1,263 @@
+//! `migrate::check` — static validation of the migrations directory.
+//!
+//! Answers "would `migrate::up` succeed?" without executing any SQL: name
+//! scheme, statement splitting, empty files, checksum drift. The only
+//! network call is the tracking-table read, and it degrades gracefully —
+//! when the database worker is unreachable the static checks still run and
+//! `db_checked: false` says the drift lists are unknown.
+
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use std::collections::BTreeSet;
+use std::path::Path;
+
+use super::status::{future_dated_entries, FutureDatedEntry, MismatchedEntry};
+use super::tracking::{self, AppliedRow};
+use super::AppState;
+use crate::error::MigrateError;
+use crate::migrations::{self, InvalidFile, MigrationScan};
+use crate::splitter::split_statements;
+
+#[derive(Debug, Default, Deserialize, JsonSchema)]
+pub struct CheckReq {}
+
+#[derive(Debug, Serialize, JsonSchema)]
+pub struct PendingCheck {
+ pub name: String,
+ pub ok: bool,
+ /// Number of statements the file splits into (0 when not ok).
+ pub statements: usize,
+ /// Why the file would fail `migrate::up`: splitter error, or empty file.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub error: Option,
+}
+
+#[derive(Debug, Serialize, JsonSchema)]
+pub struct CheckResp {
+ /// False when anything would block or break `migrate::up`: an invalid
+ /// name, an unsplittable or empty pending file, or checksum drift.
+ /// `future_dated` and `missing` are warnings and do not flip this.
+ pub ok: bool,
+ pub dir: String,
+ /// Validation of each pending file (every file on disk when
+ /// `db_checked` is false), in apply order.
+ pub pending_checked: Vec,
+ /// `.sql` files that do not follow the naming scheme — `migrate::up`
+ /// refuses to run while any exist.
+ pub invalid_names: Vec,
+ pub future_dated: Vec,
+ /// Applied files whose content changed on disk — blocks `migrate::up`.
+ pub mismatched: Vec,
+ /// Recorded as applied but no longer on disk (warning).
+ pub missing: Vec,
+ /// False when the database worker was unreachable: static checks only,
+ /// `mismatched` and `missing` are then unknown (empty).
+ pub db_checked: bool,
+}
+
+/// Validate one migration's SQL exactly like `migrate::up` would before
+/// executing: it must split cleanly and contain at least one statement.
+fn check_sql(name: &str, sql: &str) -> PendingCheck {
+ match split_statements(sql) {
+ Err(reason) => PendingCheck {
+ name: name.to_string(),
+ ok: false,
+ statements: 0,
+ error: Some(format!("cannot split migration into statements: {reason}")),
+ },
+ Ok(statements) if statements.is_empty() => PendingCheck {
+ name: name.to_string(),
+ ok: false,
+ statements: 0,
+ error: Some("migration file contains no SQL statements".into()),
+ },
+ Ok(statements) => PendingCheck {
+ name: name.to_string(),
+ ok: true,
+ statements: statements.len(),
+ error: None,
+ },
+ }
+}
+
+/// Pure report assembly. `contents` holds `(name, sql)` for each file to
+/// validate, in apply order; `applied` is `None` when the tracking table
+/// could not be read (database worker down).
+fn build_report(
+ dir: &str,
+ scan: &MigrationScan,
+ contents: &[(String, String)],
+ applied: Option<&[AppliedRow]>,
+ now: chrono::DateTime,
+) -> CheckResp {
+ let applied_names: BTreeSet = applied
+ .map(|rows| rows.iter().map(|r| r.name.clone()).collect())
+ .unwrap_or_default();
+
+ let pending_checked: Vec = contents
+ .iter()
+ .map(|(name, sql)| check_sql(name, sql))
+ .collect();
+
+ let mut mismatched = Vec::new();
+ let mut missing = Vec::new();
+ if let Some(rows) = applied {
+ for row in rows {
+ match scan.valid.iter().find(|f| f.name == row.name) {
+ None => missing.push(row.name.clone()),
+ Some(file) if file.checksum != row.checksum => mismatched.push(MismatchedEntry {
+ name: file.name.clone(),
+ applied_checksum: row.checksum.clone(),
+ file_checksum: file.checksum.clone(),
+ applied_at: row.applied_at.clone(),
+ }),
+ Some(_) => {}
+ }
+ }
+ }
+
+ let ok =
+ scan.invalid.is_empty() && pending_checked.iter().all(|c| c.ok) && mismatched.is_empty();
+
+ CheckResp {
+ ok,
+ dir: dir.to_string(),
+ pending_checked,
+ invalid_names: scan.invalid.clone(),
+ future_dated: future_dated_entries(&scan.valid, &applied_names, now),
+ mismatched,
+ missing,
+ db_checked: applied.is_some(),
+ }
+}
+
+pub async fn handle(state: &AppState, _req: CheckReq) -> Result {
+ let dir = &state.config.dir;
+ let scan = migrations::scan_migrations(Path::new(dir))?;
+
+ // Best-effort tracking read — the single network call of this handler.
+ // A missing database worker must not take static validation down with
+ // it; real errors (bad config, unsupported dialect) still propagate.
+ let applied = match tracking::fetch_applied(state).await {
+ Ok(rows) => Some(rows),
+ Err(MigrateError::DatabaseWorkerUnavailable { message }) => {
+ tracing::warn!(error = %message, "database worker unreachable; static checks only");
+ None
+ }
+ Err(e) => return Err(e),
+ };
+
+ let applied_names: BTreeSet<&str> = applied
+ .as_deref()
+ .map(|rows| rows.iter().map(|r| r.name.as_str()).collect())
+ .unwrap_or_default();
+
+ let mut contents = Vec::new();
+ for file in &scan.valid {
+ if applied_names.contains(file.name.as_str()) {
+ continue; // applied files are covered by the checksum gate
+ }
+ let sql = std::fs::read_to_string(&file.path).map_err(|e| MigrateError::ConfigError {
+ message: format!("reading {}: {e}", file.path.display()),
+ })?;
+ contents.push((file.name.clone(), sql));
+ }
+
+ Ok(build_report(
+ dir,
+ &scan,
+ &contents,
+ applied.as_deref(),
+ chrono::Utc::now(),
+ ))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::migrations::scan_migrations;
+ use serde_json::json;
+
+ fn row(name: &str, checksum: &str) -> AppliedRow {
+ AppliedRow {
+ name: name.into(),
+ checksum: checksum.into(),
+ applied_at: json!("2026-07-23T00:00:00Z"),
+ }
+ }
+
+ #[test]
+ fn valid_pending_file_reports_statement_count() {
+ let report = check_sql("20260101000000_a.sql", "CREATE TABLE a (id int); SELECT 1;");
+ assert!(report.ok);
+ assert_eq!(report.statements, 2);
+ assert!(report.error.is_none());
+ }
+
+ #[test]
+ fn unterminated_dollar_quote_and_empty_files_fail() {
+ let bad = check_sql("20260101000000_a.sql", "CREATE FUNCTION f() AS $$ BEGIN");
+ assert!(!bad.ok);
+ assert!(bad.error.unwrap().contains("cannot split"));
+
+ let empty = check_sql("20260101000000_b.sql", "-- only a comment\n");
+ assert!(!empty.ok);
+ assert!(empty.error.unwrap().contains("no SQL statements"));
+ }
+
+ #[test]
+ fn stray_file_flips_ok_but_does_not_abort() {
+ let tmp = tempfile::tempdir().unwrap();
+ std::fs::write(tmp.path().join("20260101000000_a.sql"), "SELECT 1;").unwrap();
+ std::fs::write(tmp.path().join("notes.sql"), "x").unwrap();
+ let scan = scan_migrations(tmp.path()).unwrap();
+
+ let now = crate::migrations::timestamp_of("20260723080000_now.sql").unwrap();
+ let contents = vec![("20260101000000_a.sql".into(), "SELECT 1;".into())];
+ let report = build_report("./migrations", &scan, &contents, Some(&[]), now);
+
+ assert!(!report.ok);
+ assert_eq!(report.invalid_names.len(), 1);
+ assert_eq!(report.pending_checked.len(), 1);
+ assert!(report.pending_checked[0].ok);
+ assert!(report.db_checked);
+ }
+
+ #[test]
+ fn drift_and_missing_are_classified() {
+ let tmp = tempfile::tempdir().unwrap();
+ std::fs::write(tmp.path().join("20260101000000_a.sql"), "SELECT 1;").unwrap();
+ let scan = scan_migrations(tmp.path()).unwrap();
+ let on_disk = scan.valid[0].checksum.clone();
+
+ let applied = [
+ row("20260101000000_a.sql", "different-checksum"),
+ row("20260102000000_gone.sql", "whatever"),
+ ];
+ let now = crate::migrations::timestamp_of("20260723080000_now.sql").unwrap();
+ let report = build_report("./migrations", &scan, &[], Some(&applied), now);
+
+ assert!(!report.ok); // mismatched blocks up
+ assert_eq!(report.mismatched.len(), 1);
+ assert_eq!(report.mismatched[0].file_checksum, on_disk);
+ assert_eq!(report.missing, vec!["20260102000000_gone.sql".to_string()]);
+ }
+
+ #[test]
+ fn database_down_degrades_to_static_checks() {
+ let tmp = tempfile::tempdir().unwrap();
+ std::fs::write(tmp.path().join("20260723120001_future.sql"), "SELECT 1;").unwrap();
+ let scan = scan_migrations(tmp.path()).unwrap();
+
+ let now = crate::migrations::timestamp_of("20260723080000_now.sql").unwrap();
+ let contents = vec![("20260723120001_future.sql".into(), "SELECT 1;".into())];
+ let report = build_report("./migrations", &scan, &contents, None, now);
+
+ assert!(!report.db_checked);
+ assert!(report.mismatched.is_empty() && report.missing.is_empty());
+ // future_dated is a warning: ok stays true.
+ assert!(report.ok);
+ assert_eq!(report.future_dated.len(), 1);
+ assert!(!report.future_dated[0].applied);
+ }
+}
diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs
index c61151b..a1f4ce0 100644
--- a/src/handlers/mod.rs
+++ b/src/handlers/mod.rs
@@ -1,7 +1,9 @@
//! Function handlers for the `migrate::*` namespace.
+pub mod check;
pub mod codegen;
pub mod create;
+pub mod schema;
pub mod status;
pub mod tracking;
pub mod up;
diff --git a/src/handlers/schema.rs b/src/handlers/schema.rs
new file mode 100644
index 0000000..3271e9a
--- /dev/null
+++ b/src/handlers/schema.rs
@@ -0,0 +1,167 @@
+//! `migrate::schema` — read-only structured report of the live schema.
+//!
+//! The verification companion of `migrate::up`: ordered columns with
+//! defaults, primary keys, foreign keys, indexes, triggers, and Postgres
+//! enums, without hand-writing `information_schema` queries through the CLI.
+
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use serde_json::json;
+
+use super::AppState;
+use crate::codegen::{PG_ENUMS_SQL, SQLITE_TABLES_SQL};
+use crate::config::Dialect;
+use crate::db::{self, DbCallError};
+use crate::error::MigrateError;
+use crate::schema::{
+ pg_schema_from_rows, sqlite_schema_from_rows, EnumSchema, SchemaReport, SqliteIndexRaw,
+ SqliteTableRaw, TableSchema, PG_FOREIGN_KEYS_SQL, PG_INDEXES_SQL, PG_PRIMARY_KEYS_SQL,
+ PG_SCHEMA_COLUMNS_SQL, PG_TRIGGERS_SQL, SQLITE_FOREIGN_KEYS_SQL, SQLITE_INDEX_INFO_SQL,
+ SQLITE_INDEX_LIST_SQL, SQLITE_INDEX_SQL_SQL, SQLITE_SCHEMA_COLUMNS_SQL, SQLITE_TRIGGERS_SQL,
+};
+use crate::TRACKING_TABLE;
+
+#[derive(Debug, Default, Deserialize, JsonSchema)]
+pub struct SchemaReq {
+ /// Restrict the report to one table (exact name). An unknown table
+ /// yields an empty `tables` list, not an error.
+ #[serde(default)]
+ pub table: Option,
+}
+
+#[derive(Debug, Serialize, JsonSchema)]
+pub struct SchemaResp {
+ pub db: String,
+ pub dialect: String,
+ pub tables: Vec,
+ /// Postgres only; always empty on SQLite.
+ pub enums: Vec,
+}
+
+pub async fn handle(state: &AppState, req: SchemaReq) -> Result {
+ let dialect = state.dialect().await?;
+ let report = match dialect {
+ Dialect::Postgres => introspect_postgres(state).await?,
+ Dialect::Sqlite => introspect_sqlite(state, req.table.as_deref()).await?,
+ };
+
+ let tables = match &req.table {
+ Some(name) => report
+ .tables
+ .into_iter()
+ .filter(|t| &t.name == name)
+ .collect(),
+ None => report.tables,
+ };
+
+ Ok(SchemaResp {
+ db: state.config.db.clone(),
+ dialect: dialect.to_string(),
+ tables,
+ enums: report.enums,
+ })
+}
+
+async fn introspect_postgres(state: &AppState) -> Result {
+ let db = &state.config.db;
+ let mut rows = Vec::with_capacity(6);
+ for sql in [
+ PG_SCHEMA_COLUMNS_SQL,
+ PG_PRIMARY_KEYS_SQL,
+ PG_FOREIGN_KEYS_SQL,
+ PG_INDEXES_SQL,
+ PG_TRIGGERS_SQL,
+ PG_ENUMS_SQL,
+ ] {
+ rows.push(
+ db::query(&state.iii, db, sql, vec![])
+ .await
+ .map_err(DbCallError::into_migrate_error)?
+ .rows,
+ );
+ }
+ pg_schema_from_rows(&rows[0], &rows[1], &rows[2], &rows[3], &rows[4], &rows[5])
+ .map_err(|message| MigrateError::ConfigError { message })
+}
+
+async fn introspect_sqlite(
+ state: &AppState,
+ only_table: Option<&str>,
+) -> Result {
+ let db = &state.config.db;
+ let tables = db::query(&state.iii, db, SQLITE_TABLES_SQL, vec![])
+ .await
+ .map_err(DbCallError::into_migrate_error)?;
+
+ let mut raws = Vec::new();
+ for row in &tables.rows {
+ let Some(name) = row.get("name").and_then(|v| v.as_str()) else {
+ continue;
+ };
+ if name == TRACKING_TABLE || only_table.is_some_and(|t| t != name) {
+ continue;
+ }
+
+ let columns = db::query(&state.iii, db, SQLITE_SCHEMA_COLUMNS_SQL, vec![json!(name)])
+ .await
+ .map_err(DbCallError::into_migrate_error)?
+ .rows;
+ let foreign_keys = db::query(&state.iii, db, SQLITE_FOREIGN_KEYS_SQL, vec![json!(name)])
+ .await
+ .map_err(DbCallError::into_migrate_error)?
+ .rows;
+ let triggers = db::query(&state.iii, db, SQLITE_TRIGGERS_SQL, vec![json!(name)])
+ .await
+ .map_err(DbCallError::into_migrate_error)?
+ .rows;
+
+ let index_list = db::query(&state.iii, db, SQLITE_INDEX_LIST_SQL, vec![json!(name)])
+ .await
+ .map_err(DbCallError::into_migrate_error)?
+ .rows;
+ let mut indexes = Vec::new();
+ for list_row in index_list {
+ let Some(index_name) = list_row.get("name").and_then(|v| v.as_str()) else {
+ continue;
+ };
+ let index_name = index_name.to_string();
+ let columns = db::query(
+ &state.iii,
+ db,
+ SQLITE_INDEX_INFO_SQL,
+ vec![json!(index_name)],
+ )
+ .await
+ .map_err(DbCallError::into_migrate_error)?
+ .rows;
+ let sql = db::query(
+ &state.iii,
+ db,
+ SQLITE_INDEX_SQL_SQL,
+ vec![json!(index_name)],
+ )
+ .await
+ .map_err(DbCallError::into_migrate_error)?
+ .rows
+ .first()
+ .and_then(|r| r.get("sql"))
+ .and_then(|v| v.as_str())
+ .map(String::from);
+ indexes.push(SqliteIndexRaw {
+ list_row,
+ columns,
+ sql,
+ });
+ }
+
+ raws.push(SqliteTableRaw {
+ name: name.to_string(),
+ columns,
+ foreign_keys,
+ indexes,
+ triggers,
+ });
+ }
+
+ sqlite_schema_from_rows(&raws).map_err(|message| MigrateError::ConfigError { message })
+}
diff --git a/src/handlers/status.rs b/src/handlers/status.rs
index 1f5d79b..eab8f49 100644
--- a/src/handlers/status.rs
+++ b/src/handlers/status.rs
@@ -7,10 +7,12 @@ use serde_json::Value;
use std::collections::BTreeMap;
use std::path::Path;
+use std::collections::BTreeSet;
+
use super::tracking::{self, AppliedRow};
use super::AppState;
use crate::error::MigrateError;
-use crate::migrations;
+use crate::migrations::{self, MigrationFile};
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct StatusReq {}
@@ -32,6 +34,48 @@ pub struct MismatchedEntry {
pub applied_at: Value,
}
+/// A migration file whose timestamp prefix is ahead of the wall clock —
+/// almost certainly a hand-written name instead of `migrate::create`.
+#[derive(Debug, Serialize, JsonSchema)]
+pub struct FutureDatedEntry {
+ pub name: String,
+ /// Recorded in the tracking table (a drifted file counts as applied).
+ pub applied: bool,
+ /// What to do about it, spelled out.
+ pub hint: String,
+}
+
+/// Remediation hints, keyed on whether the file was already applied.
+const FUTURE_APPLIED_HINT: &str =
+ "applied and unchanged: harmless, expires once the clock catches up";
+const FUTURE_PENDING_HINT: &str = "pending: recreate it via migrate::create — its timestamps \
+ are monotonic with existing files — or rename it by hand before apply";
+
+/// Which of `files` are future-dated at `now`, and were they applied?
+/// Shared with `migrate::check`, which reports the same entries.
+pub(crate) fn future_dated_entries(
+ files: &[MigrationFile],
+ applied_names: &BTreeSet,
+ now: chrono::DateTime,
+) -> Vec {
+ files
+ .iter()
+ .filter(|f| migrations::is_future_dated(&f.name, now))
+ .map(|f| {
+ let applied = applied_names.contains(&f.name);
+ FutureDatedEntry {
+ name: f.name.clone(),
+ applied,
+ hint: if applied {
+ FUTURE_APPLIED_HINT.to_string()
+ } else {
+ FUTURE_PENDING_HINT.to_string()
+ },
+ }
+ })
+ .collect()
+}
+
#[derive(Debug, Serialize, JsonSchema)]
pub struct StatusResp {
pub db: String,
@@ -47,10 +91,10 @@ pub struct StatusResp {
/// Recorded as applied but the file no longer exists on disk.
pub missing: Vec,
/// Files on disk whose timestamp prefix is ahead of the wall clock
- /// (beyond skew tolerance) — almost certainly hand-written names.
- /// Harmless once applied (`migrate::create` stays monotonic), but a
- /// pending one should be renamed before apply.
- pub future_dated: Vec,
+ /// (beyond skew tolerance), with an explicit remediation hint each:
+ /// applied ones are harmless, pending ones should be re-scaffolded via
+ /// `migrate::create` before apply.
+ pub future_dated: Vec,
}
pub async fn handle(state: &AppState, _req: StatusReq) -> Result {
@@ -58,6 +102,9 @@ pub async fn handle(state: &AppState, _req: StatusReq) -> Result = applied_rows.iter().map(|r| r.name.clone()).collect();
let mut applied_by_name: BTreeMap = applied_rows
.into_iter()
.map(|r| (r.name.clone(), r))
@@ -67,12 +114,7 @@ pub async fn handle(state: &AppState, _req: StatusReq) -> Result = files
- .iter()
- .filter(|f| migrations::is_future_dated(&f.name, now))
- .map(|f| f.name.clone())
- .collect();
+ let future_dated = future_dated_entries(&files, &applied_names, chrono::Utc::now());
if !future_dated.is_empty() {
tracing::warn!(
count = future_dated.len(),
@@ -116,3 +158,45 @@ pub async fn handle(state: &AppState, _req: StatusReq) -> Result MigrationFile {
+ MigrationFile {
+ name: name.into(),
+ path: name.into(),
+ checksum: "abc".into(),
+ }
+ }
+
+ #[test]
+ fn future_entries_carry_state_specific_hints() {
+ let now = timestamp_of("20260723080000_now.sql").unwrap();
+ let files = [
+ file("20260101000000_past.sql"),
+ file("20260723080400_within_skew.sql"),
+ file("20260723120000_applied_future.sql"),
+ file("20260723120001_pending_future.sql"),
+ ];
+ let applied: BTreeSet = [
+ "20260101000000_past.sql",
+ "20260723120000_applied_future.sql",
+ ]
+ .into_iter()
+ .map(String::from)
+ .collect();
+
+ let entries = future_dated_entries(&files, &applied, now);
+ // Past and within-tolerance files are not flagged.
+ assert_eq!(entries.len(), 2);
+ assert_eq!(entries[0].name, "20260723120000_applied_future.sql");
+ assert!(entries[0].applied);
+ assert!(entries[0].hint.contains("harmless"));
+ assert_eq!(entries[1].name, "20260723120001_pending_future.sql");
+ assert!(!entries[1].applied);
+ assert!(entries[1].hint.contains("migrate::create"));
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 2fa27cd..f88bfca 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -6,7 +6,8 @@
//!
//! The worker registers under the name `miiigrate`; the functions it exposes
//! live under the `migrate::` namespace (`migrate::up`, `migrate::status`,
-//! `migrate::create`, `migrate::codegen`).
+//! `migrate::check`, `migrate::create`, `migrate::codegen`,
+//! `migrate::schema`).
pub mod codegen;
pub mod config;
@@ -15,6 +16,7 @@ pub mod db;
pub mod error;
pub mod handlers;
pub mod migrations;
+pub mod schema;
pub mod splitter;
/// Worker name — also the registry package, binary, and configuration id.
diff --git a/src/main.rs b/src/main.rs
index 93dc16a..a11bb99 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -7,7 +7,7 @@ use iii_sdk::{register_worker, InitOptions, RegisterFunction};
use miiigrate::config::WorkerConfig;
use miiigrate::configuration;
use miiigrate::error::MigrateError;
-use miiigrate::handlers::{codegen, create, status, up, AppState};
+use miiigrate::handlers::{check, codegen, create, schema, status, up, AppState};
#[derive(Parser, Debug)]
#[command(
@@ -123,6 +123,26 @@ async fn main() -> Result<()> {
);
}
+ {
+ let st = state.clone();
+ iii.register_function(
+ "migrate::check",
+ RegisterFunction::new_async(move |req: check::CheckReq| {
+ let st = st.clone();
+ async move {
+ check::handle(&st, req)
+ .await
+ .map_err(iii_sdk::errors::Error::from)
+ }
+ })
+ .description(
+ "Statically validate migrations without executing any SQL: name \
+ scheme, statement splitting, empty files, checksum drift. \
+ Read-only; keeps working while the database worker is down \
+ (db_checked: false).",
+ ),
+ );
+ }
{
let st = state.clone();
iii.register_function(
@@ -161,11 +181,32 @@ async fn main() -> Result<()> {
);
}
+ {
+ let st = state.clone();
+ iii.register_function(
+ "migrate::schema",
+ RegisterFunction::new_async(move |req: schema::SchemaReq| {
+ let st = st.clone();
+ async move {
+ schema::handle(&st, req)
+ .await
+ .map_err(iii_sdk::errors::Error::from)
+ }
+ })
+ .description(
+ "Read-only structured report of the live schema through \
+ database::query: ordered columns with defaults, primary keys, \
+ foreign keys, indexes, triggers, and Postgres enums. For \
+ verifying what a migration actually did.",
+ ),
+ );
+ }
+
if auto {
run_auto_migration(&state).await;
}
- tracing::info!("miiigrate worker registered 4 functions, waiting for invocations");
+ tracing::info!("miiigrate worker registered 6 functions, waiting for invocations");
wait_for_shutdown_signal().await?;
tracing::info!("miiigrate worker shutting down");
iii.shutdown_async().await;
diff --git a/src/migrations.rs b/src/migrations.rs
index cf66073..c486ced 100644
--- a/src/migrations.rs
+++ b/src/migrations.rs
@@ -103,19 +103,34 @@ pub fn checksum_migration(bytes: &[u8]) -> String {
checksum_bytes(&normalized)
}
-/// List and checksum all `*.sql` files in `dir`, sorted by name.
-///
-/// Errors: `DIR_NOT_FOUND` when the directory is missing,
-/// `INVALID_MIGRATION_NAME` when a `.sql` file does not follow the naming
-/// scheme (a stray file must fail loudly, not be silently skipped — its
-/// ordering relative to the valid files would be undefined). Non-`.sql`
-/// entries and subdirectories are ignored.
-pub fn list_migrations(dir: &Path) -> Result, MigrateError> {
+/// A `.sql` file in the migrations directory whose name does not follow the
+/// naming scheme.
+#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, schemars::JsonSchema)]
+pub struct InvalidFile {
+ pub file: String,
+ pub reason: String,
+}
+
+/// Result of scanning the migrations directory without failing on the first
+/// invalid name. Only I/O problems (missing dir, unreadable file) error out.
+#[derive(Debug, Default)]
+pub struct MigrationScan {
+ /// Well-formed migration files, sorted by name (apply order).
+ pub valid: Vec,
+ /// `.sql` files that do not follow the naming scheme, sorted by file.
+ pub invalid: Vec,
+}
+
+/// Scan and checksum all `*.sql` files in `dir`. Non-`.sql` entries and
+/// subdirectories are ignored; ill-named `.sql` files are reported in
+/// `invalid` rather than aborting the scan, so callers like `migrate::check`
+/// can list every problem at once.
+pub fn scan_migrations(dir: &Path) -> Result {
let entries = std::fs::read_dir(dir).map_err(|_| MigrateError::DirNotFound {
dir: dir.display().to_string(),
})?;
- let mut files = Vec::new();
+ let mut scan = MigrationScan::default();
for entry in entries {
let entry = entry.map_err(|e| MigrateError::ConfigError {
message: format!("reading {}: {e}", dir.display()),
@@ -124,29 +139,46 @@ pub fn list_migrations(dir: &Path) -> Result, MigrateError> {
if !path.is_file() || path.extension().and_then(|e| e.to_str()) != Some("sql") {
continue;
}
- let name = path
- .file_name()
- .and_then(|n| n.to_str())
- .ok_or_else(|| MigrateError::InvalidMigrationName {
+ let Some(name) = path.file_name().and_then(|n| n.to_str()).map(String::from) else {
+ scan.invalid.push(InvalidFile {
file: path.display().to_string(),
reason: "non-UTF-8 file name".into(),
- })?
- .to_string();
- validate_name(&name).map_err(|reason| MigrateError::InvalidMigrationName {
- file: name.clone(),
- reason,
- })?;
+ });
+ continue;
+ };
+ if let Err(reason) = validate_name(&name) {
+ scan.invalid.push(InvalidFile { file: name, reason });
+ continue;
+ }
let bytes = std::fs::read(&path).map_err(|e| MigrateError::ConfigError {
message: format!("reading {}: {e}", path.display()),
})?;
- files.push(MigrationFile {
+ scan.valid.push(MigrationFile {
name,
checksum: checksum_migration(&bytes),
path,
});
}
- files.sort_by(|a, b| a.name.cmp(&b.name));
- Ok(files)
+ scan.valid.sort_by(|a, b| a.name.cmp(&b.name));
+ scan.invalid.sort_by(|a, b| a.file.cmp(&b.file));
+ Ok(scan)
+}
+
+/// List and checksum all `*.sql` files in `dir`, sorted by name.
+///
+/// Errors: `DIR_NOT_FOUND` when the directory is missing,
+/// `INVALID_MIGRATION_NAME` when a `.sql` file does not follow the naming
+/// scheme (a stray file must fail loudly, not be silently skipped — its
+/// ordering relative to the valid files would be undefined).
+pub fn list_migrations(dir: &Path) -> Result, MigrateError> {
+ let scan = scan_migrations(dir)?;
+ if let Some(bad) = scan.invalid.into_iter().next() {
+ return Err(MigrateError::InvalidMigrationName {
+ file: bad.file,
+ reason: bad.reason,
+ });
+ }
+ Ok(scan.valid)
}
#[cfg(test)]
@@ -247,6 +279,19 @@ mod tests {
assert_eq!(names, ["20260101000000_a.sql", "20260202000000_b.sql"]);
}
+ #[test]
+ fn scan_reports_invalid_names_without_failing() {
+ let tmp = tempfile::tempdir().unwrap();
+ std::fs::write(tmp.path().join("20260101000000_a.sql"), "a").unwrap();
+ std::fs::write(tmp.path().join("notes.sql"), "x").unwrap();
+
+ let scan = scan_migrations(tmp.path()).unwrap();
+ assert_eq!(scan.valid.len(), 1);
+ assert_eq!(scan.invalid.len(), 1);
+ assert_eq!(scan.invalid[0].file, "notes.sql");
+ assert_eq!(scan.invalid[0].reason, "name too short");
+ }
+
#[test]
fn stray_sql_file_is_a_hard_error() {
let tmp = tempfile::tempdir().unwrap();
diff --git a/src/schema.rs b/src/schema.rs
new file mode 100644
index 0000000..a185a3d
--- /dev/null
+++ b/src/schema.rs
@@ -0,0 +1,557 @@
+//! Structured schema introspection for `migrate::schema`.
+//!
+//! Where `codegen` reduces the schema to a TypeScript-oriented IR, this
+//! module reports what is actually in the database — ordered columns with
+//! defaults, primary keys, foreign keys, indexes, triggers — so a caller can
+//! verify what a migration did without hand-writing `information_schema`
+//! queries. Everything is pure: `database::query` rows in, [`SchemaReport`]
+//! out, unit-tested from JSON fixtures without a database.
+
+use schemars::JsonSchema;
+use serde::Serialize;
+use serde_json::{Map, Value};
+
+use crate::codegen::{int_field, str_field};
+use crate::TRACKING_TABLE;
+
+#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
+pub struct SchemaReport {
+ pub tables: Vec,
+ /// Postgres only; always empty on SQLite.
+ pub enums: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
+pub struct TableSchema {
+ pub name: String,
+ /// Columns in ordinal (DDL) order.
+ pub columns: Vec,
+ /// Primary-key column names in key order. Empty when the table has none.
+ pub primary_key: Vec,
+ pub foreign_keys: Vec,
+ pub indexes: Vec,
+ pub triggers: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
+pub struct ColumnSchema {
+ pub name: String,
+ /// Dialect-native type (information_schema `data_type` on Postgres, the
+ /// declared type on SQLite).
+ pub data_type: String,
+ pub nullable: bool,
+ /// Default expression as the catalog reports it (`now()`,
+ /// `nextval('users_id_seq'::regclass)`, …).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub default: Option,
+ /// 1-based ordinal position.
+ pub position: u32,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
+pub struct ForeignKey {
+ /// Constraint name on Postgres; SQLite foreign keys are unnamed.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub name: Option,
+ pub columns: Vec,
+ pub references_table: String,
+ /// Empty on SQLite when the key targets the referenced table's implicit
+ /// primary key.
+ pub references_columns: Vec,
+ pub on_delete: String,
+ pub on_update: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
+pub struct Index {
+ pub name: String,
+ pub unique: bool,
+ /// Column names, best-effort; empty for expression indexes — read
+ /// `definition` there.
+ pub columns: Vec,
+ /// Full DDL when the catalog has it (`pg_indexes.indexdef`,
+ /// `sqlite_master.sql`); absent for SQLite auto-indexes backing
+ /// PRIMARY KEY / UNIQUE constraints.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub definition: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
+pub struct Trigger {
+ pub name: String,
+ /// `BEFORE`, `AFTER`, or `INSTEAD OF`.
+ pub timing: String,
+ /// `INSERT` / `UPDATE` / `DELETE`, one trigger may fire on several.
+ pub events: Vec,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub definition: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
+pub struct EnumSchema {
+ pub name: String,
+ pub labels: Vec,
+}
+
+// ---------------------------------------------------------------------------
+// Postgres rows → report
+// ---------------------------------------------------------------------------
+
+/// Columns of the current schema's base tables, with defaults and positions.
+pub const PG_SCHEMA_COLUMNS_SQL: &str = "\
+SELECT c.table_name, c.column_name, c.data_type, c.is_nullable, \
+ c.column_default, c.ordinal_position \
+FROM information_schema.columns c \
+JOIN information_schema.tables t \
+ ON t.table_schema = c.table_schema AND t.table_name = c.table_name \
+WHERE c.table_schema = current_schema() AND t.table_type = 'BASE TABLE' \
+ORDER BY c.table_name, c.ordinal_position";
+
+/// Primary-key columns in key order.
+pub const PG_PRIMARY_KEYS_SQL: &str = "\
+SELECT tc.table_name, kcu.column_name, kcu.ordinal_position \
+FROM information_schema.table_constraints tc \
+JOIN information_schema.key_column_usage kcu \
+ ON kcu.constraint_name = tc.constraint_name \
+ AND kcu.constraint_schema = tc.constraint_schema \
+WHERE tc.table_schema = current_schema() AND tc.constraint_type = 'PRIMARY KEY' \
+ORDER BY tc.table_name, kcu.ordinal_position";
+
+/// Foreign keys with correct multi-column pairing. `information_schema`
+/// cannot express which local column maps to which referenced column, so
+/// this goes through `pg_constraint` and pairs `conkey`/`confkey` with
+/// `unnest … WITH ORDINALITY`.
+pub const PG_FOREIGN_KEYS_SQL: &str = "\
+SELECT rel.relname AS table_name, con.conname AS constraint_name, \
+ att.attname AS column_name, frel.relname AS references_table, \
+ fatt.attname AS references_column, ord.n AS position, \
+ CASE con.confdeltype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' \
+ WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS on_delete, \
+ CASE con.confupdtype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' \
+ WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS on_update \
+FROM pg_constraint con \
+JOIN pg_class rel ON rel.oid = con.conrelid \
+JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace \
+JOIN pg_class frel ON frel.oid = con.confrelid \
+CROSS JOIN LATERAL unnest(con.conkey, con.confkey) WITH ORDINALITY AS ord(attnum, fattnum, n) \
+JOIN pg_attribute att ON att.attrelid = con.conrelid AND att.attnum = ord.attnum \
+JOIN pg_attribute fatt ON fatt.attrelid = con.confrelid AND fatt.attnum = ord.fattnum \
+WHERE con.contype = 'f' AND nsp.nspname = current_schema() \
+ORDER BY rel.relname, con.conname, ord.n";
+
+/// Index definitions of the current schema.
+pub const PG_INDEXES_SQL: &str = "\
+SELECT tablename AS table_name, indexname AS index_name, indexdef AS definition \
+FROM pg_indexes WHERE schemaname = current_schema() \
+ORDER BY tablename, indexname";
+
+/// Triggers, one row per event (aggregated in Rust).
+pub const PG_TRIGGERS_SQL: &str = "\
+SELECT event_object_table AS table_name, trigger_name, \
+ action_timing AS timing, event_manipulation AS event, action_statement \
+FROM information_schema.triggers \
+WHERE trigger_schema = current_schema() \
+ORDER BY event_object_table, trigger_name, event_manipulation";
+
+/// Build the report from `database::query` rows of the five `PG_*_SQL`
+/// queries above plus [`crate::codegen::PG_ENUMS_SQL`]. The tracking table
+/// is excluded everywhere.
+pub fn pg_schema_from_rows(
+ columns: &[Map],
+ pks: &[Map],
+ fks: &[Map],
+ indexes: &[Map],
+ triggers: &[Map],
+ enums: &[Map],
+) -> Result {
+ let mut tables: Vec = Vec::new();
+ for row in columns {
+ let table = str_field(row, "table_name")?;
+ if table == TRACKING_TABLE {
+ continue;
+ }
+ let column = ColumnSchema {
+ name: str_field(row, "column_name")?,
+ data_type: str_field(row, "data_type")?,
+ nullable: str_field(row, "is_nullable")? == "YES",
+ default: opt_str_field(row, "column_default"),
+ position: int_field(row, "ordinal_position").unwrap_or(0) as u32,
+ };
+ match tables.last_mut() {
+ Some(t) if t.name == table => t.columns.push(column),
+ _ => tables.push(TableSchema {
+ name: table,
+ columns: vec![column],
+ primary_key: Vec::new(),
+ foreign_keys: Vec::new(),
+ indexes: Vec::new(),
+ triggers: Vec::new(),
+ }),
+ }
+ }
+
+ // Rows for the tracking table (or anything else not in `tables`) fall
+ // through the lookups below and are dropped, which is the point.
+ for row in pks {
+ let table = str_field(row, "table_name")?;
+ if let Some(t) = tables.iter_mut().find(|t| t.name == table) {
+ t.primary_key.push(str_field(row, "column_name")?);
+ }
+ }
+
+ for row in fks {
+ let table = str_field(row, "table_name")?;
+ let Some(t) = tables.iter_mut().find(|t| t.name == table) else {
+ continue;
+ };
+ let name = str_field(row, "constraint_name")?;
+ let column = str_field(row, "column_name")?;
+ let ref_column = str_field(row, "references_column")?;
+ match t
+ .foreign_keys
+ .last_mut()
+ .filter(|fk| fk.name.as_deref() == Some(name.as_str()))
+ {
+ Some(fk) => {
+ fk.columns.push(column);
+ fk.references_columns.push(ref_column);
+ }
+ None => t.foreign_keys.push(ForeignKey {
+ name: Some(name),
+ columns: vec![column],
+ references_table: str_field(row, "references_table")?,
+ references_columns: vec![ref_column],
+ on_delete: str_field(row, "on_delete")?,
+ on_update: str_field(row, "on_update")?,
+ }),
+ }
+ }
+
+ for row in indexes {
+ let table = str_field(row, "table_name")?;
+ let Some(t) = tables.iter_mut().find(|t| t.name == table) else {
+ continue;
+ };
+ let definition = str_field(row, "definition")?;
+ t.indexes.push(Index {
+ name: str_field(row, "index_name")?,
+ unique: definition.starts_with("CREATE UNIQUE INDEX"),
+ columns: index_columns_from_def(&definition),
+ definition: Some(definition),
+ });
+ }
+
+ for row in triggers {
+ let table = str_field(row, "table_name")?;
+ let Some(t) = tables.iter_mut().find(|t| t.name == table) else {
+ continue;
+ };
+ let name = str_field(row, "trigger_name")?;
+ let event = str_field(row, "event")?;
+ match t.triggers.last_mut().filter(|tr| tr.name == name) {
+ Some(tr) => tr.events.push(event),
+ None => t.triggers.push(Trigger {
+ name,
+ timing: str_field(row, "timing")?,
+ events: vec![event],
+ definition: opt_str_field(row, "action_statement"),
+ }),
+ }
+ }
+
+ let mut enum_schemas: Vec = Vec::new();
+ for row in enums {
+ let name = str_field(row, "enum_name")?;
+ let label = str_field(row, "label")?;
+ match enum_schemas.last_mut() {
+ Some(e) if e.name == name => e.labels.push(label),
+ _ => enum_schemas.push(EnumSchema {
+ name,
+ labels: vec![label],
+ }),
+ }
+ }
+
+ Ok(SchemaReport {
+ tables,
+ enums: enum_schemas,
+ })
+}
+
+/// Best-effort column extraction from a `pg_indexes.indexdef`:
+/// `CREATE [UNIQUE] INDEX name ON table USING btree (a, b DESC)` → `[a, b]`.
+/// Expression indexes return an empty list — the definition is the truth.
+fn index_columns_from_def(def: &str) -> Vec {
+ let Some(open) = def.find('(') else {
+ return Vec::new();
+ };
+ let mut depth = 0usize;
+ let mut end = None;
+ for (i, c) in def[open..].char_indices() {
+ match c {
+ '(' => depth += 1,
+ ')' => {
+ depth -= 1;
+ if depth == 0 {
+ end = Some(open + i);
+ break;
+ }
+ }
+ _ => {}
+ }
+ }
+ let Some(end) = end else {
+ return Vec::new();
+ };
+ let inner = &def[open + 1..end];
+ let mut columns = Vec::new();
+ for part in inner.split(',') {
+ let part = part.trim();
+ if part.contains('(') {
+ return Vec::new(); // expression index
+ }
+ // Strip ordering qualifiers; the first token is the column name.
+ let first = part.split_whitespace().next().unwrap_or_default();
+ let rest_ok = part
+ .split_whitespace()
+ .skip(1)
+ .all(|t| matches!(t, "ASC" | "DESC" | "NULLS" | "FIRST" | "LAST"));
+ if first.is_empty() || !rest_ok {
+ return Vec::new();
+ }
+ columns.push(first.trim_matches('"').to_string());
+ }
+ columns
+}
+
+// ---------------------------------------------------------------------------
+// SQLite rows → report
+// ---------------------------------------------------------------------------
+
+/// One table's columns, with defaults and PK ordinals.
+pub const SQLITE_SCHEMA_COLUMNS_SQL: &str = "SELECT cid, name, type, \"notnull\", dflt_value, pk \
+ FROM pragma_table_info(?) ORDER BY cid";
+
+/// One table's foreign keys; group rows by `id`, order columns by `seq`.
+pub const SQLITE_FOREIGN_KEYS_SQL: &str =
+ "SELECT id, seq, \"table\", \"from\", \"to\", on_update, on_delete \
+ FROM pragma_foreign_key_list(?) ORDER BY id, seq";
+
+/// One table's indexes (auto-indexes included, `origin` says which).
+pub const SQLITE_INDEX_LIST_SQL: &str =
+ "SELECT name, \"unique\", origin FROM pragma_index_list(?) ORDER BY name";
+
+/// One index's columns; `name` is NULL for expression members.
+pub const SQLITE_INDEX_INFO_SQL: &str =
+ "SELECT seqno, name FROM pragma_index_info(?) ORDER BY seqno";
+
+/// DDL of a named index (NULL for constraint-backed auto-indexes).
+pub const SQLITE_INDEX_SQL_SQL: &str =
+ "SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?";
+
+/// Triggers attached to one table.
+pub const SQLITE_TRIGGERS_SQL: &str =
+ "SELECT name, sql FROM sqlite_master WHERE type = 'trigger' AND tbl_name = ? ORDER BY name";
+
+/// Raw introspection rows for one SQLite index.
+pub struct SqliteIndexRaw {
+ /// Row of [`SQLITE_INDEX_LIST_SQL`].
+ pub list_row: Map,
+ /// Rows of [`SQLITE_INDEX_INFO_SQL`].
+ pub columns: Vec