Skip to main content

rustango/migrate/
runner.rs

1//! Apply DDL against a live Postgres pool.
2//!
3//! Two flows live here:
4//!
5//! * [`apply_all`] / [`drop_all`] walk the inventory registry directly
6//!   — useful for fresh-DB bootstrap and tear-down in tests. No file
7//!   I/O, no ledger.
8//! * [`migrate`] applies pending migration files from a directory,
9//!   using the `__rustango_migrations__` ledger table to skip files
10//!   that have already been applied. Each file runs in its own
11//!   transaction by default (Django-style — partial progress across
12//!   files is recoverable).
13
14use std::collections::HashSet;
15use std::path::Path;
16
17use crate::core::{inventory, ModelEntry, ModelSchema};
18use crate::sql::sqlx;
19#[cfg(feature = "postgres")]
20use crate::sql::sqlx::Row;
21// PG-typed shims below import these; sqlite/mysql-only builds get
22// just the `_pool` entry points.
23#[cfg(feature = "postgres")]
24use crate::sql::sqlx::PgPool;
25
26use super::diff::render_changes_split;
27use super::file::{self, Migration, Operation};
28use super::invert::invert;
29use super::progress::{emit, MigrationEvent, MigrationObserver, Outcome};
30use super::snapshot::SchemaSnapshot;
31use super::{ddl, MigrateError};
32
33/// Default bookkeeping-table name — stores one row per applied
34/// migration. Double-underscored to avoid colliding with user
35/// tables. Override per-app via `Builder::ledger`.
36pub const LEDGER_TABLE: &str = "__rustango_migrations__";
37
38/// Ledger for the framework's own ("system app") migrations — the
39/// generated `system/migrations/` chain that stands up the `rustango_*`
40/// tables. Kept separate from [`LEDGER_TABLE`] so the framework's chain and
41/// the project's never collide or renumber each other.
42pub const SYSTEM_LEDGER_TABLE: &str = "__rustango_system_migrations__";
43
44/// Per-app migration runner config. Lets two rustango apps live in
45/// the same Postgres database without colliding on the default
46/// `__rustango_migrations__` ledger table — each app picks its own
47/// ledger name and the runners stay independent.
48///
49/// All verbs are mirrored on the `Builder` so a custom-ledger app
50/// has the same surface as the default free functions:
51///
52/// ```ignore
53/// let mine = Builder::default().ledger("__myapp_migrations__");
54/// mine.migrate(&pool, dir).await?;
55/// mine.applied_set(&pool).await?;
56/// ```
57///
58/// Ledger names must be valid SQL identifiers (`[A-Za-z_][A-Za-z0-9_]*`,
59/// ≤ 63 bytes). `.ledger("…")` panics if not — this is a programming
60/// error caught at config time, not a runtime input.
61#[derive(Debug, Clone, Copy)]
62pub struct Builder {
63    ledger: &'static str,
64}
65
66impl Default for Builder {
67    fn default() -> Self {
68        Self {
69            ledger: LEDGER_TABLE,
70        }
71    }
72}
73
74impl Builder {
75    /// Equivalent to `Builder::default()`. Provided for symmetry with
76    /// the rest of the workspace's builder constructors.
77    #[must_use]
78    pub fn new() -> Self {
79        Self::default()
80    }
81
82    /// Override the ledger table name. Must be a valid SQL
83    /// identifier (`[A-Za-z_][A-Za-z0-9_]*`, ≤ 63 bytes).
84    ///
85    /// # Panics
86    /// Panics if `name` isn't a valid SQL identifier — the ledger
87    /// name is interpolated into DDL (`CREATE TABLE`, `INSERT INTO`,
88    /// `SELECT FROM`) so we refuse anything that could escape
89    /// quoting.
90    #[must_use]
91    pub fn ledger(mut self, name: &'static str) -> Self {
92        validate_ledger_name(name);
93        self.ledger = name;
94        self
95    }
96
97    /// The configured ledger table name.
98    #[must_use]
99    pub fn ledger_name(&self) -> &'static str {
100        self.ledger
101    }
102
103    /// As [`migrate`], with this builder's ledger.
104    ///
105    /// PG-typed back-compat. For non-PG migrations, call
106    /// [`migrate_pool`] directly.
107    ///
108    /// # Errors
109    /// As [`migrate`].
110    #[cfg(feature = "postgres")]
111    pub async fn migrate(&self, pool: &PgPool, dir: &Path) -> Result<Vec<Migration>, MigrateError> {
112        migrate_with_ledger(pool, dir, self.ledger, None).await
113    }
114
115    /// As [`migrate_to`], with this builder's ledger.
116    ///
117    /// # Errors
118    /// As [`migrate_to`].
119    #[cfg(feature = "postgres")]
120    pub async fn migrate_to(
121        &self,
122        pool: &PgPool,
123        dir: &Path,
124        target: &str,
125    ) -> Result<Vec<Migration>, MigrateError> {
126        migrate_to_with_ledger(pool, dir, target, self.ledger).await
127    }
128
129    /// As [`migrate_embedded`], with this builder's ledger.
130    ///
131    /// # Errors
132    /// As [`migrate_embedded`].
133    #[cfg(feature = "postgres")]
134    pub async fn migrate_embedded(
135        &self,
136        pool: &PgPool,
137        embedded: &[(&str, &str)],
138    ) -> Result<Vec<Migration>, MigrateError> {
139        migrate_embedded_with_ledger(pool, embedded, self.ledger).await
140    }
141
142    /// As [`migrate_dry_run`], with this builder's ledger.
143    ///
144    /// # Errors
145    /// As [`migrate_dry_run`].
146    #[cfg(feature = "postgres")]
147    pub async fn migrate_dry_run(
148        &self,
149        pool: &PgPool,
150        dir: &Path,
151    ) -> Result<Vec<MigrationPreview>, MigrateError> {
152        migrate_dry_run_with_ledger(pool, dir, self.ledger).await
153    }
154
155    /// As [`downgrade`], with this builder's ledger.
156    ///
157    /// # Errors
158    /// As [`downgrade`].
159    #[cfg(feature = "postgres")]
160    pub async fn downgrade(
161        &self,
162        pool: &PgPool,
163        dir: &Path,
164        steps: usize,
165    ) -> Result<Vec<Migration>, MigrateError> {
166        downgrade_with_ledger(pool, dir, steps, self.ledger).await
167    }
168
169    /// As [`unapply`], with this builder's ledger.
170    ///
171    /// # Errors
172    /// As [`unapply`].
173    #[cfg(feature = "postgres")]
174    pub async fn unapply(
175        &self,
176        pool: &PgPool,
177        dir: &Path,
178        name: &str,
179    ) -> Result<Migration, MigrateError> {
180        unapply_with_ledger(pool, dir, name, self.ledger).await
181    }
182
183    /// As [`unapply_force`], with this builder's ledger.
184    ///
185    /// # Errors
186    /// As [`unapply_force`].
187    #[cfg(feature = "postgres")]
188    pub async fn unapply_force(
189        &self,
190        pool: &PgPool,
191        dir: &Path,
192        name: &str,
193    ) -> Result<Migration, MigrateError> {
194        unapply_force_with_ledger(pool, dir, name, self.ledger).await
195    }
196
197    /// As [`applied_set`], with this builder's ledger.
198    ///
199    /// # Errors
200    /// As [`applied_set`].
201    #[cfg(feature = "postgres")]
202    pub async fn applied_set(&self, pool: &PgPool) -> Result<HashSet<String>, MigrateError> {
203        applied_set_for(pool, self.ledger).await
204    }
205
206    /// As [`ensure_ledger`], with this builder's ledger.
207    ///
208    /// # Errors
209    /// As [`ensure_ledger`].
210    #[cfg(feature = "postgres")]
211    pub async fn ensure_ledger(&self, pool: &PgPool) -> Result<(), MigrateError> {
212        ensure_ledger_for(pool, self.ledger).await
213    }
214}
215
216fn validate_ledger_name(name: &str) {
217    // SQL identifier syntax: leading letter or `_`, then letters /
218    // digits / `_`. Postgres limits identifiers to 63 bytes.
219    let bytes = name.as_bytes();
220    let valid = !bytes.is_empty()
221        && bytes.len() <= 63
222        && (bytes[0].is_ascii_alphabetic() || bytes[0] == b'_')
223        && bytes
224            .iter()
225            .all(|b| b.is_ascii_alphanumeric() || *b == b'_');
226    assert!(
227        valid,
228        "Builder::ledger({name:?}) is not a valid SQL identifier — \
229         must match [A-Za-z_][A-Za-z0-9_]* and be ≤ 63 bytes"
230    );
231}
232
233/// Postgres advisory-lock key used to serialize concurrent
234/// `migrate` / `migrate_to` / `unapply` / `downgrade` /
235/// `migrate_embedded` calls across processes. Without this lock,
236/// peer boots both query `applied_set`, both see the same pending
237/// list, both try to apply it, and one loses the race with a
238/// `relation already exists` or PK violation on the ledger INSERT.
239///
240/// "RUSTMIGT" in ASCII hex.
241#[cfg(any(feature = "postgres", feature = "mysql"))]
242const MIGRATE_LOCK_KEY: i64 = 0x5255_5354_4d49_4754;
243
244/// Collect every registered model's schema into a `Vec`. Order is the
245/// order of registration (linker order); callers that care should sort.
246#[must_use]
247pub fn registered_models() -> Vec<&'static ModelSchema> {
248    inventory::iter::<ModelEntry>
249        .into_iter()
250        .map(|e| e.schema)
251        .collect()
252}
253
254/// The subset of [`registered_models`] the inventory-walk bootstrap
255/// (`apply_all*` / `drop_all*`) should touch: framework-managed tables
256/// only. Django `Meta.managed = False` (#321) means the framework
257/// neither creates nor drops the table — the snapshot / migration path
258/// already filters these (see `snapshot.rs`), and the bootstrap walk
259/// must match. Otherwise a `managed = false` model (e.g.
260/// `rustango_translations`, which owns its schema via its own
261/// `ensure_table`) would get a second, schema-mismatched `CREATE TABLE`
262/// emitted from its field metadata here.
263fn bootstrap_models() -> Vec<&'static ModelSchema> {
264    registered_models()
265        .into_iter()
266        .filter(|m| m.managed)
267        .collect()
268}
269
270/// Run `CREATE TABLE` for every registered model, then every model's FK
271/// `ALTER TABLE` constraints. Two-phase so create order doesn't matter.
272/// PG-typed back-compat; for non-PG use [`apply_all_pool`].
273///
274/// # Errors
275/// Returns [`MigrateError`] for any sqlx failure (connection, syntax,
276/// constraint violation).
277#[cfg(feature = "postgres")]
278pub async fn apply_all(pool: &PgPool) -> Result<(), MigrateError> {
279    // `signals` is optional, so the pre/post_migrate emissions are gated
280    // (#1208) — they were unconditional while the module is not.
281    #[cfg(feature = "signals")]
282    use crate::signals::migrate::{
283        send_post_migrate, send_pre_migrate, PostMigrateContext, PreMigrateContext,
284    };
285    #[cfg(feature = "signals")]
286    send_pre_migrate(PreMigrateContext {
287        source: "apply_all",
288    })
289    .await;
290    let models = bootstrap_models();
291
292    for model in &models {
293        let sql = ddl::create_table_sql(model);
294        sqlx::query(&sql).execute(pool).await?;
295    }
296    for model in &models {
297        for sql in ddl::create_constraints_sql(model) {
298            sqlx::query(&sql).execute(pool).await?;
299        }
300    }
301    // #411 — post_migrate fires once after the bootstrap walk
302    // completes successfully.
303    #[cfg(feature = "signals")]
304    send_post_migrate(PostMigrateContext {
305        source: "apply_all",
306        applied: Vec::new(),
307    })
308    .await;
309    Ok(())
310}
311
312/// `DROP TABLE IF EXISTS … CASCADE` for every registered model. CASCADE
313/// makes order irrelevant — FKs go away with the parent table.
314/// PG-typed back-compat; for non-PG use [`drop_all_pool`].
315///
316/// # Errors
317/// Returns [`MigrateError`] for any sqlx failure.
318#[cfg(feature = "postgres")]
319pub async fn drop_all(pool: &PgPool) -> Result<(), MigrateError> {
320    for model in bootstrap_models() {
321        let sql = ddl::drop_table_sql(model, /* if_exists */ true, /* cascade */ true);
322        sqlx::query(&sql).execute(pool).await?;
323    }
324    Ok(())
325}
326
327/// `apply_all` against either backend. Equivalent to [`apply_all`] but
328/// takes [`crate::sql::Pool`] and dispatches per backend — uses the
329/// dialect-aware DDL emitters from
330/// [`crate::migrate::ddl::create_table_sql_with_dialect`] +
331/// [`crate::migrate::ddl::create_constraints_sql_with_dialect`], so
332/// MySQL gets backticks + `TINYINT(1)` + `BIGINT AUTO_INCREMENT` etc.
333///
334/// Useful for dev bootstrap, ephemeral test databases, and one-shot
335/// CLI tools — for production schema evolution use the file-based
336/// [`migrate`] runner (still PG-only; bi-dialect ledger path lands
337/// in a follow-up batch).
338///
339/// # Errors
340/// As [`apply_all`].
341pub async fn apply_all_pool(pool: &crate::sql::Pool) -> Result<(), MigrateError> {
342    #[cfg(feature = "signals")]
343    use crate::signals::migrate::{
344        send_post_migrate, send_pre_migrate, PostMigrateContext, PreMigrateContext,
345    };
346    #[cfg(feature = "signals")]
347    send_pre_migrate(PreMigrateContext {
348        source: "apply_all_pool",
349    })
350    .await;
351    let dialect = pool.dialect();
352    let models = bootstrap_models();
353    for model in &models {
354        let sql = ddl::create_table_sql_with_dialect(dialect, model);
355        crate::sql::raw_execute_pool(pool, &sql, ::std::vec::Vec::new()).await?;
356    }
357    for model in &models {
358        for sql in ddl::create_constraints_sql_with_dialect(dialect, model) {
359            crate::sql::raw_execute_pool(pool, &sql, ::std::vec::Vec::new()).await?;
360        }
361    }
362    // #450 — post-hoc `COMMENT ON COLUMN` for dialects that need it
363    // (Postgres). MySQL already inlined comments in CREATE TABLE;
364    // SQLite returns an empty vec (no native column comments).
365    for model in &models {
366        for sql in ddl::column_comment_statements_with_dialect(dialect, model) {
367            crate::sql::raw_execute_pool(pool, &sql, ::std::vec::Vec::new()).await?;
368        }
369    }
370    // Django Meta.db_table_comment — same shape as column-level:
371    // PG emits a post-hoc `COMMENT ON TABLE`, MySQL inlined it in
372    // CREATE TABLE, SQLite emits nothing.
373    for model in &models {
374        for sql in ddl::table_comment_statements_with_dialect(dialect, model) {
375            crate::sql::raw_execute_pool(pool, &sql, ::std::vec::Vec::new()).await?;
376        }
377    }
378    // #411 — post_migrate fires once after the bootstrap walk
379    // completes. `applied` is empty because apply_all_pool doesn't
380    // carry per-migration names — it walks the model inventory.
381    #[cfg(feature = "signals")]
382    send_post_migrate(PostMigrateContext {
383        source: "apply_all_pool",
384        applied: Vec::new(),
385    })
386    .await;
387    Ok(())
388}
389
390/// `drop_all` against either backend. Equivalent to [`drop_all`] but
391/// takes [`crate::sql::Pool`].
392///
393/// MySQL caveat: `DROP TABLE … CASCADE` is rejected by MySQL's parser
394/// (MySQL drops cascade FK constraints automatically and doesn't take
395/// the keyword). For now this routes the cascade flag through PG only;
396/// MySQL gets `DROP TABLE IF EXISTS` without it. A future batch will
397/// add `Dialect::supports_drop_cascade()` to gate the keyword cleanly.
398///
399/// # Errors
400/// As [`drop_all`].
401pub async fn drop_all_pool(pool: &crate::sql::Pool) -> Result<(), MigrateError> {
402    let dialect = pool.dialect();
403    // Cascade only emitted for PG — MySQL parses it as syntax error.
404    let cascade = dialect.name() == "postgres";
405    let models = bootstrap_models();
406    // Phase 1 — drop FK constraints, so table drop order can't matter.
407    // The mirror of `apply_all`'s two-phase create. Without it MySQL, which
408    // enforces FKs and has no `DROP TABLE … CASCADE`, fails as soon as a
409    // parent is dropped before its child (#1277). Best-effort: a constraint
410    // may already be absent, and only PG can say `IF EXISTS` here.
411    for model in &models {
412        for sql in ddl::drop_constraints_sql_with_dialect(dialect, model) {
413            let _ = crate::sql::raw_execute_pool(pool, &sql, ::std::vec::Vec::new()).await;
414        }
415    }
416    // Phase 2 — drop the tables themselves.
417    for model in &models {
418        let sql =
419            ddl::drop_table_sql_with_dialect(dialect, model, /* if_exists */ true, cascade);
420        crate::sql::raw_execute_pool(pool, &sql, ::std::vec::Vec::new()).await?;
421    }
422    Ok(())
423}
424
425/// Ensure the ledger table exists, then apply every pending migration
426/// in `dir` (lex-sorted by name) to `pool`. Already-applied migrations
427/// are skipped.
428///
429/// Each migration runs in its own transaction unless its `atomic`
430/// field is `false` (e.g. for `CREATE INDEX CONCURRENTLY`). On
431/// failure within an atomic migration the file's changes roll back
432/// cleanly; **prior** files stay applied (their commits already
433/// happened), so re-running `migrate` after fixing the offender will
434/// pick up where it left off.
435///
436/// Returns the migrations that were newly applied (could be empty).
437///
438/// # Errors
439/// Returns [`MigrateError::Io`]/[`MigrateError::Json`]/[`MigrateError::Validation`]
440/// for file problems, [`MigrateError::Driver`] for SQL failures.
441#[cfg(feature = "postgres")]
442pub async fn migrate(pool: &PgPool, dir: &Path) -> Result<Vec<Migration>, MigrateError> {
443    Builder::default().migrate(pool, dir).await
444}
445
446/// [`migrate`], reporting each migration to `observer` as it starts and
447/// finishes.
448///
449/// The PG-typed sibling of [`migrate_pool_with_progress`]. Kept separate
450/// rather than routed through it because this path is not the same code:
451/// it fires the `pre_migrate` / `post_migrate` signals and uses the
452/// PG-typed ledger helpers.
453///
454/// **The observer is called with the migrate lock held**, so it must not
455/// block; see [the module docs](super::progress#observers-must-not-block).
456///
457/// # Errors
458/// As [`migrate`].
459#[cfg(feature = "postgres")]
460pub async fn migrate_with_progress(
461    pool: &PgPool,
462    dir: &Path,
463    observer: &dyn MigrationObserver,
464) -> Result<Vec<Migration>, MigrateError> {
465    migrate_with_ledger(pool, dir, LEDGER_TABLE, Some(observer)).await
466}
467
468#[cfg(feature = "postgres")]
469async fn migrate_with_ledger(
470    pool: &PgPool,
471    dir: &Path,
472    ledger: &str,
473    observer: Option<&dyn MigrationObserver>,
474) -> Result<Vec<Migration>, MigrateError> {
475    #[cfg(feature = "signals")]
476    use crate::signals::migrate::{
477        send_post_migrate, send_pre_migrate, PostMigrateContext, PreMigrateContext,
478    };
479    #[cfg(feature = "signals")]
480    send_pre_migrate(PreMigrateContext { source: "migrate" }).await;
481    ensure_ledger_for(pool, ledger).await?;
482    let newly = with_migrate_lock(pool, async {
483        let all = file::list_dir(dir)?;
484        let applied = applied_set_for(pool, ledger).await?;
485        let pending = pending_migrations(all, &applied);
486
487        let total = pending.len();
488        emit(observer, || MigrationEvent::Planned { total });
489
490        let mut newly = Vec::with_capacity(total);
491        // Squash reconciliation applies on this legacy PgPool entry point
492        // too — route through the same dialect-agnostic decision used by
493        // `migrate_pool` so both runners agree. `fake_initial` stays off
494        // here: table-existence faking is opted into only by the
495        // framework's system-migration path.
496        let enum_pool = crate::sql::Pool::Postgres(pool.clone());
497        for (i, mig) in pending.into_iter().enumerate() {
498            let index = i + 1;
499            emit(observer, || MigrationEvent::Started {
500                name: mig.name.clone(),
501                index,
502                total,
503            });
504            let began = std::time::Instant::now();
505
506            let step = async {
507                Ok::<_, MigrateError>(match reconcile(&enum_pool, &mig, &applied, false).await? {
508                    ReconcileAction::Fake => {
509                        fake_apply_pool(&enum_pool, &mig, ledger).await?;
510                        Outcome::Faked
511                    }
512                    // `fake_initial` is off on this path, so
513                    // `RunPartial` is unreachable here; treat it as a
514                    // plain run for totality.
515                    ReconcileAction::Run | ReconcileAction::RunPartial(_) => {
516                        apply_one(pool, &mig, ledger).await?;
517                        Outcome::Ran
518                    }
519                })
520            }
521            .await;
522
523            let outcome = match step {
524                Ok(outcome) => outcome,
525                Err(e) => {
526                    emit(observer, || MigrationEvent::Failed {
527                        name: mig.name.clone(),
528                        index,
529                        total,
530                        error: e.to_string(),
531                    });
532                    return Err(e);
533                }
534            };
535
536            emit(observer, || MigrationEvent::Finished {
537                name: mig.name.clone(),
538                index,
539                total,
540                outcome,
541                elapsed: began.elapsed(),
542            });
543            newly.push(mig);
544        }
545        Ok(newly)
546    })
547    .await?;
548    // #411 — post_migrate fires once after the file-based migrate
549    // session completes. `applied` lists newly-applied migration
550    // names (empty when everything was already applied).
551    #[cfg(feature = "signals")]
552    send_post_migrate(PostMigrateContext {
553        source: "migrate",
554        applied: newly.iter().map(|m| m.name.clone()).collect(),
555    })
556    .await;
557    Ok(newly)
558}
559
560/// Hold the migrate advisory lock for the duration of `body`, then
561/// release it (best-effort) before returning. Peers calling any
562/// migrate-shaped operation block until the holder releases.
563///
564/// The lock is **session-scoped**, so we acquire a dedicated
565/// connection from the pool, hold it for the whole body, and
566/// explicitly unlock before dropping it back to the pool. (Dropping
567/// alone wouldn't release, since pooled connections survive between
568/// uses.)
569#[cfg(feature = "postgres")]
570async fn with_migrate_lock<F, R>(pool: &PgPool, body: F) -> Result<R, MigrateError>
571where
572    F: std::future::Future<Output = Result<R, MigrateError>>,
573{
574    use crate::sql::{Dialect as _, Postgres};
575    // Dialect dispatch: Postgres returns the `pg_advisory_lock` SQL;
576    // SQLite (when added in slice 10.5) returns `None` and the body
577    // runs without a session lock (SQLite's single-writer model
578    // achieves the same exclusion via `BEGIN EXCLUSIVE`).
579    let dialect = Postgres;
580    let mut lock_conn = pool.acquire().await?;
581    if let Some(acquire_sql) = dialect.acquire_session_lock_sql() {
582        sqlx::query(&acquire_sql)
583            .bind(MIGRATE_LOCK_KEY)
584            .execute(&mut *lock_conn)
585            .await?;
586    }
587    let result = body.await;
588    // Always try to release. If unlock fails (e.g. connection died),
589    // Postgres releases on session close so we won't deadlock peers
590    // forever — and we want the original error from `result` to
591    // propagate, not a noisy unlock error.
592    if let Some(release_sql) = dialect.release_session_lock_sql() {
593        let _ = sqlx::query(&release_sql)
594            .bind(MIGRATE_LOCK_KEY)
595            .execute(&mut *lock_conn)
596            .await;
597    }
598    result
599}
600
601/// Set of migration names already recorded in the default ledger
602/// table (`__rustango_migrations__`). For a custom ledger, build a
603/// [`Builder`] with `.ledger("…")` and call its `applied_set` method.
604///
605/// # Errors
606/// Returns [`MigrateError::Driver`] for any sqlx failure (including a
607/// missing ledger table — call [`ensure_ledger`] first).
608#[cfg(feature = "postgres")]
609pub async fn applied_set(pool: &PgPool) -> Result<HashSet<String>, MigrateError> {
610    applied_set_for(pool, LEDGER_TABLE).await
611}
612
613#[cfg(feature = "postgres")]
614async fn applied_set_for(pool: &PgPool, ledger: &str) -> Result<HashSet<String>, MigrateError> {
615    let rows = sqlx::query(&format!("SELECT name FROM {ledger}"))
616        .fetch_all(pool)
617        .await?;
618    let mut out = HashSet::with_capacity(rows.len());
619    for row in rows {
620        out.insert(row.try_get::<String, _>("name")?);
621    }
622    Ok(out)
623}
624
625/// Bootstrap the default ledger table (`__rustango_migrations__`)
626/// if it doesn't exist. Idempotent and safe to run from concurrent
627/// processes — Postgres' `CREATE TABLE IF NOT EXISTS` is *not*
628/// race-free against concurrent creators (they can both pass the
629/// existence check and then collide on the catalog), so the
630/// bootstrap is serialized via a transaction-scoped advisory lock.
631///
632/// For a custom ledger, build a [`Builder`] with `.ledger("…")` and
633/// call its `ensure_ledger` method.
634///
635/// # Errors
636/// Returns [`MigrateError::Driver`] for any sqlx failure.
637#[cfg(feature = "postgres")]
638pub async fn ensure_ledger(pool: &PgPool) -> Result<(), MigrateError> {
639    ensure_ledger_for(pool, LEDGER_TABLE).await
640}
641
642#[cfg(feature = "postgres")]
643async fn ensure_ledger_for(pool: &PgPool, ledger: &str) -> Result<(), MigrateError> {
644    use crate::sql::{Dialect as _, Postgres};
645    // Stable arbitrary key — must be the same every call. "RUST" in ASCII hex.
646    const LOCK_KEY: i64 = 0x5255_5354;
647    let dialect = Postgres;
648    let mut tx = pool.begin().await?;
649    // Postgres returns `pg_advisory_xact_lock(...)`. SQLite returns
650    // `None` (its `BEGIN` already gates concurrent CREATE TABLE).
651    if let Some(xact_lock_sql) = dialect.acquire_xact_lock_sql() {
652        sqlx::query(&xact_lock_sql)
653            .bind(LOCK_KEY)
654            .execute(&mut *tx)
655            .await?;
656    }
657    let create_sql = format!(
658        "CREATE TABLE IF NOT EXISTS {ledger} (\
659         name TEXT PRIMARY KEY, \
660         applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW())"
661    );
662    sqlx::query(&create_sql).execute(&mut *tx).await?;
663    tx.commit().await?;
664    Ok(())
665}
666
667/// One pending migration the dry-run would apply.
668///
669/// `statements` is the literal SQL the runner would execute, in
670/// order: each `SchemaChange` op's immediate DDL, each `DataOp`'s
671/// `sql`, then any deferred FK ALTERs, then the
672/// `INSERT INTO __rustango_migrations__` ledger row. Atomic
673/// migrations also get synthetic `BEGIN`/`COMMIT` markers so the
674/// reader can see where the transaction boundary is.
675#[derive(Debug, Clone, PartialEq, Eq)]
676pub struct MigrationPreview {
677    pub name: String,
678    /// `true` if the migration would run inside a transaction (the
679    /// `atomic` flag on the file).
680    pub atomic: bool,
681    pub statements: Vec<String>,
682}
683
684/// Compute the SQL `migrate(pool, dir)` would execute, without
685/// running any of it. Reads the ledger to know what's pending; never
686/// writes. Output is one [`MigrationPreview`] per pending migration,
687/// in apply order.
688///
689/// Used by `manage migrate --dry-run`.
690///
691/// # Errors
692/// As [`migrate`] minus the SQL execution — file I/O, JSON parse,
693/// chain validation, plus the `applied_set` read.
694#[cfg(feature = "postgres")]
695pub async fn migrate_dry_run(
696    pool: &PgPool,
697    dir: &Path,
698) -> Result<Vec<MigrationPreview>, MigrateError> {
699    Builder::default().migrate_dry_run(pool, dir).await
700}
701
702#[cfg(feature = "postgres")]
703async fn migrate_dry_run_with_ledger(
704    pool: &PgPool,
705    dir: &Path,
706    ledger: &str,
707) -> Result<Vec<MigrationPreview>, MigrateError> {
708    ensure_ledger_for(pool, ledger).await?;
709    let all = file::list_dir(dir)?;
710    let applied = applied_set_for(pool, ledger).await?;
711    let pending: Vec<Migration> = all
712        .into_iter()
713        .filter(|m| !applied.contains(&m.name))
714        .collect();
715
716    let mut out = Vec::with_capacity(pending.len());
717    for mig in pending {
718        out.push(preview_migration(&mig, ledger)?);
719    }
720    Ok(out)
721}
722
723/// Django-shape `sqlmigrate <name>` — Compute the SQL the named
724/// migration would emit when applied, without touching the database.
725/// Pure file I/O + render — no ledger read required.
726///
727/// Issue #345. Use from `manage sqlmigrate <name>`.
728///
729/// # Errors
730/// - [`MigrateError::Validation`] when `name` is not present in `dir`.
731/// - Any IO / parse error from [`file::list_dir`].
732pub fn sqlmigrate_one(dir: &Path, name: &str) -> Result<MigrationPreview, MigrateError> {
733    let all = file::list_dir(dir)?;
734    let mig = all.into_iter().find(|m| m.name == name).ok_or_else(|| {
735        MigrateError::Validation(format!("migration `{name}` not found in {}", dir.display()))
736    })?;
737    preview_migration(&mig, LEDGER_TABLE)
738}
739
740/// #347 — invoke a named migration callback. Looks the name up in
741/// the inventory registry and `await`s the future. Unknown names
742/// surface as `MigrateError::Validation` so the operator gets a clear
743/// pointer to the missing `register_migration_callback!` call.
744async fn invoke_migration_callback(
745    op: &crate::migrate::file::CallbackOp,
746    pool: crate::sql::Pool,
747) -> Result<(), MigrateError> {
748    let cb = crate::migrate::callbacks::find(&op.name).ok_or_else(|| {
749        MigrateError::Validation(format!(
750            "migration callback `{}` is not registered — \
751             call `rustango::register_migration_callback!(\"{0}\", …)` \
752             at startup",
753            op.name,
754        ))
755    })?;
756    (cb.forward)(pool).await
757}
758
759/// Build a [`MigrationPreview`] for a single migration. Pure —
760/// no DB access. Same render path as `apply_atomic` / `apply_loose`
761/// but the statements stream into a `Vec<String>` instead of a tx.
762fn preview_migration(mig: &Migration, ledger: &str) -> Result<MigrationPreview, MigrateError> {
763    let mut statements = Vec::new();
764    let mut deferred_fks: Vec<String> = Vec::new();
765    if mig.atomic {
766        statements.push("BEGIN".to_string());
767    }
768    for op in &mig.forward {
769        match op {
770            Operation::Schema(change) => {
771                let batch = render_changes_split(std::slice::from_ref(change), &mig.snapshot)
772                    .map_err(MigrateError::Validation)?;
773                statements.extend(batch.immediate);
774                deferred_fks.extend(batch.deferred_fks);
775            }
776            Operation::Data(d) => {
777                statements.push(d.sql.clone());
778            }
779            Operation::Callback(c) => {
780                // #347 — RunPython preview. The callback body isn't
781                // SQL, so the preview emits a comment marker so
782                // operators can see WHERE the side effect lands in
783                // the apply order.
784                statements.push(format!("-- RunPython: {}", c.name));
785            }
786        }
787    }
788    statements.extend(deferred_fks);
789    statements.push(format!(
790        "INSERT INTO {ledger} (name) VALUES ('{}')",
791        mig.name.replace('\'', "''")
792    ));
793    if mig.atomic {
794        statements.push("COMMIT".to_string());
795    }
796    Ok(MigrationPreview {
797        name: mig.name.clone(),
798        atomic: mig.atomic,
799        statements,
800    })
801}
802
803#[cfg(feature = "postgres")]
804async fn apply_atomic(pool: &PgPool, mig: &Migration, ledger: &str) -> Result<(), MigrateError> {
805    tracing::info!(migration = %mig.name, "applying (atomic)");
806    let mut tx = pool.begin().await?;
807    let mut deferred_fks: Vec<String> = Vec::new();
808    for op in &mig.forward {
809        match op {
810            Operation::Schema(change) => {
811                let batch = render_changes_split(std::slice::from_ref(change), &mig.snapshot)
812                    .map_err(MigrateError::Validation)?;
813                for stmt in batch.immediate {
814                    sqlx::query(&stmt).execute(&mut *tx).await?;
815                }
816                deferred_fks.extend(batch.deferred_fks);
817            }
818            Operation::Data(d) => {
819                sqlx::query(&d.sql).execute(&mut *tx).await?;
820            }
821            Operation::Callback(c) => {
822                // #347 — RunPython runs OUTSIDE the migration's tx
823                // because our callback signature takes an owned `Pool`
824                // (drives its own connections). Document the limitation
825                // — operators who want atomicity should set
826                // `atomic: false` on the migration and manage their
827                // own transactions.
828                invoke_migration_callback(c, pool.clone().into()).await?;
829            }
830        }
831    }
832    for stmt in deferred_fks {
833        sqlx::query(&stmt).execute(&mut *tx).await?;
834    }
835    sqlx::query(&format!("INSERT INTO {ledger} (name) VALUES ($1)"))
836        .bind(&mig.name)
837        .execute(&mut *tx)
838        .await?;
839    tx.commit().await?;
840    Ok(())
841}
842
843/// Move the database to a specific migration target — forward or back.
844///
845/// Compares `target` to the current head (lex-greatest applied
846/// migration name in `dir`) and walks the right direction:
847///
848/// * `target > head` → apply pending migrations whose name lies in
849///   `(head, target]`, in lex order.
850/// * `target == head` → no-op.
851/// * `target < head` → unapply migrations whose name lies in
852///   `(target, head]`, in **reverse** lex order.
853/// * `target == "zero"` → unapply every applied migration. Special-
854///   cased so users have a stable way to wipe the schema's migration
855///   state without having to think about which file is "earliest".
856///
857/// Returns the migrations that were applied or unapplied (the caller
858/// can compare against [`applied_set`] before/after to infer
859/// direction). Returns an empty `Vec` if the target was already the
860/// current head.
861///
862/// # Errors
863/// * [`MigrateError::Validation`] if `target` doesn't match any file
864///   in `dir` (and isn't `"zero"`).
865/// * Any error [`migrate`] or [`unapply`] would raise.
866#[cfg(feature = "postgres")]
867pub async fn migrate_to(
868    pool: &PgPool,
869    dir: &Path,
870    target: &str,
871) -> Result<Vec<Migration>, MigrateError> {
872    Builder::default().migrate_to(pool, dir, target).await
873}
874
875#[cfg(feature = "postgres")]
876async fn migrate_to_with_ledger(
877    pool: &PgPool,
878    dir: &Path,
879    target: &str,
880    ledger: &str,
881) -> Result<Vec<Migration>, MigrateError> {
882    ensure_ledger_for(pool, ledger).await?;
883    with_migrate_lock(pool, async {
884        let all = file::list_dir(dir)?;
885        let applied = applied_set_for(pool, ledger).await?;
886
887        if target == "zero" {
888            return unapply_all_in_order(pool, dir, &all, &applied, ledger).await;
889        }
890
891        if !all.iter().any(|m| m.name == target) {
892            return Err(MigrateError::Validation(format!(
893                "target migration `{target}` not found in {}",
894                dir.display()
895            )));
896        }
897
898        let head = all
899            .iter()
900            .rev()
901            .find(|m| applied.contains(&m.name))
902            .map(|m| m.name.clone());
903
904        let mut touched = Vec::new();
905        match head {
906            None => {
907                // Nothing applied — forward up to and including target.
908                for mig in all.into_iter().filter(|m| m.name.as_str() <= target) {
909                    apply_one(pool, &mig, ledger).await?;
910                    touched.push(mig);
911                }
912            }
913            Some(h) => {
914                use std::cmp::Ordering;
915                match target.cmp(h.as_str()) {
916                    Ordering::Equal => {}
917                    Ordering::Greater => {
918                        for mig in all.into_iter().filter(|m| {
919                            m.name.as_str() > h.as_str()
920                                && m.name.as_str() <= target
921                                && !applied.contains(&m.name)
922                        }) {
923                            apply_one(pool, &mig, ledger).await?;
924                            touched.push(mig);
925                        }
926                    }
927                    Ordering::Less => {
928                        let mut to_unapply: Vec<Migration> = all
929                            .into_iter()
930                            .filter(|m| {
931                                m.name.as_str() > target
932                                    && m.name.as_str() <= h.as_str()
933                                    && applied.contains(&m.name)
934                            })
935                            .collect();
936                        to_unapply.reverse();
937                        for mig in to_unapply {
938                            unapply_locked(pool, dir, &mig.name, ledger).await?;
939                            touched.push(mig);
940                        }
941                    }
942                }
943            }
944        }
945        Ok(touched)
946    })
947    .await
948}
949
950/// Apply pending migrations from an in-memory `&[(name, json)]` slice.
951///
952/// Built for deployments where shipping a `migrations/` folder
953/// alongside the binary is awkward (Docker images, single-binary
954/// distribution). Pair with the [`embed_migrations!`] proc-macro from
955/// `rustango-macros` (re-exported as `rustango::embed_migrations!`),
956/// which scans a directory at compile time and emits the slice via
957/// `include_str!` per file. The macro emits content in lex-sorted
958/// order, but this function re-sorts defensively.
959///
960/// Each entry's first item must equal the migration's `name` field
961/// — a divergence would mean the slice was hand-built incorrectly.
962///
963/// [`embed_migrations!`]: https://docs.rs/rustango/0.1/rustango/macro.embed_migrations.html
964///
965/// # Errors
966/// As [`migrate`], plus [`MigrateError::Validation`] when an entry
967/// key doesn't match the migration's own name.
968#[cfg(feature = "postgres")]
969pub async fn migrate_embedded(
970    pool: &PgPool,
971    embedded: &[(&str, &str)],
972) -> Result<Vec<Migration>, MigrateError> {
973    Builder::default().migrate_embedded(pool, embedded).await
974}
975
976#[cfg(feature = "postgres")]
977async fn migrate_embedded_with_ledger(
978    pool: &PgPool,
979    embedded: &[(&str, &str)],
980    ledger: &str,
981) -> Result<Vec<Migration>, MigrateError> {
982    ensure_ledger_for(pool, ledger).await?;
983    with_migrate_lock(pool, async {
984        let mut all: Vec<Migration> = Vec::with_capacity(embedded.len());
985        for (name, json) in embedded {
986            let mig = file::parse(json)?;
987            if mig.name != *name {
988                return Err(MigrateError::Validation(format!(
989                    "embedded entry key `{name}` doesn't match migration `name` field `{}`",
990                    mig.name,
991                )));
992            }
993            all.push(mig);
994        }
995        all.sort_by(|a, b| a.name.cmp(&b.name));
996        file::validate_chain(&all, "embedded slice")?;
997
998        let applied = applied_set_for(pool, ledger).await?;
999        let pending: Vec<Migration> = all
1000            .into_iter()
1001            .filter(|m| !applied.contains(&m.name))
1002            .collect();
1003
1004        let mut newly = Vec::with_capacity(pending.len());
1005        for mig in pending {
1006            apply_one(pool, &mig, ledger).await?;
1007            newly.push(mig);
1008        }
1009        Ok(newly)
1010    })
1011    .await
1012}
1013
1014/// Step back `steps` applied migrations (Alembic's `downgrade -N`).
1015///
1016/// `downgrade(pool, dir, 1)` rolls back the most recently applied
1017/// migration. `downgrade(pool, dir, n)` rolls back the `n` most
1018/// recent. If `n` exceeds the number of applied migrations, every
1019/// applied migration is rolled back. `n == 0` is a no-op.
1020///
1021/// # Errors
1022/// As [`unapply`] for each step.
1023#[cfg(feature = "postgres")]
1024pub async fn downgrade(
1025    pool: &PgPool,
1026    dir: &Path,
1027    steps: usize,
1028) -> Result<Vec<Migration>, MigrateError> {
1029    Builder::default().downgrade(pool, dir, steps).await
1030}
1031
1032#[cfg(feature = "postgres")]
1033async fn downgrade_with_ledger(
1034    pool: &PgPool,
1035    dir: &Path,
1036    steps: usize,
1037    ledger: &str,
1038) -> Result<Vec<Migration>, MigrateError> {
1039    if steps == 0 {
1040        return Ok(Vec::new());
1041    }
1042    ensure_ledger_for(pool, ledger).await?;
1043    with_migrate_lock(pool, async {
1044        let all = file::list_dir(dir)?;
1045        let applied = applied_set_for(pool, ledger).await?;
1046
1047        let applied_in_order: Vec<Migration> = all
1048            .into_iter()
1049            .filter(|m| applied.contains(&m.name))
1050            .collect();
1051        if applied_in_order.is_empty() {
1052            return Ok(Vec::new());
1053        }
1054
1055        let n = steps.min(applied_in_order.len());
1056        let to_unapply: Vec<Migration> = applied_in_order.into_iter().rev().take(n).collect();
1057
1058        let mut touched = Vec::with_capacity(to_unapply.len());
1059        for mig in to_unapply {
1060            unapply_locked(pool, dir, &mig.name, ledger).await?;
1061            touched.push(mig);
1062        }
1063        Ok(touched)
1064    })
1065    .await
1066}
1067
1068#[cfg(feature = "postgres")]
1069async fn apply_one(pool: &PgPool, mig: &Migration, ledger: &str) -> Result<(), MigrateError> {
1070    if mig.atomic {
1071        apply_atomic(pool, mig, ledger).await
1072    } else {
1073        apply_loose(pool, mig, ledger).await
1074    }
1075}
1076
1077#[cfg(feature = "postgres")]
1078async fn unapply_all_in_order(
1079    pool: &PgPool,
1080    dir: &Path,
1081    all: &[Migration],
1082    applied: &HashSet<String>,
1083    ledger: &str,
1084) -> Result<Vec<Migration>, MigrateError> {
1085    let mut to_unapply: Vec<Migration> = all
1086        .iter()
1087        .filter(|m| applied.contains(&m.name))
1088        .cloned()
1089        .collect();
1090    to_unapply.reverse();
1091    let mut touched = Vec::with_capacity(to_unapply.len());
1092    for mig in to_unapply {
1093        // Caller already holds the migrate lock; use `unapply_locked`
1094        // to avoid re-acquiring (which would deadlock on a different
1095        // pooled connection / session).
1096        unapply_locked(pool, dir, &mig.name, ledger).await?;
1097        touched.push(mig);
1098    }
1099    Ok(touched)
1100}
1101
1102/// Roll back a single applied migration.
1103///
1104/// Loads `dir/{name}.json`, looks up its predecessor (or empty for
1105/// the first migration) for snapshot context, computes the inverse
1106/// op list via [`super::invert::invert`], and executes it in a
1107/// transaction (or loose if the original `atomic: false`). Removes
1108/// the entry from `__rustango_migrations__` on success.
1109///
1110/// **Refuses to unapply a non-head migration** — leaving an applied
1111/// migration newer than the rolled-back one would put the schema in
1112/// an inconsistent state (the newer one still thinks its predecessor
1113/// is in place). Use [`downgrade`] or [`migrate_to`] for ordered
1114/// rollback, or [`unapply_force`] to bypass.
1115///
1116/// **What "roll back" means here:** schema reversal restores shape,
1117/// not data — `DropColumn` then `unapply` does NOT bring back the
1118/// column's row values. Data reversal is only as good as the
1119/// `reverse_sql` you wrote in the migration file; if you wrote
1120/// `reverse_sql: "DELETE FROM x"`, that's what runs.
1121///
1122/// **Irreversible migrations** (`reversible: false` on any data op)
1123/// fail fast before any DB write, with an error that names the op.
1124///
1125/// # Errors
1126/// * [`MigrateError::Validation`] — non-head target, irreversible
1127///   op, missing migration file, missing predecessor.
1128/// * [`MigrateError::Driver`] — SQL failure during rollback.
1129#[cfg(feature = "postgres")]
1130pub async fn unapply(pool: &PgPool, dir: &Path, name: &str) -> Result<Migration, MigrateError> {
1131    Builder::default().unapply(pool, dir, name).await
1132}
1133
1134#[cfg(feature = "postgres")]
1135async fn unapply_with_ledger(
1136    pool: &PgPool,
1137    dir: &Path,
1138    name: &str,
1139    ledger: &str,
1140) -> Result<Migration, MigrateError> {
1141    ensure_ledger_for(pool, ledger).await?;
1142    with_migrate_lock(pool, async {
1143        check_is_head(pool, dir, name, ledger).await?;
1144        unapply_locked(pool, dir, name, ledger).await
1145    })
1146    .await
1147}
1148
1149/// Roll back any applied migration, even out of order.
1150///
1151/// Same body as [`unapply`] but skips the head check — the caller
1152/// accepts responsibility for the resulting schema state. Use only
1153/// when you genuinely need to drop an arbitrary applied migration
1154/// (e.g. surgical correction of a bad migration mid-history); in
1155/// most cases [`downgrade`] or [`migrate_to`] is what you want.
1156///
1157/// # Errors
1158/// As [`unapply`], minus the head-mismatch check.
1159#[cfg(feature = "postgres")]
1160pub async fn unapply_force(
1161    pool: &PgPool,
1162    dir: &Path,
1163    name: &str,
1164) -> Result<Migration, MigrateError> {
1165    Builder::default().unapply_force(pool, dir, name).await
1166}
1167
1168#[cfg(feature = "postgres")]
1169async fn unapply_force_with_ledger(
1170    pool: &PgPool,
1171    dir: &Path,
1172    name: &str,
1173    ledger: &str,
1174) -> Result<Migration, MigrateError> {
1175    ensure_ledger_for(pool, ledger).await?;
1176    with_migrate_lock(pool, unapply_locked(pool, dir, name, ledger)).await
1177}
1178
1179/// Verify `name` is the lex-greatest currently-applied migration.
1180/// Silent pass-through if the migration isn't applied at all — that
1181/// case will surface as a clearer error from `unapply_locked`
1182/// ("migration not found in dir" or similar).
1183#[cfg(feature = "postgres")]
1184async fn check_is_head(
1185    pool: &PgPool,
1186    dir: &Path,
1187    name: &str,
1188    ledger: &str,
1189) -> Result<(), MigrateError> {
1190    let applied = applied_set_for(pool, ledger).await?;
1191    if !applied.contains(name) {
1192        return Ok(());
1193    }
1194    let all = file::list_dir(dir)?;
1195    let head = all
1196        .iter()
1197        .rev()
1198        .find(|m| applied.contains(&m.name))
1199        .map(|m| m.name.as_str());
1200    match head {
1201        Some(h) if h == name => Ok(()),
1202        Some(h) => Err(MigrateError::Validation(format!(
1203            "refusing to unapply `{name}` out of order: current head is `{h}`. \
1204             Use `downgrade(pool, dir, n)` / `migrate_to(pool, dir, target)` for \
1205             ordered rollback, or `unapply_force` to bypass.",
1206        ))),
1207        None => Ok(()),
1208    }
1209}
1210
1211/// Body of [`unapply`] without acquiring the migrate lock — for
1212/// reuse by `migrate_to` and `downgrade`, which already hold the
1213/// lock for the whole operation. Acquiring the lock recursively on
1214/// a different pooled connection would block forever (each
1215/// `pool.acquire()` is a fresh session).
1216#[cfg(feature = "postgres")]
1217async fn unapply_locked(
1218    pool: &PgPool,
1219    dir: &Path,
1220    name: &str,
1221    ledger: &str,
1222) -> Result<Migration, MigrateError> {
1223    let all = file::list_dir(dir)?;
1224    let target = all
1225        .iter()
1226        .find(|m| m.name == name)
1227        .cloned()
1228        .ok_or_else(|| {
1229            MigrateError::Validation(format!("migration `{name}` not found in {}", dir.display()))
1230        })?;
1231
1232    let prev_snapshot = match &target.prev {
1233        None => SchemaSnapshot {
1234            tables: vec![],
1235            m2m_tables: vec![],
1236            indexes: vec![],
1237            checks: vec![],
1238            excludes: vec![],
1239        },
1240        Some(prev_name) => all
1241            .iter()
1242            .find(|m| &m.name == prev_name)
1243            .map(|m| m.snapshot.clone())
1244            .ok_or_else(|| {
1245                MigrateError::Validation(format!(
1246                    "migration `{name}` declares prev=`{prev_name}` but that file is missing in {}",
1247                    dir.display()
1248                ))
1249            })?,
1250    };
1251
1252    let inverted = invert(&target.forward, &prev_snapshot)?;
1253
1254    if target.atomic {
1255        unapply_atomic(pool, &target, &inverted, &prev_snapshot, ledger).await?;
1256    } else {
1257        unapply_loose(pool, &target, &inverted, &prev_snapshot, ledger).await?;
1258    }
1259
1260    Ok(target)
1261}
1262
1263#[cfg(feature = "postgres")]
1264async fn unapply_atomic(
1265    pool: &PgPool,
1266    target: &Migration,
1267    inverted: &[Operation],
1268    snapshot: &SchemaSnapshot,
1269    ledger: &str,
1270) -> Result<(), MigrateError> {
1271    tracing::info!(migration = %target.name, "unapplying (atomic)");
1272    let mut tx = pool.begin().await?;
1273    let mut deferred_fks: Vec<String> = Vec::new();
1274    for op in inverted {
1275        match op {
1276            Operation::Schema(change) => {
1277                let batch = render_changes_split(std::slice::from_ref(change), snapshot)
1278                    .map_err(MigrateError::Validation)?;
1279                for stmt in batch.immediate {
1280                    sqlx::query(&stmt).execute(&mut *tx).await?;
1281                }
1282                deferred_fks.extend(batch.deferred_fks);
1283            }
1284            Operation::Data(d) => {
1285                sqlx::query(&d.sql).execute(&mut *tx).await?;
1286            }
1287            Operation::Callback(c) => {
1288                // #347 — callback runs OUTSIDE the surrounding tx; see
1289                // `invoke_migration_callback` doc.
1290                invoke_migration_callback(c, pool.clone().into()).await?;
1291            }
1292        }
1293    }
1294    for stmt in deferred_fks {
1295        sqlx::query(&stmt).execute(&mut *tx).await?;
1296    }
1297    sqlx::query(&format!("DELETE FROM {ledger} WHERE name = $1"))
1298        .bind(&target.name)
1299        .execute(&mut *tx)
1300        .await?;
1301    tx.commit().await?;
1302    Ok(())
1303}
1304
1305#[cfg(feature = "postgres")]
1306async fn unapply_loose(
1307    pool: &PgPool,
1308    target: &Migration,
1309    inverted: &[Operation],
1310    snapshot: &SchemaSnapshot,
1311    ledger: &str,
1312) -> Result<(), MigrateError> {
1313    tracing::info!(migration = %target.name, "unapplying (non-atomic)");
1314    let mut deferred_fks: Vec<String> = Vec::new();
1315    for op in inverted {
1316        match op {
1317            Operation::Schema(change) => {
1318                let batch = render_changes_split(std::slice::from_ref(change), snapshot)
1319                    .map_err(MigrateError::Validation)?;
1320                for stmt in batch.immediate {
1321                    sqlx::query(&stmt).execute(pool).await?;
1322                }
1323                deferred_fks.extend(batch.deferred_fks);
1324            }
1325            Operation::Data(d) => {
1326                sqlx::query(&d.sql).execute(pool).await?;
1327            }
1328            Operation::Callback(c) => {
1329                // #347 — non-tx PG path; pool is &PgPool, convert to
1330                // the Pool enum via the From impl.
1331                invoke_migration_callback(c, pool.clone().into()).await?;
1332            }
1333        }
1334    }
1335    for stmt in deferred_fks {
1336        sqlx::query(&stmt).execute(pool).await?;
1337    }
1338    sqlx::query(&format!("DELETE FROM {ledger} WHERE name = $1"))
1339        .bind(&target.name)
1340        .execute(pool)
1341        .await?;
1342    Ok(())
1343}
1344
1345#[cfg(feature = "postgres")]
1346async fn apply_loose(pool: &PgPool, mig: &Migration, ledger: &str) -> Result<(), MigrateError> {
1347    tracing::info!(migration = %mig.name, "applying (non-atomic)");
1348    let mut deferred_fks: Vec<String> = Vec::new();
1349    for op in &mig.forward {
1350        match op {
1351            Operation::Schema(change) => {
1352                let batch = render_changes_split(std::slice::from_ref(change), &mig.snapshot)
1353                    .map_err(MigrateError::Validation)?;
1354                for stmt in batch.immediate {
1355                    sqlx::query(&stmt).execute(pool).await?;
1356                }
1357                deferred_fks.extend(batch.deferred_fks);
1358            }
1359            Operation::Data(d) => {
1360                sqlx::query(&d.sql).execute(pool).await?;
1361            }
1362            Operation::Callback(c) => {
1363                // #347 — non-tx PG path; pool is &PgPool, convert to
1364                // the Pool enum via the From impl.
1365                invoke_migration_callback(c, pool.clone().into()).await?;
1366            }
1367        }
1368    }
1369    for stmt in deferred_fks {
1370        sqlx::query(&stmt).execute(pool).await?;
1371    }
1372    sqlx::query(&format!("INSERT INTO {ledger} (name) VALUES ($1)"))
1373        .bind(&mig.name)
1374        .execute(pool)
1375        .await?;
1376    Ok(())
1377}
1378
1379// ====================================================================
1380// `&Pool` file-based ledger runner — v0.23.0-batch12
1381// ====================================================================
1382//
1383// Bi-dialect counterpart to `migrate(&PgPool, dir)`. Same semantics
1384// (skip already-applied migrations from the ledger, apply each in a
1385// transaction by default), same default ledger table name. Skipped
1386// in this batch:
1387//
1388// - Advisory locks. PG and MySQL emit different lock-name shapes
1389//   (i64 vs string) and the bind needs per-backend dispatch — that's
1390//   batch 13. Until then, concurrent `migrate_pool` calls against the
1391//   same DB can race; single-process bootstrap is safe.
1392// - migrate_to_pool / unapply_pool / downgrade_pool / migrate_dry_run_pool —
1393//   the harder direction-aware paths land in batch 13+ once the
1394//   advisory lock dispatch is in place.
1395// - Per-Builder customization on the `_pool` path. Default ledger
1396//   only for batch 12.
1397
1398/// Ensure the default ledger table (`__rustango_migrations__`) exists
1399/// on either backend. Idempotent — re-running on an existing ledger
1400/// is a no-op.
1401///
1402/// Backend-specific ledger DDL (the `applied_at` column type differs):
1403/// - Postgres: `applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`
1404/// - MySQL: `applied_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)`
1405///
1406/// # Errors
1407/// Returns [`MigrateError::Exec`] for any executor / driver failure.
1408pub async fn ensure_ledger_pool(pool: &crate::sql::Pool) -> Result<(), MigrateError> {
1409    ensure_ledger_pool_with_ledger(pool, LEDGER_TABLE).await
1410}
1411
1412/// Ensure a custom-named ledger table exists. Pair with the other
1413/// `*_pool_with_ledger` entry points to operate against a non-default
1414/// ledger. Issue #146 — operator-controlled ledger naming.
1415pub async fn ensure_ledger_pool_with_ledger(
1416    pool: &crate::sql::Pool,
1417    ledger: &str,
1418) -> Result<(), MigrateError> {
1419    let dialect_name = pool.dialect().name();
1420    let timestamp_col = match dialect_name {
1421        "postgres" => "TIMESTAMPTZ NOT NULL DEFAULT NOW()",
1422        "mysql" => "DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)",
1423        // SQLite has no native TIMESTAMP type — TEXT with affinity
1424        // and `CURRENT_TIMESTAMP` (UTC, ISO-8601 to second precision)
1425        // is the conventional shape. Sufficient for ledger ordering.
1426        "sqlite" => "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
1427        // Future dialects: a `Dialect::current_timestamp_default()` +
1428        // `Dialect::timestamp_type()` pair would let this branch go
1429        // away. For now the runner only knows the backends rustango
1430        // ships against.
1431        other => {
1432            return Err(MigrateError::Validation(format!(
1433                "ensure_ledger_pool: unrecognized dialect `{other}`"
1434            )));
1435        }
1436    };
1437    let create_sql = format!(
1438        "CREATE TABLE IF NOT EXISTS {ledger} (\
1439         name VARCHAR(255) PRIMARY KEY, \
1440         applied_at {timestamp_col})"
1441    );
1442    crate::sql::raw_execute_pool(pool, &create_sql, ::std::vec::Vec::new()).await?;
1443    Ok(())
1444}
1445
1446/// Set of migration names already recorded in the default ledger
1447/// against either backend.
1448///
1449/// # Errors
1450/// Returns [`MigrateError::Exec`] for any read failure (including a
1451/// missing ledger table — call [`ensure_ledger_pool`] first).
1452pub async fn applied_set_pool(pool: &crate::sql::Pool) -> Result<HashSet<String>, MigrateError> {
1453    applied_set_pool_with_ledger(pool, LEDGER_TABLE).await
1454}
1455
1456/// Read the applied-migration name set from a custom-named ledger
1457/// table. Pairs with [`ensure_ledger_pool_with_ledger`] / the other
1458/// `*_pool_with_ledger` entry points. Issue #146.
1459pub async fn applied_set_pool_with_ledger(
1460    pool: &crate::sql::Pool,
1461    ledger: &str,
1462) -> Result<HashSet<String>, MigrateError> {
1463    let sql = format!("SELECT name FROM {ledger}");
1464    // #561 — was 3-arm `match pool` doing byte-identical
1465    // `try_get::<String, _>("name")` loops. The
1466    // `raw_query_pool::<(String,)>` positional tuple decode pulls
1467    // the single column on every backend via the `Maybe*FromRow`
1468    // blanket impls. `ExecError` rides in via `MigrateError::Exec`'s
1469    // `#[from]` impl.
1470    let rows: Vec<(String,)> = crate::sql::raw_query_pool(&sql, Vec::new(), pool).await?;
1471    Ok(rows.into_iter().map(|(name,)| name).collect())
1472}
1473
1474/// Apply every pending migration in `dir` against either backend.
1475/// Each migration runs in its own transaction unless its `atomic`
1476/// field is `false` (e.g. `CREATE INDEX CONCURRENTLY`, which neither
1477/// PG nor MySQL allow inside a transaction).
1478///
1479/// Skips files already recorded in the ledger. Returns the migrations
1480/// that were newly applied.
1481///
1482/// **Concurrency caveat (batch 12):** no advisory lock yet — peers
1483/// running `migrate_pool` against the same DB simultaneously can both
1484/// pass the `applied_set` check and try to apply the same file. The
1485/// ledger PRIMARY KEY constraint catches the second writer's INSERT
1486/// and rolls its transaction back, but you'll see noisy errors. Lock
1487/// dispatch lands in batch 13.
1488///
1489/// # Errors
1490/// As [`migrate`].
1491pub async fn migrate_pool(
1492    pool: &crate::sql::Pool,
1493    dir: &Path,
1494) -> Result<Vec<Migration>, MigrateError> {
1495    migrate_pool_with_ledger(pool, dir, LEDGER_TABLE).await
1496}
1497
1498/// [`migrate_pool`], reporting each migration to `observer` as it starts
1499/// and finishes.
1500///
1501/// The observer sees the pending count up front, then a
1502/// [`Started`](MigrationEvent::Started) and a
1503/// [`Finished`](MigrationEvent::Finished) per migration — carrying which
1504/// of the three apply paths it took and how long it took — or a
1505/// [`Failed`](MigrationEvent::Failed) naming the migration that died.
1506///
1507/// **The observer is called with the migrate lock held**, so it must not
1508/// block; see [the module docs](super::progress#observers-must-not-block).
1509///
1510/// # Errors
1511/// As [`migrate_pool`]. The observer never changes the outcome: a run
1512/// with an observer applies exactly what the same run without one would.
1513pub async fn migrate_pool_with_progress(
1514    pool: &crate::sql::Pool,
1515    dir: &Path,
1516    observer: &dyn MigrationObserver,
1517) -> Result<Vec<Migration>, MigrateError> {
1518    migrate_pool_with_ledger_opts(pool, dir, LEDGER_TABLE, false, Some(observer)).await
1519}
1520
1521/// [`migrate_pool_with_ledger_fake_initial`] with progress reporting —
1522/// the framework's own system-migration path, which is where
1523/// [`Outcome::Faked`] actually shows up.
1524///
1525/// # Errors
1526/// As [`migrate_pool_with_ledger_fake_initial`].
1527pub async fn migrate_pool_with_ledger_fake_initial_with_progress(
1528    pool: &crate::sql::Pool,
1529    dir: &Path,
1530    ledger: &str,
1531    observer: &dyn MigrationObserver,
1532) -> Result<Vec<Migration>, MigrateError> {
1533    migrate_pool_with_ledger_opts(pool, dir, ledger, true, Some(observer)).await
1534}
1535
1536/// Apply every pending migration in `dir` against a custom-named
1537/// ledger table. Sibling of [`migrate_pool`] with operator-supplied
1538/// ledger name (issue #146). Use for multi-tenant / multi-app
1539/// deployments where two migration directories share a database and
1540/// must not collide on the default `__rustango_migrations__`
1541/// bookkeeping table.
1542///
1543/// # Errors
1544/// As [`migrate_pool`].
1545pub async fn migrate_pool_with_ledger(
1546    pool: &crate::sql::Pool,
1547    dir: &Path,
1548    ledger: &str,
1549) -> Result<Vec<Migration>, MigrateError> {
1550    migrate_pool_with_ledger_opts(pool, dir, ledger, false, None).await
1551}
1552
1553/// Like [`migrate_pool_with_ledger`], but with **guarded fake-initial**
1554/// reconciliation (#1167): before applying, any pending migration that
1555/// is *purely* table-creation (`CreateTable` ops only) whose tables
1556/// **all already exist** is recorded in the ledger *without running its
1557/// SQL*. This is the upgrade path for a subsystem that used to build
1558/// its tables via the lazy `ensure_table` DDL and is now managed by a
1559/// system migration: on the first `migrate` after the upgrade, the
1560/// freshly-generated `CREATE TABLE` would otherwise collide with the
1561/// already-present table. Faking it records the migration as applied so
1562/// the chain moves on; existing data is untouched.
1563///
1564/// Scoped to the framework's own system-migration apply path (see
1565/// [`crate::tenancy`] provisioning) — user migrations go through the
1566/// plain [`migrate_pool_with_ledger`] and never auto-fake. The guard is
1567/// deliberately narrow: a migration with *any* non-`CreateTable`
1568/// operation (a column add, a data backfill, a callback) is never
1569/// faked, and a create-migration is faked only when **every** table it
1570/// creates is already present (a partial state falls through to the
1571/// runner, which surfaces the collision loudly rather than papering
1572/// over it).
1573///
1574/// # Errors
1575/// As [`migrate_pool_with_ledger`], plus a ledger-insert failure while
1576/// recording a faked migration.
1577pub async fn migrate_pool_with_ledger_fake_initial(
1578    pool: &crate::sql::Pool,
1579    dir: &Path,
1580    ledger: &str,
1581) -> Result<Vec<Migration>, MigrateError> {
1582    migrate_pool_with_ledger_opts(pool, dir, ledger, true, None).await
1583}
1584
1585async fn migrate_pool_with_ledger_opts(
1586    pool: &crate::sql::Pool,
1587    dir: &Path,
1588    ledger: &str,
1589    fake_initial: bool,
1590    observer: Option<&dyn MigrationObserver>,
1591) -> Result<Vec<Migration>, MigrateError> {
1592    ensure_ledger_pool_with_ledger(pool, ledger).await?;
1593    with_migrate_lock_pool(pool, async {
1594        let all = file::list_dir(dir)?;
1595        let applied = applied_set_pool_with_ledger(pool, ledger).await?;
1596        let pending = pending_migrations(all, &applied);
1597
1598        let total = pending.len();
1599        emit(observer, || MigrationEvent::Planned { total });
1600
1601        let mut newly = Vec::with_capacity(total);
1602        for (i, mig) in pending.into_iter().enumerate() {
1603            let index = i + 1;
1604            emit(observer, || MigrationEvent::Started {
1605                name: mig.name.clone(),
1606                index,
1607                total,
1608            });
1609            let began = std::time::Instant::now();
1610
1611            // The whole apply is wrapped so a failure can be reported to
1612            // the observer before it propagates. `?` on its own would
1613            // leave a watcher looking at a migration stuck on "started"
1614            // forever, which is the state this exists to prevent.
1615            let outcome = reconcile_and_apply(pool, &mig, &applied, ledger, fake_initial).await;
1616            let outcome = match outcome {
1617                Ok(outcome) => outcome,
1618                Err(e) => {
1619                    emit(observer, || MigrationEvent::Failed {
1620                        name: mig.name.clone(),
1621                        index,
1622                        total,
1623                        error: e.to_string(),
1624                    });
1625                    return Err(e);
1626                }
1627            };
1628
1629            emit(observer, || MigrationEvent::Finished {
1630                name: mig.name.clone(),
1631                index,
1632                total,
1633                outcome,
1634                elapsed: began.elapsed(),
1635            });
1636            newly.push(mig);
1637        }
1638        Ok(newly)
1639    })
1640    .await
1641}
1642
1643/// Reconcile and apply a single pending migration, reporting which of
1644/// the three paths it took.
1645///
1646/// Split out of the loop so the caller can time it and report a failure
1647/// with the migration's name attached — the runner's `?` alone loses
1648/// which file died.
1649async fn reconcile_and_apply(
1650    pool: &crate::sql::Pool,
1651    mig: &Migration,
1652    applied: &HashSet<String>,
1653    ledger: &str,
1654    fake_initial: bool,
1655) -> Result<Outcome, MigrateError> {
1656    match reconcile(pool, mig, applied, fake_initial).await? {
1657        ReconcileAction::Fake => {
1658            fake_apply_pool(pool, mig, ledger).await?;
1659            Ok(Outcome::Faked)
1660        }
1661        other => {
1662            let (effective, outcome) = match other {
1663                ReconcileAction::RunPartial(existing) => (
1664                    std::borrow::Cow::Owned(without_tables(mig, &existing)),
1665                    Outcome::RanPartial { skipped: existing },
1666                ),
1667                _ => (std::borrow::Cow::Borrowed(mig), Outcome::Ran),
1668            };
1669            if effective.atomic {
1670                apply_atomic_pool(pool, &effective, ledger).await?;
1671            } else {
1672                apply_nonatomic_pool(pool, &effective, ledger).await?;
1673            }
1674            Ok(outcome)
1675        }
1676    }
1677}
1678
1679// ------------------------------------------------------- reconciliation
1680
1681/// What to do with a pending migration once it has been reconciled
1682/// against the database's actual state. See [`reconcile`].
1683#[derive(Debug, Clone, PartialEq, Eq)]
1684enum ReconcileAction {
1685    /// Apply normally — run its `forward` ops, then record it.
1686    Run,
1687    /// Record it as applied (and tombstone anything it `replaces`)
1688    /// **without** running any DDL: the end state is already present.
1689    Fake,
1690    /// Apply it, but skip the operations for the listed tables — they
1691    /// already exist (created by the retired `ensure_*` DDL of an older
1692    /// build). Only the framework's own system chain uses this.
1693    RunPartial(Vec<String>),
1694}
1695
1696/// Decide whether a pending migration should run or be reconciled.
1697///
1698/// Two independent reconciliation paths:
1699///
1700/// * **Squash** (`replaces` non-empty) — always considered, on every
1701///   runner. A squash collapses historical migrations into one file that
1702///   recreates the same end state, so on a database that already has that
1703///   history it must not re-run:
1704///   - every replaced migration present in the ledger → [`Fake`] (same-ledger)
1705///   - none present, but all the tables it creates already exist → [`Fake`]
1706///     (cross-ledger, Django's `--fake-initial`)
1707///   - a *partial* match either way → **error**, because the database is in
1708///     a state no automatic choice can safely resolve
1709///   - otherwise (fresh database) → [`Run`]
1710///
1711/// * **Plain migration** (`replaces` empty) — only when `fake_initial` is
1712///   set (the framework's own system-migration path, #1174): a *purely*
1713///   table-creating migration whose tables all already exist is faked, so a
1714///   subsystem that used to build its tables via the retired `ensure_table`
1715///   DDL upgrades cleanly. Deliberately conservative: any non-`CreateTable`
1716///   operation, or a partially-present table set, falls through to [`Run`]
1717///   so real work is never skipped and genuine collisions still surface.
1718///
1719/// [`Fake`]: ReconcileAction::Fake
1720/// [`Run`]: ReconcileAction::Run
1721async fn reconcile(
1722    pool: &crate::sql::Pool,
1723    mig: &Migration,
1724    applied: &HashSet<String>,
1725    fake_initial: bool,
1726) -> Result<ReconcileAction, MigrateError> {
1727    if !mig.replaces.is_empty() {
1728        let present = mig.replaces.iter().filter(|n| applied.contains(*n)).count();
1729        if present == mig.replaces.len() {
1730            tracing::info!(
1731                migration = %mig.name,
1732                replaces = ?mig.replaces,
1733                "reconciling squash — every replaced migration is already applied; \
1734                 recording it and tombstoning them without running DDL"
1735            );
1736            return Ok(ReconcileAction::Fake);
1737        }
1738        if present > 0 {
1739            let missing: Vec<&str> = mig
1740                .replaces
1741                .iter()
1742                .filter(|n| !applied.contains(*n))
1743                .map(String::as_str)
1744                .collect();
1745            return Err(MigrateError::Validation(format!(
1746                "cannot reconcile squash `{}`: it replaces {} migration(s) but only {} are \
1747                 recorded as applied (missing: {}). The database is in a partial state — \
1748                 resolve it by hand (see `migrate --fake <name>`) rather than guessing.",
1749                mig.name,
1750                mig.replaces.len(),
1751                present,
1752                missing.join(", "),
1753            )));
1754        }
1755        // None of the replaced set is in THIS ledger. The history may still
1756        // be present (a different ledger, or tables built out-of-band), so
1757        // fall back to comparing against the tables themselves.
1758        let targets = create_table_targets(mig);
1759        if targets.is_empty() {
1760            return Ok(ReconcileAction::Run);
1761        }
1762        let existing = count_existing_tables(pool, &targets).await;
1763        if existing == targets.len() {
1764            tracing::info!(
1765                migration = %mig.name,
1766                tables = %targets.join(", "),
1767                "reconciling squash — its tables already exist (cross-ledger \
1768                 fake-initial); recording it without running DDL"
1769            );
1770            return Ok(ReconcileAction::Fake);
1771        }
1772        if existing > 0 {
1773            return Err(MigrateError::Validation(format!(
1774                "cannot reconcile squash `{}`: {} of its {} tables already exist but the rest \
1775                 do not. The database is in a partial state — resolve it by hand (see \
1776                 `migrate --fake <name>`) rather than guessing.",
1777                mig.name,
1778                existing,
1779                targets.len(),
1780            )));
1781        }
1782        return Ok(ReconcileAction::Run);
1783    }
1784
1785    // Plain migration: only the framework's own system-migration path opts
1786    // into table-existence reconciliation.
1787    if fake_initial {
1788        if let Some(tables) = create_only_tables(mig) {
1789            let existing: Vec<String> = {
1790                let mut present = Vec::new();
1791                for t in &tables {
1792                    if count_existing_tables(pool, std::slice::from_ref(t)).await == 1 {
1793                        present.push(t.clone());
1794                    }
1795                }
1796                present
1797            };
1798            if existing.len() == tables.len() {
1799                tracing::warn!(
1800                    migration = %mig.name,
1801                    tables = %tables.join(", "),
1802                    "fake-initial: tables already exist (pre-migration \
1803                     `ensure_table` era) — recorded as applied without \
1804                     running its CREATE TABLE; existing data left intact"
1805                );
1806                return Ok(ReconcileAction::Fake);
1807            }
1808            if !existing.is_empty() {
1809                // Partial: some of the framework's tables predate this chain
1810                // (the `ensure_table` era created them piecemeal — whichever
1811                // subsystems the app actually touched). Create only what's
1812                // missing and leave the rest alone. That is exactly the
1813                // `CREATE TABLE IF NOT EXISTS` semantics the retired `ensure_*`
1814                // calls had, so upgrading is never worse than before; refusing
1815                // here instead would simply break the upgrade.
1816                tracing::warn!(
1817                    migration = %mig.name,
1818                    existing = %existing.join(", "),
1819                    "fake-initial: some framework tables already exist — creating \
1820                     only the missing ones and leaving the existing untouched"
1821                );
1822                return Ok(ReconcileAction::RunPartial(existing));
1823            }
1824        }
1825    }
1826    Ok(ReconcileAction::Run)
1827}
1828
1829/// A copy of `mig` with every operation that targets an already-present
1830/// table removed — the `RunPartial` payload.
1831///
1832/// Both the `CreateTable` and any `CreateIndex` for a skipped table are
1833/// dropped: the table was created by an older framework build, which created
1834/// its indexes too, so re-running them would collide the same way. Operations
1835/// for tables we *are* creating pass through untouched.
1836pub(crate) fn without_tables(mig: &Migration, existing: &[String]) -> Migration {
1837    use super::diff::SchemaChange as SC;
1838    let skip = |t: &String| existing.iter().any(|e| e == t);
1839    let forward = mig
1840        .forward
1841        .iter()
1842        .filter(|op| match op {
1843            Operation::Schema(SC::CreateTable(t)) => !skip(t),
1844            Operation::Schema(SC::CreateIndex { table, .. }) => !skip(table),
1845            Operation::Schema(SC::CreateM2MTable { through, .. }) => !skip(through),
1846            _ => true,
1847        })
1848        .cloned()
1849        .collect();
1850    Migration {
1851        forward,
1852        ..mig.clone()
1853    }
1854}
1855
1856/// The migrations in `all` that still need to be considered, given the
1857/// `applied` ledger set.
1858///
1859/// Beyond the obvious "not in the ledger" filter, this drops anything that an
1860/// **applied squash** declares it `replaces`. Once a squash is recorded, its
1861/// predecessors' ledger rows are tombstoned, but their *files* usually remain
1862/// on disk for a release or two (Django keeps them so older deployments can
1863/// still migrate forward). Without this filter those files would look pending
1864/// on the very next run and try to recreate tables that already exist.
1865fn pending_migrations(all: Vec<Migration>, applied: &HashSet<String>) -> Vec<Migration> {
1866    let superseded: HashSet<&str> = all
1867        .iter()
1868        .filter(|m| applied.contains(&m.name))
1869        .flat_map(|m| m.replaces.iter().map(String::as_str))
1870        .collect();
1871    all.iter()
1872        .filter(|m| !applied.contains(&m.name) && !superseded.contains(m.name.as_str()))
1873        .cloned()
1874        .collect()
1875}
1876
1877/// Every table this migration creates, in order. Unlike
1878/// [`create_only_tables`] this does **not** require the migration to be
1879/// purely table-creating — a squash legitimately carries other operations
1880/// alongside its `CreateTable`s, and it is the created tables that tell us
1881/// whether the end state is already present.
1882fn create_table_targets(mig: &Migration) -> Vec<String> {
1883    mig.forward
1884        .iter()
1885        .filter_map(|op| match op {
1886            Operation::Schema(super::diff::SchemaChange::CreateTable(t)) => Some(t.clone()),
1887            _ => None,
1888        })
1889        .collect()
1890}
1891
1892/// The tables `mig` creates, when the migration does **nothing but** stand
1893/// those tables up; otherwise `None`.
1894///
1895/// "Nothing but" is judged against the real shape a generated initial
1896/// migration has, which is *not* only `CreateTable`: `makemigrations` emits
1897/// the table, then its indexes (and any M2M join table) as sibling
1898/// operations. A media-subsystem initial, for instance, is 4 `CreateTable` +
1899/// 6 `CreateIndex`. Requiring literal `CreateTable`-purity would reject every
1900/// real migration and silently disable fake-initial reconciliation — which is
1901/// exactly what it did before this was fixed.
1902///
1903/// So the accepted set is "operations that are part of creating these
1904/// tables":
1905/// * `CreateTable`
1906/// * `CreateIndex` / `CreateM2MTable`, but **only** when they target a table
1907///   this same migration creates — an index added to a *pre-existing* table
1908///   is real work that must not be skipped.
1909///
1910/// Everything else (a column add, an alter, a drop, a data backfill, a
1911/// callback) disqualifies the migration, so a genuine side effect is never
1912/// faked away.
1913fn create_only_tables(mig: &Migration) -> Option<Vec<String>> {
1914    use super::diff::SchemaChange as SC;
1915    let mut tables: Vec<String> = Vec::new();
1916    let mut targeted: Vec<&str> = Vec::new();
1917    for op in &mig.forward {
1918        match op {
1919            Operation::Schema(SC::CreateTable(t)) => tables.push(t.clone()),
1920            Operation::Schema(SC::CreateIndex { table, .. }) => targeted.push(table.as_str()),
1921            Operation::Schema(SC::CreateM2MTable { through, .. }) => tables.push(through.clone()),
1922            _ => return None,
1923        }
1924    }
1925    if tables.is_empty() {
1926        return None;
1927    }
1928    // Every index must belong to a table this migration itself creates.
1929    if targeted
1930        .iter()
1931        .any(|t| !tables.iter().any(|created| created == t))
1932    {
1933        return None;
1934    }
1935    Some(tables)
1936}
1937
1938/// How many of `tables` already exist in `pool`. Probes with
1939/// `SELECT 1 FROM <t> LIMIT 1` so each name resolves through exactly the
1940/// same search_path / current-database rules the migration's own
1941/// `CREATE TABLE` would use (unqualified — correct for schema-mode
1942/// tenants and single-DB alike).
1943async fn count_existing_tables(pool: &crate::sql::Pool, tables: &[String]) -> usize {
1944    let mut n = 0;
1945    for t in tables {
1946        if table_exists_here(pool, t).await {
1947            n += 1;
1948        }
1949    }
1950    n
1951}
1952
1953/// Does `table` exist **in the schema this connection writes to**?
1954///
1955/// Deliberately *not* `SELECT 1 FROM <table>`: an unqualified name resolves
1956/// through the search path, so in schema-mode multi-tenancy (`search_path =
1957/// <tenant>, public`) a probe would happily find a same-named table in
1958/// `public` and conclude the tenant already had one. Reconciliation would then
1959/// skip creating it and the tenant would silently come up without its tables.
1960///
1961/// Each backend is asked about its *current* namespace only:
1962/// * Postgres — `information_schema.tables` filtered to `current_schema()`
1963/// * MySQL — filtered to `DATABASE()` (a schema is a database here)
1964/// * SQLite — `sqlite_master`, which is inherently per-connection
1965async fn table_exists_here(pool: &crate::sql::Pool, table: &str) -> bool {
1966    match pool {
1967        #[cfg(feature = "postgres")]
1968        crate::sql::Pool::Postgres(pg) => sqlx::query_scalar::<_, i64>(
1969            "SELECT COUNT(*) FROM information_schema.tables \
1970             WHERE table_schema = current_schema() AND table_name = $1",
1971        )
1972        .bind(table)
1973        .fetch_one(pg)
1974        .await
1975        .map(|n| n > 0)
1976        .unwrap_or(false),
1977        #[cfg(feature = "mysql")]
1978        crate::sql::Pool::Mysql(my) => sqlx::query_scalar::<_, i64>(
1979            "SELECT COUNT(*) FROM information_schema.tables \
1980             WHERE table_schema = DATABASE() AND table_name = ?",
1981        )
1982        .bind(table)
1983        .fetch_one(my)
1984        .await
1985        .map(|n| n > 0)
1986        .unwrap_or(false),
1987        #[cfg(feature = "sqlite")]
1988        crate::sql::Pool::Sqlite(sq) => sqlx::query_scalar::<_, i64>(
1989            "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?",
1990        )
1991        .bind(table)
1992        .fetch_one(sq)
1993        .await
1994        .map(|n| n > 0)
1995        .unwrap_or(false),
1996    }
1997}
1998
1999/// Record `mig` as applied **without running its `forward` ops**, and
2000/// tombstone every migration it `replaces`.
2001///
2002/// The squash row is inserted *before* the replaced rows are deleted, so a
2003/// crash midway leaves the ledger over-full (squash + some predecessors)
2004/// rather than empty — the next run then sees a full/partial state it can
2005/// report, instead of silently re-running the squash against live tables.
2006async fn fake_apply_pool(
2007    pool: &crate::sql::Pool,
2008    mig: &Migration,
2009    ledger: &str,
2010) -> Result<(), MigrateError> {
2011    // Render both statements before the first `.await` so the
2012    // `&dyn Dialect` borrow never crosses a suspend point (keeps this
2013    // future `Send` — see `count_existing_tables`).
2014    let (insert, delete) = {
2015        let dialect = pool.dialect();
2016        let placeholder = dialect.placeholder(1);
2017        let table = dialect.quote_ident(ledger);
2018        let name_col = dialect.quote_ident("name");
2019        let conflict_tail = dialect.insert_on_conflict_skip(&[&name_col]);
2020        (
2021            format!("INSERT INTO {table} ({name_col}) VALUES ({placeholder}) {conflict_tail}"),
2022            format!("DELETE FROM {table} WHERE {name_col} = {placeholder}"),
2023        )
2024    };
2025    crate::sql::raw_execute_pool(
2026        pool,
2027        &insert,
2028        vec![crate::core::SqlValue::String(mig.name.clone())],
2029    )
2030    .await
2031    .map_err(|e| {
2032        MigrateError::Validation(format!(
2033            "fake-apply: ledger insert for `{}` failed: {e}",
2034            mig.name
2035        ))
2036    })?;
2037
2038    if mig.replaces.is_empty() {
2039        return Ok(());
2040    }
2041    for replaced in &mig.replaces {
2042        crate::sql::raw_execute_pool(
2043            pool,
2044            &delete,
2045            vec![crate::core::SqlValue::String(replaced.clone())],
2046        )
2047        .await
2048        .map_err(|e| {
2049            MigrateError::Validation(format!(
2050                "fake-apply: tombstoning `{replaced}` (replaced by `{}`) failed: {e}",
2051                mig.name
2052            ))
2053        })?;
2054    }
2055    Ok(())
2056}
2057
2058/// Hold the migrate session-scoped advisory lock while `body` runs,
2059/// then release. Bi-dialect counterpart of [`with_migrate_lock`] —
2060/// dispatches the lock acquire/release SQL through the pool's dialect.
2061///
2062/// Backend-specific bind shapes:
2063/// - **Postgres** — `pg_advisory_lock($1)` takes an `i64`; we bind
2064///   [`MIGRATE_LOCK_KEY`] (the same key the legacy PgPool runner
2065///   uses, so the two paths coordinate).
2066/// - **MySQL** — `GET_LOCK(?, -1)` takes a `VARCHAR` lock name; we
2067///   bind `format!("rustango_migrate_{:x}", MIGRATE_LOCK_KEY)` so
2068///   the name is stable, deterministic, and namespaced (MySQL
2069///   `GET_LOCK` is global to the server, not scoped per database).
2070///
2071/// The lock is acquired on a checked-out connection and held until
2072/// `body` returns; release happens on the same connection so MySQL's
2073/// connection-scoped `GET_LOCK` semantics work correctly.
2074async fn with_migrate_lock_pool<F, R>(pool: &crate::sql::Pool, body: F) -> Result<R, MigrateError>
2075where
2076    F: std::future::Future<Output = Result<R, MigrateError>>,
2077{
2078    match pool {
2079        #[cfg(feature = "postgres")]
2080        crate::sql::Pool::Postgres(pg) => {
2081            let mut lock_conn = pg.acquire().await?;
2082            sqlx::query("SELECT pg_advisory_lock($1)")
2083                .bind(MIGRATE_LOCK_KEY)
2084                .execute(&mut *lock_conn)
2085                .await?;
2086            let result = body.await;
2087            // Best-effort release. PG releases on session close anyway,
2088            // so a failed unlock can't permanently deadlock peers.
2089            let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
2090                .bind(MIGRATE_LOCK_KEY)
2091                .execute(&mut *lock_conn)
2092                .await;
2093            result
2094        }
2095        #[cfg(feature = "mysql")]
2096        crate::sql::Pool::Mysql(my) => {
2097            let lock_name = format!("rustango_migrate_{:x}", MIGRATE_LOCK_KEY);
2098            let mut lock_conn = my.acquire().await?;
2099            sqlx::query("SELECT GET_LOCK(?, -1)")
2100                .bind(&lock_name)
2101                .execute(&mut *lock_conn)
2102                .await?;
2103            let result = body.await;
2104            // Best-effort release. MySQL releases on connection close,
2105            // so a failed RELEASE_LOCK can't permanently deadlock peers.
2106            let _ = sqlx::query("SELECT RELEASE_LOCK(?)")
2107                .bind(&lock_name)
2108                .execute(&mut *lock_conn)
2109                .await;
2110            result
2111        }
2112        #[cfg(feature = "sqlite")]
2113        crate::sql::Pool::Sqlite(_) => {
2114            // SQLite has no advisory-lock primitive comparable to PG's
2115            // `pg_advisory_lock` or MySQL's `GET_LOCK`. The single-writer
2116            // semantics of SQLite (and the typical single-process
2117            // deployment shape) make migration coordination unnecessary
2118            // — concurrent migrations would serialize on the database
2119            // file lock anyway. Run the body without an additional lock.
2120            body.await
2121        }
2122    }
2123}
2124
2125/// Apply one migration inside a transaction. Both backends support
2126/// the same `BEGIN`/`COMMIT`/`ROLLBACK` shape, but sqlx's `Transaction<DB>`
2127/// is generic over the backend so the body is inlined per-arm rather
2128/// than factored — `Executor<Database = sqlx::Postgres>` and
2129/// `Executor<Database = sqlx::MySql>` can't share a single generic
2130/// function without a Database-erased shim trait that doesn't ship
2131/// in sqlx.
2132async fn apply_atomic_pool(
2133    pool: &crate::sql::Pool,
2134    mig: &Migration,
2135    ledger: &str,
2136) -> Result<(), MigrateError> {
2137    tracing::info!(migration = %mig.name, "applying (atomic, _pool)");
2138    let dialect = pool.dialect();
2139    match pool {
2140        #[cfg(feature = "postgres")]
2141        crate::sql::Pool::Postgres(pg) => {
2142            let mut tx = pg.begin().await?;
2143            let mut deferred_fks: Vec<String> = Vec::new();
2144            for op in &mig.forward {
2145                match op {
2146                    Operation::Schema(change) => {
2147                        let batch = super::diff::render_changes_split_with_dialect(
2148                            std::slice::from_ref(change),
2149                            &mig.snapshot,
2150                            dialect,
2151                        )
2152                        .map_err(MigrateError::Validation)?;
2153                        for stmt in batch.immediate {
2154                            sqlx::query(&stmt).execute(&mut *tx).await?;
2155                        }
2156                        deferred_fks.extend(batch.deferred_fks);
2157                    }
2158                    Operation::Data(d) => {
2159                        sqlx::query(&d.sql).execute(&mut *tx).await?;
2160                    }
2161                    Operation::Callback(c) => {
2162                        // #347 — see `invoke_migration_callback` doc.
2163                        invoke_migration_callback(c, pool.clone().into()).await?;
2164                    }
2165                }
2166            }
2167            for stmt in deferred_fks {
2168                sqlx::query(&stmt).execute(&mut *tx).await?;
2169            }
2170            sqlx::query(&format!("INSERT INTO {ledger} (name) VALUES ($1)"))
2171                .bind(&mig.name)
2172                .execute(&mut *tx)
2173                .await?;
2174            tx.commit().await?;
2175        }
2176        #[cfg(feature = "mysql")]
2177        crate::sql::Pool::Mysql(my) => {
2178            // ⚠ MySQL atomic migrations have a caveat that PG / SQLite
2179            // don't: MySQL silently auto-COMMITs the current
2180            // transaction on every DDL statement (CREATE TABLE,
2181            // ALTER TABLE, DROP TABLE, CREATE INDEX, etc. — the
2182            // "implicit commit before/after statement" list in
2183            // https://dev.mysql.com/doc/refman/8.0/en/implicit-commit.html).
2184            // The BEGIN we issue below establishes a tx that subsequent
2185            // `Operation::Data(RunSQL)` statements DO participate in,
2186            // but every `Operation::Schema(change)` auto-commits and
2187            // breaks atomicity. The COMMIT at the end is a no-op for
2188            // any DDL emitted above.
2189            //
2190            // In practice this means: if a migration has 5 DDL ops and
2191            // op #3 fails, ops #1+#2 are already committed and can't be
2192            // rolled back. The migration ends up half-applied; operators
2193            // have to manually un-do the partially-applied DDL OR fix
2194            // the migration to be re-runnable from where it failed.
2195            //
2196            // This is a MySQL engine limitation, not a rustango bug,
2197            // and matches Django's `migrate` behavior against MySQL
2198            // (Django docs note the same caveat). Tracked in #559.
2199            // The runner emits a `tracing::warn!` so operators see
2200            // the caveat in logs when they invoke `atomic: true` on
2201            // MySQL.
2202            tracing::warn!(
2203                migration = %mig.name,
2204                "MySQL silently auto-commits on every DDL statement (CREATE/ALTER/DROP/INDEX); \
2205                 the atomic-migration wrapper only protects RunSQL/RunPython operations \
2206                 between DDL ops. A failure mid-DDL leaves the migration partially applied \
2207                 and requires manual recovery. See migrate/runner.rs::apply_atomic_pool for \
2208                 details. Tracked in #559."
2209            );
2210            let mut tx = my.begin().await?;
2211            let mut deferred_fks: Vec<String> = Vec::new();
2212            for op in &mig.forward {
2213                match op {
2214                    Operation::Schema(change) => {
2215                        let batch = super::diff::render_changes_split_with_dialect(
2216                            std::slice::from_ref(change),
2217                            &mig.snapshot,
2218                            dialect,
2219                        )
2220                        .map_err(MigrateError::Validation)?;
2221                        for stmt in batch.immediate {
2222                            sqlx::query(&stmt).execute(&mut *tx).await?;
2223                        }
2224                        deferred_fks.extend(batch.deferred_fks);
2225                    }
2226                    Operation::Data(d) => {
2227                        sqlx::query(&d.sql).execute(&mut *tx).await?;
2228                    }
2229                    Operation::Callback(c) => {
2230                        // #347 — see `invoke_migration_callback` doc.
2231                        invoke_migration_callback(c, pool.clone().into()).await?;
2232                    }
2233                }
2234            }
2235            for stmt in deferred_fks {
2236                sqlx::query(&stmt).execute(&mut *tx).await?;
2237            }
2238            sqlx::query(&format!("INSERT INTO {ledger} (name) VALUES (?)"))
2239                .bind(&mig.name)
2240                .execute(&mut *tx)
2241                .await?;
2242            tx.commit().await?;
2243        }
2244        #[cfg(feature = "sqlite")]
2245        crate::sql::Pool::Sqlite(sq) => {
2246            // SQLite supports the same BEGIN/COMMIT/ROLLBACK shape as
2247            // PG/MySQL, with `?` placeholders. Mirror the PG arm.
2248            let mut tx = sq.begin().await?;
2249            let mut deferred_fks: Vec<String> = Vec::new();
2250            for op in &mig.forward {
2251                match op {
2252                    Operation::Schema(change) => {
2253                        let batch = super::diff::render_changes_split_with_dialect(
2254                            std::slice::from_ref(change),
2255                            &mig.snapshot,
2256                            dialect,
2257                        )
2258                        .map_err(MigrateError::Validation)?;
2259                        for stmt in batch.immediate {
2260                            sqlx::query(&stmt).execute(&mut *tx).await?;
2261                        }
2262                        deferred_fks.extend(batch.deferred_fks);
2263                    }
2264                    Operation::Data(d) => {
2265                        sqlx::query(&d.sql).execute(&mut *tx).await?;
2266                    }
2267                    Operation::Callback(c) => {
2268                        // #347 — see `invoke_migration_callback` doc.
2269                        invoke_migration_callback(c, pool.clone().into()).await?;
2270                    }
2271                }
2272            }
2273            for stmt in deferred_fks {
2274                sqlx::query(&stmt).execute(&mut *tx).await?;
2275            }
2276            sqlx::query(&format!("INSERT INTO {ledger} (name) VALUES (?)"))
2277                .bind(&mig.name)
2278                .execute(&mut *tx)
2279                .await?;
2280            tx.commit().await?;
2281        }
2282    }
2283    Ok(())
2284}
2285
2286/// Apply one migration without a transaction (the file's `atomic`
2287/// field is `false` — typically because it contains `CREATE INDEX
2288/// CONCURRENTLY` which neither backend allows inside a transaction).
2289async fn apply_nonatomic_pool(
2290    pool: &crate::sql::Pool,
2291    mig: &Migration,
2292    ledger: &str,
2293) -> Result<(), MigrateError> {
2294    tracing::info!(migration = %mig.name, "applying (non-atomic, _pool)");
2295    let mut deferred_fks: Vec<String> = Vec::new();
2296    for op in &mig.forward {
2297        match op {
2298            Operation::Schema(change) => {
2299                let batch = super::diff::render_changes_split_with_dialect(
2300                    std::slice::from_ref(change),
2301                    &mig.snapshot,
2302                    pool.dialect(),
2303                )
2304                .map_err(MigrateError::Validation)?;
2305                for stmt in batch.immediate {
2306                    crate::sql::raw_execute_pool(pool, &stmt, ::std::vec::Vec::new()).await?;
2307                }
2308                deferred_fks.extend(batch.deferred_fks);
2309            }
2310            Operation::Data(d) => {
2311                crate::sql::raw_execute_pool(pool, &d.sql, ::std::vec::Vec::new()).await?;
2312            }
2313            Operation::Callback(c) => {
2314                // #347 — non-tx context; pass the pool directly.
2315                invoke_migration_callback(c, pool.clone()).await?;
2316            }
2317        }
2318    }
2319    for stmt in deferred_fks {
2320        crate::sql::raw_execute_pool(pool, &stmt, ::std::vec::Vec::new()).await?;
2321    }
2322    let placeholder = pool.dialect().placeholder(1);
2323    let insert_sql = format!("INSERT INTO {ledger} (name) VALUES ({placeholder})");
2324    crate::sql::raw_execute_pool(
2325        pool,
2326        &insert_sql,
2327        ::std::vec![crate::core::SqlValue::String(mig.name.clone())],
2328    )
2329    .await?;
2330    Ok(())
2331}
2332
2333// ====================================================================
2334// Direction-aware `_pool` runners — v0.23.0-batch14
2335// ====================================================================
2336//
2337// `migrate_to_pool` / `unapply_pool` / `unapply_force_pool` /
2338// `downgrade_pool` / `migrate_dry_run_pool` — bi-dialect counterparts
2339// to the existing PgPool functions. Same semantics, advisory-locked
2340// via `with_migrate_lock_pool` (batch 13).
2341//
2342// `migrate_embedded_pool` follows the same pattern but isn't yet
2343// emitted — the embed_migrations! macro and its callers are
2344// PgPool-bound and migrating them is a separate concern.
2345
2346/// Move the database to a specific migration target — bi-dialect
2347/// counterpart of [`migrate_to`].
2348///
2349/// # Errors
2350/// As [`migrate_to`].
2351pub async fn migrate_to_pool(
2352    pool: &crate::sql::Pool,
2353    dir: &Path,
2354    target: &str,
2355) -> Result<Vec<Migration>, MigrateError> {
2356    migrate_to_pool_with_ledger(pool, dir, target, LEDGER_TABLE).await
2357}
2358
2359/// Migrate to a specific target against a custom-named ledger.
2360/// Sibling of [`migrate_to_pool`] (issue #146).
2361///
2362/// # Errors
2363/// As [`migrate_to_pool`].
2364pub async fn migrate_to_pool_with_ledger(
2365    pool: &crate::sql::Pool,
2366    dir: &Path,
2367    target: &str,
2368    ledger: &str,
2369) -> Result<Vec<Migration>, MigrateError> {
2370    ensure_ledger_pool_with_ledger(pool, ledger).await?;
2371    with_migrate_lock_pool(pool, async {
2372        let all = file::list_dir(dir)?;
2373        let applied = applied_set_pool_with_ledger(pool, ledger).await?;
2374
2375        if target == "zero" {
2376            return unapply_all_in_order_pool(pool, dir, &all, &applied, ledger).await;
2377        }
2378
2379        if !all.iter().any(|m| m.name == target) {
2380            return Err(MigrateError::Validation(format!(
2381                "target migration `{target}` not found in {}",
2382                dir.display()
2383            )));
2384        }
2385
2386        let head = all
2387            .iter()
2388            .rev()
2389            .find(|m| applied.contains(&m.name))
2390            .map(|m| m.name.clone());
2391
2392        let mut touched = Vec::new();
2393        match head {
2394            None => {
2395                for mig in all.into_iter().filter(|m| m.name.as_str() <= target) {
2396                    apply_one_pool(pool, &mig, ledger).await?;
2397                    touched.push(mig);
2398                }
2399            }
2400            Some(h) => {
2401                use std::cmp::Ordering;
2402                match target.cmp(h.as_str()) {
2403                    Ordering::Equal => {}
2404                    Ordering::Greater => {
2405                        for mig in all.into_iter().filter(|m| {
2406                            m.name.as_str() > h.as_str()
2407                                && m.name.as_str() <= target
2408                                && !applied.contains(&m.name)
2409                        }) {
2410                            apply_one_pool(pool, &mig, ledger).await?;
2411                            touched.push(mig);
2412                        }
2413                    }
2414                    Ordering::Less => {
2415                        let mut to_unapply: Vec<Migration> = all
2416                            .into_iter()
2417                            .filter(|m| {
2418                                m.name.as_str() > target
2419                                    && m.name.as_str() <= h.as_str()
2420                                    && applied.contains(&m.name)
2421                            })
2422                            .collect();
2423                        to_unapply.reverse();
2424                        for mig in to_unapply {
2425                            unapply_locked_pool(pool, dir, &mig.name, ledger).await?;
2426                            touched.push(mig);
2427                        }
2428                    }
2429                }
2430            }
2431        }
2432        Ok(touched)
2433    })
2434    .await
2435}
2436
2437/// Step back `steps` applied migrations against either backend.
2438///
2439/// # Errors
2440/// As [`downgrade`].
2441pub async fn downgrade_pool(
2442    pool: &crate::sql::Pool,
2443    dir: &Path,
2444    steps: usize,
2445) -> Result<Vec<Migration>, MigrateError> {
2446    downgrade_pool_with_ledger(pool, dir, steps, LEDGER_TABLE).await
2447}
2448
2449/// Roll back `steps` migrations against a custom-named ledger.
2450/// Sibling of [`downgrade_pool`] (issue #146).
2451///
2452/// # Errors
2453/// As [`downgrade_pool`].
2454pub async fn downgrade_pool_with_ledger(
2455    pool: &crate::sql::Pool,
2456    dir: &Path,
2457    steps: usize,
2458    ledger: &str,
2459) -> Result<Vec<Migration>, MigrateError> {
2460    if steps == 0 {
2461        return Ok(Vec::new());
2462    }
2463    ensure_ledger_pool_with_ledger(pool, ledger).await?;
2464    with_migrate_lock_pool(pool, async {
2465        let all = file::list_dir(dir)?;
2466        let applied = applied_set_pool_with_ledger(pool, ledger).await?;
2467
2468        let applied_in_order: Vec<Migration> = all
2469            .into_iter()
2470            .filter(|m| applied.contains(&m.name))
2471            .collect();
2472        if applied_in_order.is_empty() {
2473            return Ok(Vec::new());
2474        }
2475
2476        let n = steps.min(applied_in_order.len());
2477        let to_unapply: Vec<Migration> = applied_in_order.into_iter().rev().take(n).collect();
2478
2479        let mut touched = Vec::with_capacity(to_unapply.len());
2480        for mig in to_unapply {
2481            unapply_locked_pool(pool, dir, &mig.name, ledger).await?;
2482            touched.push(mig);
2483        }
2484        Ok(touched)
2485    })
2486    .await
2487}
2488
2489/// Roll back a single applied migration against either backend.
2490/// Refuses non-head targets (use [`downgrade_pool`] /
2491/// [`migrate_to_pool`] for ordered rollback, or
2492/// [`unapply_force_pool`] to bypass).
2493///
2494/// # Errors
2495/// As [`unapply`].
2496pub async fn unapply_pool(
2497    pool: &crate::sql::Pool,
2498    dir: &Path,
2499    name: &str,
2500) -> Result<Migration, MigrateError> {
2501    unapply_pool_with_ledger(pool, dir, name, LEDGER_TABLE).await
2502}
2503
2504/// Unapply a single named migration against a custom-named ledger.
2505/// Sibling of [`unapply_pool`] (issue #146).
2506///
2507/// # Errors
2508/// As [`unapply_pool`].
2509pub async fn unapply_pool_with_ledger(
2510    pool: &crate::sql::Pool,
2511    dir: &Path,
2512    name: &str,
2513    ledger: &str,
2514) -> Result<Migration, MigrateError> {
2515    ensure_ledger_pool_with_ledger(pool, ledger).await?;
2516    with_migrate_lock_pool(pool, async {
2517        check_is_head_pool(pool, dir, name, ledger).await?;
2518        unapply_locked_pool(pool, dir, name, ledger).await
2519    })
2520    .await
2521}
2522
2523/// Roll back any applied migration on either backend, even out of
2524/// order. Caller accepts responsibility for the resulting schema state.
2525///
2526/// # Errors
2527/// As [`unapply_force`].
2528pub async fn unapply_force_pool(
2529    pool: &crate::sql::Pool,
2530    dir: &Path,
2531    name: &str,
2532) -> Result<Migration, MigrateError> {
2533    unapply_force_pool_with_ledger(pool, dir, name, LEDGER_TABLE).await
2534}
2535
2536async fn unapply_force_pool_with_ledger(
2537    pool: &crate::sql::Pool,
2538    dir: &Path,
2539    name: &str,
2540    ledger: &str,
2541) -> Result<Migration, MigrateError> {
2542    ensure_ledger_pool_with_ledger(pool, ledger).await?;
2543    with_migrate_lock_pool(pool, unapply_locked_pool(pool, dir, name, ledger)).await
2544}
2545
2546/// Compute the SQL `migrate_pool(pool, dir)` would execute, without
2547/// running any of it. Bi-dialect counterpart of [`migrate_dry_run`].
2548///
2549/// # Errors
2550/// As [`migrate_dry_run`].
2551pub async fn migrate_dry_run_pool(
2552    pool: &crate::sql::Pool,
2553    dir: &Path,
2554) -> Result<Vec<MigrationPreview>, MigrateError> {
2555    migrate_dry_run_pool_with_ledger(pool, dir, LEDGER_TABLE).await
2556}
2557
2558/// Dry-run pending migrations against a custom-named ledger.
2559/// Sibling of [`migrate_dry_run_pool`] (issue #146).
2560///
2561/// # Errors
2562/// As [`migrate_dry_run_pool`].
2563pub async fn migrate_dry_run_pool_with_ledger(
2564    pool: &crate::sql::Pool,
2565    dir: &Path,
2566    ledger: &str,
2567) -> Result<Vec<MigrationPreview>, MigrateError> {
2568    ensure_ledger_pool_with_ledger(pool, ledger).await?;
2569    let all = file::list_dir(dir)?;
2570    let applied = applied_set_pool_with_ledger(pool, ledger).await?;
2571    let pending: Vec<Migration> = all
2572        .into_iter()
2573        .filter(|m| !applied.contains(&m.name))
2574        .collect();
2575    let mut out = Vec::with_capacity(pending.len());
2576    for mig in pending {
2577        out.push(preview_migration(&mig, ledger)?);
2578    }
2579    Ok(out)
2580}
2581
2582// ---- internal helpers ----
2583
2584async fn apply_one_pool(
2585    pool: &crate::sql::Pool,
2586    mig: &Migration,
2587    ledger: &str,
2588) -> Result<(), MigrateError> {
2589    if mig.atomic {
2590        apply_atomic_pool(pool, mig, ledger).await
2591    } else {
2592        apply_nonatomic_pool(pool, mig, ledger).await
2593    }
2594}
2595
2596async fn unapply_all_in_order_pool(
2597    pool: &crate::sql::Pool,
2598    dir: &Path,
2599    all: &[Migration],
2600    applied: &HashSet<String>,
2601    ledger: &str,
2602) -> Result<Vec<Migration>, MigrateError> {
2603    let mut to_unapply: Vec<Migration> = all
2604        .iter()
2605        .filter(|m| applied.contains(&m.name))
2606        .cloned()
2607        .collect();
2608    to_unapply.reverse();
2609    let mut touched = Vec::with_capacity(to_unapply.len());
2610    for mig in to_unapply {
2611        unapply_locked_pool(pool, dir, &mig.name, ledger).await?;
2612        touched.push(mig);
2613    }
2614    Ok(touched)
2615}
2616
2617/// `unapply_pool`'s body without acquiring the migrate lock — used
2618/// by `migrate_to_pool` and `downgrade_pool` which already hold it.
2619async fn unapply_locked_pool(
2620    pool: &crate::sql::Pool,
2621    dir: &Path,
2622    name: &str,
2623    ledger: &str,
2624) -> Result<Migration, MigrateError> {
2625    let all = file::list_dir(dir)?;
2626    let target = all
2627        .iter()
2628        .find(|m| m.name == name)
2629        .cloned()
2630        .ok_or_else(|| {
2631            MigrateError::Validation(format!("migration `{name}` not found in {}", dir.display()))
2632        })?;
2633
2634    let prev_snapshot = match &target.prev {
2635        None => SchemaSnapshot {
2636            tables: vec![],
2637            m2m_tables: vec![],
2638            indexes: vec![],
2639            checks: vec![],
2640            excludes: vec![],
2641        },
2642        Some(prev_name) => all
2643            .iter()
2644            .find(|m| &m.name == prev_name)
2645            .map(|m| m.snapshot.clone())
2646            .ok_or_else(|| {
2647                MigrateError::Validation(format!(
2648                    "migration `{name}` declares prev=`{prev_name}` but that file is missing in {}",
2649                    dir.display()
2650                ))
2651            })?,
2652    };
2653
2654    let inverted = invert(&target.forward, &prev_snapshot)?;
2655
2656    if target.atomic {
2657        unapply_atomic_pool(pool, &target, &inverted, &prev_snapshot, ledger).await?;
2658    } else {
2659        unapply_nonatomic_pool(pool, &target, &inverted, &prev_snapshot, ledger).await?;
2660    }
2661
2662    Ok(target)
2663}
2664
2665async fn check_is_head_pool(
2666    pool: &crate::sql::Pool,
2667    dir: &Path,
2668    name: &str,
2669    ledger: &str,
2670) -> Result<(), MigrateError> {
2671    let applied = applied_set_pool_with_ledger(pool, ledger).await?;
2672    if !applied.contains(name) {
2673        return Ok(());
2674    }
2675    let all = file::list_dir(dir)?;
2676    let head = all
2677        .iter()
2678        .rev()
2679        .find(|m| applied.contains(&m.name))
2680        .map(|m| m.name.as_str());
2681    match head {
2682        Some(h) if h == name => Ok(()),
2683        Some(h) => Err(MigrateError::Validation(format!(
2684            "refusing to unapply `{name}` out of order: current head is `{h}`. \
2685             Use `downgrade_pool(pool, dir, n)` / `migrate_to_pool(pool, dir, target)` for \
2686             ordered rollback, or `unapply_force_pool` to bypass.",
2687        ))),
2688        None => Ok(()),
2689    }
2690}
2691
2692async fn unapply_atomic_pool(
2693    pool: &crate::sql::Pool,
2694    target: &Migration,
2695    inverted: &[Operation],
2696    snapshot: &SchemaSnapshot,
2697    ledger: &str,
2698) -> Result<(), MigrateError> {
2699    tracing::info!(migration = %target.name, "unapplying (atomic, _pool)");
2700    match pool {
2701        #[cfg(feature = "postgres")]
2702        crate::sql::Pool::Postgres(pg) => {
2703            let mut tx = pg.begin().await?;
2704            let mut deferred_fks: Vec<String> = Vec::new();
2705            for op in inverted {
2706                match op {
2707                    Operation::Schema(change) => {
2708                        let batch = super::diff::render_changes_split_with_dialect(
2709                            std::slice::from_ref(change),
2710                            snapshot,
2711                            pool.dialect(),
2712                        )
2713                        .map_err(MigrateError::Validation)?;
2714                        for stmt in batch.immediate {
2715                            sqlx::query(&stmt).execute(&mut *tx).await?;
2716                        }
2717                        deferred_fks.extend(batch.deferred_fks);
2718                    }
2719                    Operation::Data(d) => {
2720                        sqlx::query(&d.sql).execute(&mut *tx).await?;
2721                    }
2722                    Operation::Callback(c) => {
2723                        // #347 — see `invoke_migration_callback` doc.
2724                        invoke_migration_callback(c, pool.clone().into()).await?;
2725                    }
2726                }
2727            }
2728            for stmt in deferred_fks {
2729                sqlx::query(&stmt).execute(&mut *tx).await?;
2730            }
2731            sqlx::query(&format!("DELETE FROM {ledger} WHERE name = $1"))
2732                .bind(&target.name)
2733                .execute(&mut *tx)
2734                .await?;
2735            tx.commit().await?;
2736        }
2737        #[cfg(feature = "mysql")]
2738        crate::sql::Pool::Mysql(my) => {
2739            // MySQL: implicit-commit on every DDL statement defeats the
2740            // atomic-rollback wrapper here too. A failure mid-unapply
2741            // leaves the schema half-reverted. See `apply_atomic_pool`'s
2742            // MySQL arm for the full caveat doc. Tracked in #559.
2743            tracing::warn!(
2744                migration = %target.name,
2745                "MySQL silently auto-commits on every DDL statement; the atomic-unapply \
2746                 wrapper only protects RunSQL/RunPython between DDL ops. A failure mid-unapply \
2747                 leaves the schema half-reverted and requires manual recovery."
2748            );
2749            let mut tx = my.begin().await?;
2750            let mut deferred_fks: Vec<String> = Vec::new();
2751            for op in inverted {
2752                match op {
2753                    Operation::Schema(change) => {
2754                        let batch = super::diff::render_changes_split_with_dialect(
2755                            std::slice::from_ref(change),
2756                            snapshot,
2757                            pool.dialect(),
2758                        )
2759                        .map_err(MigrateError::Validation)?;
2760                        for stmt in batch.immediate {
2761                            sqlx::query(&stmt).execute(&mut *tx).await?;
2762                        }
2763                        deferred_fks.extend(batch.deferred_fks);
2764                    }
2765                    Operation::Data(d) => {
2766                        sqlx::query(&d.sql).execute(&mut *tx).await?;
2767                    }
2768                    Operation::Callback(c) => {
2769                        // #347 — see `invoke_migration_callback` doc.
2770                        invoke_migration_callback(c, pool.clone().into()).await?;
2771                    }
2772                }
2773            }
2774            for stmt in deferred_fks {
2775                sqlx::query(&stmt).execute(&mut *tx).await?;
2776            }
2777            sqlx::query(&format!("DELETE FROM {ledger} WHERE name = ?"))
2778                .bind(&target.name)
2779                .execute(&mut *tx)
2780                .await?;
2781            tx.commit().await?;
2782        }
2783        #[cfg(feature = "sqlite")]
2784        crate::sql::Pool::Sqlite(sq) => {
2785            let mut tx = sq.begin().await?;
2786            let mut deferred_fks: Vec<String> = Vec::new();
2787            for op in inverted {
2788                match op {
2789                    Operation::Schema(change) => {
2790                        let batch = super::diff::render_changes_split_with_dialect(
2791                            std::slice::from_ref(change),
2792                            snapshot,
2793                            pool.dialect(),
2794                        )
2795                        .map_err(MigrateError::Validation)?;
2796                        for stmt in batch.immediate {
2797                            sqlx::query(&stmt).execute(&mut *tx).await?;
2798                        }
2799                        deferred_fks.extend(batch.deferred_fks);
2800                    }
2801                    Operation::Data(d) => {
2802                        sqlx::query(&d.sql).execute(&mut *tx).await?;
2803                    }
2804                    Operation::Callback(c) => {
2805                        // #347 — see `invoke_migration_callback` doc.
2806                        invoke_migration_callback(c, pool.clone().into()).await?;
2807                    }
2808                }
2809            }
2810            for stmt in deferred_fks {
2811                sqlx::query(&stmt).execute(&mut *tx).await?;
2812            }
2813            sqlx::query(&format!("DELETE FROM {ledger} WHERE name = ?"))
2814                .bind(&target.name)
2815                .execute(&mut *tx)
2816                .await?;
2817            tx.commit().await?;
2818        }
2819    }
2820    Ok(())
2821}
2822
2823/// Apply pending migrations from an in-memory `&[(name, json)]` slice
2824/// against either backend. Bi-dialect counterpart of [`migrate_embedded`].
2825///
2826/// Built for single-binary deployments where shipping a `migrations/`
2827/// folder alongside the binary is awkward (Docker images, scratch
2828/// containers, embedded systems). Pair with the
2829/// [`embed_migrations!`](crate::embed_migrations) proc-macro, which
2830/// scans a directory at compile time and emits the slice via
2831/// `include_str!` per file.
2832///
2833/// Each entry's first item must equal the migration's `name` field
2834/// — a divergence would mean the slice was hand-built incorrectly.
2835///
2836/// # Errors
2837/// As [`migrate_embedded`], plus [`MigrateError::Validation`] when an
2838/// entry key doesn't match the migration's own `name` field.
2839pub async fn migrate_embedded_pool(
2840    pool: &crate::sql::Pool,
2841    embedded: &[(&str, &str)],
2842) -> Result<Vec<Migration>, MigrateError> {
2843    migrate_embedded_pool_with_ledger(pool, embedded, LEDGER_TABLE).await
2844}
2845
2846async fn migrate_embedded_pool_with_ledger(
2847    pool: &crate::sql::Pool,
2848    embedded: &[(&str, &str)],
2849    ledger: &str,
2850) -> Result<Vec<Migration>, MigrateError> {
2851    ensure_ledger_pool_with_ledger(pool, ledger).await?;
2852    with_migrate_lock_pool(pool, async {
2853        let mut all: Vec<Migration> = Vec::with_capacity(embedded.len());
2854        for (name, json) in embedded {
2855            let mig = file::parse(json)?;
2856            if mig.name != *name {
2857                return Err(MigrateError::Validation(format!(
2858                    "embedded entry key `{name}` doesn't match migration `name` field `{}`",
2859                    mig.name,
2860                )));
2861            }
2862            all.push(mig);
2863        }
2864        all.sort_by(|a, b| a.name.cmp(&b.name));
2865        file::validate_chain(&all, "embedded slice")?;
2866
2867        let applied = applied_set_pool_with_ledger(pool, ledger).await?;
2868        let pending: Vec<Migration> = all
2869            .into_iter()
2870            .filter(|m| !applied.contains(&m.name))
2871            .collect();
2872
2873        let mut newly = Vec::with_capacity(pending.len());
2874        for mig in pending {
2875            apply_one_pool(pool, &mig, ledger).await?;
2876            newly.push(mig);
2877        }
2878        Ok(newly)
2879    })
2880    .await
2881}
2882
2883async fn unapply_nonatomic_pool(
2884    pool: &crate::sql::Pool,
2885    target: &Migration,
2886    inverted: &[Operation],
2887    snapshot: &SchemaSnapshot,
2888    ledger: &str,
2889) -> Result<(), MigrateError> {
2890    tracing::info!(migration = %target.name, "unapplying (non-atomic, _pool)");
2891    let mut deferred_fks: Vec<String> = Vec::new();
2892    for op in inverted {
2893        match op {
2894            Operation::Schema(change) => {
2895                let batch = super::diff::render_changes_split_with_dialect(
2896                    std::slice::from_ref(change),
2897                    snapshot,
2898                    pool.dialect(),
2899                )
2900                .map_err(MigrateError::Validation)?;
2901                for stmt in batch.immediate {
2902                    crate::sql::raw_execute_pool(pool, &stmt, ::std::vec::Vec::new()).await?;
2903                }
2904                deferred_fks.extend(batch.deferred_fks);
2905            }
2906            Operation::Data(d) => {
2907                crate::sql::raw_execute_pool(pool, &d.sql, ::std::vec::Vec::new()).await?;
2908            }
2909            Operation::Callback(c) => {
2910                // #347 — non-tx context; pass the pool directly.
2911                invoke_migration_callback(c, pool.clone()).await?;
2912            }
2913        }
2914    }
2915    for stmt in deferred_fks {
2916        crate::sql::raw_execute_pool(pool, &stmt, ::std::vec::Vec::new()).await?;
2917    }
2918    let placeholder = pool.dialect().placeholder(1);
2919    let delete_sql = format!("DELETE FROM {ledger} WHERE name = {placeholder}");
2920    crate::sql::raw_execute_pool(
2921        pool,
2922        &delete_sql,
2923        ::std::vec![crate::core::SqlValue::String(target.name.clone())],
2924    )
2925    .await?;
2926    Ok(())
2927}