Skip to main content

drizzle_cli/db/
mod.rs

1//! Live-database work for CLI commands: planning and applying migrations,
2//! planning and applying `push`, and introspection.
3//!
4//! Each entry point dispatches on the resolved credentials to whichever
5//! driver features were compiled in.
6
7use std::path::Path;
8
9use crate::commands::overrides::ResolvedConnection;
10#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
11use crate::config::PostgresCreds;
12use crate::config::{Credentials, Dialect, Driver, Extension, IntrospectCasing};
13use crate::error::CliError;
14use crate::output;
15use drizzle_migrations::Migrations;
16use drizzle_migrations::schema::Snapshot;
17
18#[cfg(feature = "d1-http")]
19mod d1_http;
20mod filters;
21mod mysql;
22
23pub use filters::apply_snapshot_filters;
24#[cfg(test)]
25use filters::{compile_patterns, matches_patterns};
26
27/// What [`run_migrations`] did.
28#[derive(Debug, Default)]
29pub struct MigrationResult {
30    /// Number of migrations applied
31    pub applied_count: usize,
32    /// Tags of applied migrations
33    pub applied_migrations: Vec<String>,
34    /// Tags of interrupted migrations reconciled by `--repair`.
35    pub repaired_migrations: Vec<String>,
36}
37
38/// Planned migration execution details.
39#[derive(Debug, Clone)]
40pub struct MigrationPlan {
41    /// Number of already-applied migrations found in the database metadata table.
42    pub applied_count: usize,
43    /// Number of pending migrations found locally.
44    pub pending_count: usize,
45    /// Pending migration tags in execution order.
46    pub pending_migrations: Vec<String>,
47    /// Total number of non-empty SQL statements in pending migrations.
48    pub pending_statements: usize,
49    /// Integrity findings: hash drift between applied rows and local files,
50    /// applied rows with no local migration, duplicate tracking rows, and
51    /// interrupted (dirty) migrations. `--plan` reports these as warnings;
52    /// `--verify` and `--safe` treat any finding as failure.
53    pub findings: Vec<String>,
54}
55
56/// A tracking table as `migrate --plan`, `--dry-run` and `--verify` read it:
57/// they must not create or upgrade it.
58#[cfg(any(
59    feature = "rusqlite",
60    feature = "libsql",
61    feature = "turso",
62    feature = "postgres-sync",
63    feature = "tokio-postgres",
64    feature = "mysql-sync",
65    feature = "mysql-async",
66))]
67pub(crate) enum TrackingState {
68    /// No tracking table: no migration has run.
69    Missing,
70    /// A table from before migrations were recorded by name, as its rows.
71    Legacy(Vec<drizzle_migrations::AppliedMigrationMetadata>),
72    /// The current layout.
73    Current,
74}
75
76/// The applied migrations of a legacy tracking table, matched to the local
77/// ones by timestamp and hash the way `migrate` upgrades the table.
78#[cfg(any(
79    feature = "rusqlite",
80    feature = "libsql",
81    feature = "turso",
82    feature = "postgres-sync",
83    feature = "tokio-postgres",
84    feature = "mysql-sync",
85    feature = "mysql-async",
86))]
87pub(crate) fn legacy_applied_records(
88    set: &Migrations,
89    rows: &[drizzle_migrations::AppliedMigrationMetadata],
90) -> Result<Vec<AppliedMigrationRecord>, CliError> {
91    let matched = drizzle_migrations::match_applied_migration_metadata(set.all(), rows)
92        .map_err(|e| CliError::MigrationError(e.to_string()))?;
93    Ok(matched
94        .into_iter()
95        .map(|row| AppliedMigrationRecord {
96            hash: row.hash,
97            name: row.name,
98            dirty: false,
99        })
100        .collect())
101}
102
103/// Whether a SQLite database named by `path` exists. Opening a missing file
104/// would create it; `:memory:` and `file:` URIs are left to the driver.
105#[cfg(any(feature = "rusqlite", feature = "libsql"))]
106fn sqlite_database_exists(path: &str) -> bool {
107    path == ":memory:" || path.starts_with("file:") || std::path::Path::new(path).exists()
108}
109
110#[cfg(any(
111    feature = "rusqlite",
112    feature = "libsql",
113    feature = "turso",
114    feature = "postgres-sync",
115    feature = "tokio-postgres",
116    feature = "mysql-sync",
117    feature = "mysql-async",
118    feature = "d1-http",
119))]
120#[derive(Debug, Clone)]
121pub(crate) struct AppliedMigrationRecord {
122    pub(crate) hash: String,
123    pub(crate) name: String,
124    /// Row was started but never marked finished (`applied_at IS NULL`).
125    pub(crate) dirty: bool,
126}
127
128/// SQL that `drizzle push` would run, from [`plan_push`].
129#[derive(Debug, Clone)]
130pub struct PushPlan {
131    /// Statements to execute, in order.
132    pub sql_statements: Vec<String>,
133    /// Warnings from diffing (for example possible data loss).
134    pub warnings: Vec<String>,
135    /// `true` when the plan may lose data, so
136    /// [`apply_push`] asks for confirmation unless forced.
137    pub destructive: bool,
138}
139
140/// Optional filters for introspection and push planning.
141#[derive(Debug, Clone, Default)]
142pub struct SnapshotFilters {
143    /// Table name patterns to keep (`tablesFilter`).
144    pub tables: Option<Vec<String>>,
145    /// PostgreSQL schema patterns to keep (`schemaFilter`).
146    pub schemas: Option<Vec<String>>,
147    /// PostgreSQL extensions whose objects are left out.
148    pub extensions: Option<Vec<Extension>>,
149    /// Which `PostgreSQL` roles (and their privileges) to keep, from
150    /// `entities.roles`. `None` leaves roles as they are.
151    pub roles: Option<crate::config::RolesFilter>,
152}
153
154fn connection_driver_mismatch(connection: &ResolvedConnection) -> CliError {
155    CliError::Other(format!(
156        "driver '{}' cannot use the resolved {} credentials",
157        connection.driver, connection.dialect
158    ))
159}
160
161impl SnapshotFilters {
162    const fn is_empty(&self) -> bool {
163        self.tables.is_none() && self.schemas.is_none() && self.extensions.is_none()
164    }
165}
166
167/// Plans a push: introspects the live database and diffs it against
168/// `desired`.
169///
170/// The tracking table and anything `filters` excludes are removed from the
171/// live side first; the caller filters `desired`. `breakpoints` is unused.
172/// The plan is destructive when a statement drops or truncates something,
173/// or (MySQL) when the diff emits any warning.
174///
175/// # Errors
176///
177/// Returns [`CliError`] if introspecting the live database fails, if applying
178/// the given snapshot filters fails, or if generating the diff SQL fails.
179pub fn plan_push(
180    connection: &ResolvedConnection,
181    desired: &Snapshot,
182    breakpoints: bool,
183    filters: &SnapshotFilters,
184    migrations_table: &str,
185) -> Result<PushPlan, CliError> {
186    plan_push_with_renames(
187        connection,
188        desired,
189        breakpoints,
190        filters,
191        migrations_table,
192        &mut |_, _, options| Ok(options),
193    )
194}
195
196/// [`plan_push`] that lets `resolve_renames` settle rename-or-create
197/// questions before diffing.
198///
199/// `resolve_renames` gets the live snapshot (after filtering), the desired
200/// snapshot, and the diff options, and returns the options to diff with,
201/// typically from
202/// [`renames::resolve_for_command`](crate::commands::renames::resolve_for_command).
203/// [`plan_push`] passes the options through, so the diff infers renames.
204///
205/// # Errors
206///
207/// Returns the errors of [`plan_push`] and of `resolve_renames`.
208pub fn plan_push_with_renames(
209    connection: &ResolvedConnection,
210    desired: &Snapshot,
211    breakpoints: bool,
212    filters: &SnapshotFilters,
213    migrations_table: &str,
214    resolve_renames: &mut dyn FnMut(
215        &Snapshot,
216        &Snapshot,
217        drizzle_migrations::DiffOptions,
218    ) -> Result<drizzle_migrations::DiffOptions, CliError>,
219) -> Result<PushPlan, CliError> {
220    let introspected = introspect_database(connection)?;
221    let mut current = introspected.snapshot;
222    exclude_tracking_table(&mut current, connection.dialect, migrations_table)?;
223    apply_snapshot_filters(&mut current, connection.dialect, filters)?;
224    // PostgreSQL rewrites stored expressions into its own form; compare them
225    // the way drizzle-kit's push does, or every push would recreate them.
226    if let (Snapshot::Postgres(live), Snapshot::Postgres(wanted)) = (&mut current, desired) {
227        live.align_push_insensitive_fields(wanted);
228    }
229    let mut options = drizzle_migrations::DiffOptions::new();
230    if let Some(defaults) = introspected.mysql_catalog_defaults {
231        options = options.mysql_catalog_defaults(defaults);
232    }
233    // Ask about renames on exactly the snapshots the diff compares.
234    let options = resolve_renames(&current, desired, options)?;
235    let (sql_statements, warnings) = generate_push_sql(&current, desired, breakpoints, &options)?;
236    let destructive = push_requires_confirmation(connection.dialect, &sql_statements, &warnings);
237
238    Ok(PushPlan {
239        sql_statements,
240        warnings,
241        destructive,
242    })
243}
244
245fn exclude_tracking_table(
246    snapshot: &mut Snapshot,
247    dialect: Dialect,
248    migrations_table: &str,
249) -> Result<(), CliError> {
250    if migrations_table.trim().is_empty() {
251        return Ok(());
252    }
253    apply_snapshot_filters(
254        snapshot,
255        dialect,
256        &SnapshotFilters {
257            tables: Some(vec![format!(
258                "!{}",
259                glob::Pattern::escape(migrations_table)
260            )]),
261            schemas: None,
262            extensions: None,
263            roles: None,
264        },
265    )
266}
267
268/// Reads the live schema for `drizzle seed`: introspects the database,
269/// drops the migrations tracking table, and applies `filters`.
270///
271/// # Errors
272///
273/// Returns [`CliError`] if introspecting the database or applying the
274/// filters fails.
275pub fn introspect_for_seed(
276    connection: &ResolvedConnection,
277    filters: &SnapshotFilters,
278    migrations_table: &str,
279) -> Result<Snapshot, CliError> {
280    let mut snapshot = introspect_database(connection)?.snapshot;
281    exclude_tracking_table(&mut snapshot, connection.dialect, migrations_table)?;
282    apply_snapshot_filters(&mut snapshot, connection.dialect, filters)?;
283    Ok(snapshot)
284}
285
286/// Runs the statements of a seed in order, in one transaction where the
287/// driver supports it.
288///
289/// # Errors
290///
291/// Returns [`CliError`] if no compiled driver matches the credentials,
292/// connecting fails, or a statement fails.
293pub fn execute_seed(
294    connection: &ResolvedConnection,
295    statements: &[String],
296) -> Result<(), CliError> {
297    execute_statements(connection, statements)
298}
299
300/// Runs a [`PushPlan`], asking for confirmation first when it is
301/// destructive and `force` is `false`.
302///
303/// # Errors
304///
305/// Returns [`CliError::Aborted`] if the user declines the confirmation prompt
306/// for a destructive plan, and [`CliError`] if the prompt itself fails or if
307/// executing the planned SQL statements against the database fails.
308pub fn apply_push(
309    connection: &ResolvedConnection,
310    plan: &PushPlan,
311    force: bool,
312) -> Result<(), CliError> {
313    apply_push_with_confirmation(connection, plan, force, confirm_destructive)
314}
315
316/// [`apply_push`] with the destructive-change prompt supplied by the caller.
317fn apply_push_with_confirmation(
318    connection: &ResolvedConnection,
319    plan: &PushPlan,
320    force: bool,
321    confirm: impl FnOnce() -> Result<bool, CliError>,
322) -> Result<(), CliError> {
323    if plan.sql_statements.is_empty() {
324        return Ok(());
325    }
326
327    if plan.destructive && !force && !confirm()? {
328        return Err(CliError::Aborted(
329            "Push aborted: the destructive changes were not confirmed, so nothing was applied"
330                .into(),
331        ));
332    }
333
334    execute_statements(connection, &plan.sql_statements)
335}
336
337/// Compares local migrations with the database's tracking table without
338/// changing anything (`migrate --plan` / `--verify`).
339///
340/// # Errors
341///
342/// Returns [`CliError`] if no compiled driver matches the credentials, if
343/// connecting to the database fails, or if reading the migration tracking
344/// table or on-disk migration files fails.
345#[allow(unused_variables)] // params consumed inside feature-gated block
346pub fn plan_migrations(
347    connection: &ResolvedConnection,
348    migrations_dir: &Path,
349    migrations_table: &str,
350    migrations_schema: &str,
351) -> Result<MigrationPlan, CliError> {
352    let set = load_migration_set(
353        connection.dialect,
354        migrations_dir,
355        migrations_table,
356        migrations_schema,
357    )?;
358
359    match &connection.credentials {
360        #[cfg(feature = "rusqlite")]
361        Credentials::Sqlite { path } => inspect_sqlite_migrations(&set, path),
362
363        #[cfg(not(feature = "rusqlite"))]
364        Credentials::Sqlite { .. } => Err(CliError::MissingDriver {
365            dialect: "SQLite",
366            feature: "rusqlite",
367        }),
368
369        #[cfg(any(feature = "libsql", feature = "turso"))]
370        Credentials::Turso { url, auth_token } => {
371            if connection.driver == Driver::Libsql {
372                #[cfg(feature = "libsql")]
373                {
374                    inspect_libsql_local_migrations(&set, url)
375                }
376                #[cfg(not(feature = "libsql"))]
377                {
378                    Err(CliError::MissingDriver {
379                        dialect: "LibSQL (local)",
380                        feature: "libsql",
381                    })
382                }
383            } else if connection.driver == Driver::Turso {
384                #[cfg(feature = "turso")]
385                {
386                    inspect_turso_migrations(&set, url, auth_token.as_deref())
387                }
388                #[cfg(not(feature = "turso"))]
389                {
390                    let _ = auth_token;
391                    Err(CliError::MissingDriver {
392                        dialect: "Turso (remote)",
393                        feature: "turso",
394                    })
395                }
396            } else {
397                Err(connection_driver_mismatch(connection))
398            }
399        }
400
401        #[cfg(all(not(feature = "turso"), not(feature = "libsql")))]
402        Credentials::Turso { .. } => Err(CliError::MissingDriver {
403            dialect: "Turso",
404            feature: "turso or libsql",
405        }),
406
407        Credentials::Postgres(creds) => {
408            let _ = creds;
409            match connection.driver {
410                Driver::PostgresSync => {
411                    #[cfg(feature = "postgres-sync")]
412                    {
413                        inspect_postgres_sync_migrations(&set, creds)
414                    }
415                    #[cfg(not(feature = "postgres-sync"))]
416                    {
417                        Err(CliError::MissingDriver {
418                            dialect: "PostgreSQL",
419                            feature: "postgres-sync",
420                        })
421                    }
422                }
423                Driver::TokioPostgres => {
424                    #[cfg(feature = "tokio-postgres")]
425                    {
426                        inspect_postgres_async_migrations(&set, creds)
427                    }
428                    #[cfg(not(feature = "tokio-postgres"))]
429                    {
430                        Err(CliError::MissingDriver {
431                            dialect: "PostgreSQL",
432                            feature: "tokio-postgres",
433                        })
434                    }
435                }
436                _ => Err(connection_driver_mismatch(connection)),
437            }
438        }
439
440        Credentials::MySQL(creds) => mysql::plan_migrations(connection.driver, creds, &set),
441
442        #[cfg(feature = "d1-http")]
443        Credentials::D1 {
444            account_id,
445            database_id,
446            token,
447        } => d1_http::inspect_migrations(&set, account_id, database_id, token),
448
449        #[cfg(not(feature = "d1-http"))]
450        Credentials::D1 { .. } => Err(CliError::MissingDriver {
451            dialect: "Cloudflare D1 (HTTP)",
452            feature: "d1-http",
453        }),
454
455        Credentials::AwsDataApi { .. } => Err(CliError::UnsupportedForDriver {
456            operation: "Migration planning against AWS Data API",
457            driver: "aws-data-api",
458            hint: "AWS RDS Data API schema ops are not yet wired into this CLI. For now, \
459                   run the generated SQL with `aws rds-data execute-statement --sql=...` \
460                   (or use tokio-postgres with a temporary direct connection).",
461        }),
462    }
463}
464
465/// Applies the pending migrations in `migrations_dir` and records them in
466/// the tracking table, creating or upgrading that table as needed.
467///
468/// With `repair`, an interrupted migration is first reconciled against the
469/// live schema (see [`drizzle_migrations::repair`]); without it, an
470/// interrupted migration stops the run.
471///
472/// # Errors
473///
474/// Returns [`CliError`] if no compiled driver matches, connecting fails, the
475/// migrations cannot be loaded, a migration is interrupted (and `repair` is
476/// off or cannot reconcile it), a statement fails, or the tracking table
477/// cannot be written.
478#[allow(unused_variables)] // params consumed inside feature-gated block
479pub fn run_migrations(
480    connection: &ResolvedConnection,
481    migrations_dir: &Path,
482    migrations_table: &str,
483    migrations_schema: &str,
484    repair: bool,
485) -> Result<MigrationResult, CliError> {
486    let set = load_migration_set(
487        connection.dialect,
488        migrations_dir,
489        migrations_table,
490        migrations_schema,
491    )?;
492
493    match &connection.credentials {
494        #[cfg(feature = "rusqlite")]
495        Credentials::Sqlite { path } => run_sqlite_migrations(&set, path, repair),
496
497        #[cfg(not(feature = "rusqlite"))]
498        Credentials::Sqlite { .. } => Err(CliError::MissingDriver {
499            dialect: "SQLite",
500            feature: "rusqlite",
501        }),
502
503        #[cfg(any(feature = "libsql", feature = "turso"))]
504        Credentials::Turso { url, auth_token } => {
505            if connection.driver == Driver::Libsql {
506                #[cfg(feature = "libsql")]
507                {
508                    run_libsql_local_migrations(&set, url, repair)
509                }
510                #[cfg(not(feature = "libsql"))]
511                {
512                    Err(CliError::MissingDriver {
513                        dialect: "LibSQL (local)",
514                        feature: "libsql",
515                    })
516                }
517            } else if connection.driver == Driver::Turso {
518                #[cfg(feature = "turso")]
519                {
520                    run_turso_migrations(&set, url, auth_token.as_deref(), repair)
521                }
522                #[cfg(not(feature = "turso"))]
523                {
524                    let _ = auth_token;
525                    Err(CliError::MissingDriver {
526                        dialect: "Turso (remote)",
527                        feature: "turso",
528                    })
529                }
530            } else {
531                Err(connection_driver_mismatch(connection))
532            }
533        }
534
535        #[cfg(all(not(feature = "turso"), not(feature = "libsql")))]
536        Credentials::Turso { .. } => Err(CliError::MissingDriver {
537            dialect: "Turso",
538            feature: "turso or libsql",
539        }),
540
541        Credentials::Postgres(creds) => {
542            let _ = creds;
543            match connection.driver {
544                Driver::PostgresSync => {
545                    #[cfg(feature = "postgres-sync")]
546                    {
547                        run_postgres_sync_migrations(&set, creds, repair)
548                    }
549                    #[cfg(not(feature = "postgres-sync"))]
550                    {
551                        Err(CliError::MissingDriver {
552                            dialect: "PostgreSQL",
553                            feature: "postgres-sync",
554                        })
555                    }
556                }
557                Driver::TokioPostgres => {
558                    #[cfg(feature = "tokio-postgres")]
559                    {
560                        run_postgres_async_migrations(&set, creds, repair)
561                    }
562                    #[cfg(not(feature = "tokio-postgres"))]
563                    {
564                        Err(CliError::MissingDriver {
565                            dialect: "PostgreSQL",
566                            feature: "tokio-postgres",
567                        })
568                    }
569                }
570                _ => Err(connection_driver_mismatch(connection)),
571            }
572        }
573
574        Credentials::MySQL(creds) => mysql::run_migrations(connection.driver, creds, &set, repair),
575
576        #[cfg(feature = "d1-http")]
577        Credentials::D1 {
578            account_id,
579            database_id,
580            token,
581        } => d1_http::run_migrations(&set, account_id, database_id, token, repair),
582
583        #[cfg(not(feature = "d1-http"))]
584        Credentials::D1 { .. } => Err(CliError::MissingDriver {
585            dialect: "Cloudflare D1 (HTTP)",
586            feature: "d1-http",
587        }),
588
589        Credentials::AwsDataApi { .. } => Err(CliError::UnsupportedForDriver {
590            operation: "Running migrations against AWS Data API",
591            driver: "aws-data-api",
592            hint: "AWS RDS Data API migrations are not yet wired into this CLI. Run the \
593                   generated SQL via `aws rds-data execute-statement` or connect directly \
594                   with tokio-postgres.",
595        }),
596    }
597}
598
599fn load_migration_set(
600    dialect: Dialect,
601    migrations_dir: &Path,
602    migrations_table: &str,
603    migrations_schema: &str,
604) -> Result<Migrations, CliError> {
605    let tracking = migration_tracking(dialect, migrations_table, migrations_schema);
606
607    // Load migrations from filesystem
608    let migrations = drizzle_migrations::MigrationDir::new(migrations_dir)
609        .dialect(dialect.to_base())
610        .discover()
611        .map_err(|e| CliError::Other(format!("Failed to load migrations: {e}")))?;
612    Ok(Migrations::with_tracking(
613        migrations,
614        dialect.to_base(),
615        tracking,
616    ))
617}
618
619fn migration_tracking(
620    dialect: Dialect,
621    migrations_table: &str,
622    migrations_schema: &str,
623) -> drizzle_types::MigrationTracking {
624    let mut tracking = match dialect {
625        Dialect::Postgresql => drizzle_types::MigrationTracking::POSTGRES,
626        Dialect::Mysql => drizzle_types::MigrationTracking::MYSQL,
627        Dialect::Sqlite | Dialect::Turso => drizzle_types::MigrationTracking::SQLITE,
628    };
629
630    if !migrations_table.trim().is_empty() {
631        tracking = tracking.table(migrations_table.to_owned());
632    }
633
634    if dialect == Dialect::Postgresql && !migrations_schema.trim().is_empty() {
635        tracking = tracking.schema(migrations_schema.to_owned());
636    }
637
638    tracking
639}
640
641#[cfg(any(
642    feature = "rusqlite",
643    feature = "libsql",
644    feature = "turso",
645    feature = "postgres-sync",
646    feature = "tokio-postgres",
647    feature = "mysql-sync",
648    feature = "mysql-async",
649    feature = "d1-http",
650))]
651pub(crate) fn build_migration_plan(
652    set: &Migrations,
653    applied: &[AppliedMigrationRecord],
654) -> Result<MigrationPlan, CliError> {
655    let findings = collect_integrity_findings(set, applied)?;
656
657    // Dirty rows are not applied: the migration started but never finished.
658    let applied_names = applied
659        .iter()
660        .filter(|m| !m.dirty)
661        .map(|m| m.name.clone())
662        .collect::<Vec<_>>();
663    let pending = set.pending(&applied_names).collect::<Vec<_>>();
664
665    let pending_statements = pending
666        .iter()
667        .map(|m| {
668            m.statements()
669                .iter()
670                .filter(|stmt| !stmt.trim().is_empty())
671                .count()
672        })
673        .sum();
674
675    Ok(MigrationPlan {
676        applied_count: applied_names.len(),
677        pending_count: pending.len(),
678        pending_migrations: pending.iter().map(|m| m.tag().to_string()).collect(),
679        pending_statements,
680        findings,
681    })
682}
683
684/// Collect integrity findings between the tracking table and local files.
685///
686/// Findings (drift, missing-local rows, duplicate tracking rows, interrupted
687/// migrations) are returned rather than raised: `--plan` shows them as
688/// warnings while `--verify`/`--safe` fail on any. A duplicate name in the
689/// LOCAL migration set is still a hard error — the local set is broken and
690/// no comparison against it is meaningful.
691#[cfg(any(
692    feature = "rusqlite",
693    feature = "libsql",
694    feature = "turso",
695    feature = "postgres-sync",
696    feature = "tokio-postgres",
697    feature = "mysql-sync",
698    feature = "mysql-async",
699    feature = "d1-http",
700))]
701fn collect_integrity_findings(
702    set: &Migrations,
703    applied: &[AppliedMigrationRecord],
704) -> Result<Vec<String>, CliError> {
705    use std::collections::{HashMap, HashSet};
706
707    let mut local_by_name = HashMap::<&str, &str>::new();
708    for migration in set.all() {
709        if local_by_name
710            .insert(migration.name(), migration.hash())
711            .is_some()
712        {
713            return Err(CliError::MigrationError(format!(
714                "Local migrations contain duplicate name: {}",
715                migration.name()
716            )));
717        }
718    }
719
720    let mut findings = Vec::new();
721    let mut seen_db_names = HashSet::<&str>::new();
722    for applied_row in applied {
723        if !seen_db_names.insert(applied_row.name.as_str()) {
724            findings.push(format!(
725                "duplicate tracking rows for `{}` in the migrations table",
726                applied_row.name
727            ));
728            continue;
729        }
730
731        if applied_row.dirty {
732            let recovery = if set.dialect() == drizzle_types::Dialect::MySQL {
733                "inspect the partially applied DDL and resolve it manually"
734            } else {
735                "run `drizzle migrate --repair` or resolve it manually"
736            };
737            findings.push(format!(
738                "`{}` was interrupted mid-apply (started but never finished) — {recovery}",
739                applied_row.name,
740            ));
741        }
742
743        let Some(local_hash) = local_by_name.get(applied_row.name.as_str()) else {
744            findings.push(format!(
745                "`{}` is recorded in the database but no local migration has that name",
746                applied_row.name
747            ));
748            continue;
749        };
750
751        if *local_hash != applied_row.hash {
752            findings.push(format!(
753                "`{}` has drifted: the local migration file no longer matches what \
754                 was applied (database hash {}, local hash {})",
755                applied_row.name, applied_row.hash, local_hash
756            ));
757        }
758    }
759
760    Ok(findings)
761}
762
763#[cfg(any(feature = "rusqlite", feature = "libsql", feature = "turso"))]
764fn escape_sql_literal(value: &str) -> String {
765    value.replace('\'', "''")
766}
767
768// ============================================================================
769// Interrupted ("dirty") migration handling
770// ============================================================================
771//
772// A tracking row with a non-null `name` and a NULL `applied_at` means an
773// earlier run recorded a migration as started and never recorded it as
774// finished — the database may hold part of that migration's DDL. Every runner
775// checks for those rows before applying anything; `--repair` additionally
776// reconciles them statement-by-statement against live introspection.
777
778#[cfg(any(
779    feature = "rusqlite",
780    feature = "libsql",
781    feature = "turso",
782    feature = "postgres-sync",
783    feature = "tokio-postgres",
784))]
785fn migrator_cli_error(error: drizzle_migrations::MigratorError) -> CliError {
786    CliError::MigrationError(error.to_string())
787}
788
789/// Turn a dirty-name list into either an error (no `--repair`) or the list of
790/// migrations to reconcile.
791#[cfg(any(
792    feature = "rusqlite",
793    feature = "libsql",
794    feature = "turso",
795    feature = "postgres-sync",
796    feature = "tokio-postgres",
797))]
798fn dirty_migrations_to_repair<'a>(
799    set: &'a Migrations,
800    dirty: &[String],
801    repair: bool,
802) -> Result<Vec<&'a drizzle_migrations::Migration>, CliError> {
803    if dirty.is_empty() {
804        return Ok(Vec::new());
805    }
806    if !repair {
807        return Err(migrator_cli_error(
808            set.interrupted_migration_error(dirty)
809                .expect("dirty list is non-empty"),
810        ));
811    }
812    set.resolve_dirty_migrations(dirty)
813        .map_err(migrator_cli_error)
814}
815
816#[cfg(any(
817    feature = "rusqlite",
818    feature = "libsql",
819    feature = "turso",
820    feature = "d1-http",
821))]
822fn sqlite_execution_cli_error(
823    error: drizzle_migrations::SqliteMigrationExecutionError,
824) -> CliError {
825    CliError::MigrationError(error.to_string())
826}
827
828#[cfg(any(
829    feature = "rusqlite",
830    feature = "libsql",
831    feature = "turso",
832    feature = "d1-http",
833))]
834#[cfg(feature = "d1-http")]
835pub(super) fn reject_sqlite_foreign_key_suspension<'a>(
836    migrations: impl IntoIterator<Item = &'a drizzle_migrations::Migration>,
837    adapter: &str,
838) -> Result<(), CliError> {
839    let executions = migrations
840        .into_iter()
841        .map(drizzle_migrations::Migration::sqlite_execution)
842        .collect::<Result<Vec<_>, _>>()
843        .map_err(sqlite_execution_cli_error)?;
844    if executions
845        .iter()
846        .any(|execution| execution.suspends_foreign_keys())
847    {
848        return Err(CliError::MigrationError(format!(
849            "{adapter} cannot execute SQLite table rebuilds that suspend foreign keys"
850        )));
851    }
852    Ok(())
853}
854
855#[cfg(any(feature = "rusqlite", feature = "libsql", feature = "turso"))]
856fn ensure_sqlite_dirty_repairs_are_safe(
857    migrations: &[&drizzle_migrations::Migration],
858) -> Result<(), CliError> {
859    for migration in migrations {
860        let execution = migration
861            .sqlite_execution()
862            .map_err(sqlite_execution_cli_error)?;
863        if execution.suspends_foreign_keys() {
864            return Err(CliError::MigrationError(format!(
865                "Migration '{}' cannot be repaired safely: foreign-key-suspending table rebuilds require the original connection-owned transaction scope",
866                migration.tag()
867            )));
868        }
869    }
870    Ok(())
871}
872
873#[cfg(any(feature = "rusqlite", feature = "libsql", feature = "turso"))]
874fn finish_sqlite_foreign_key_scope<T>(
875    result: Result<T, CliError>,
876    restore: Result<(), CliError>,
877) -> Result<T, CliError> {
878    match (result, restore) {
879        (Err(primary), Err(restore)) => Err(CliError::MigrationError(format!(
880            "{primary}; additionally failed to restore SQLite foreign-key enforcement: {restore}"
881        ))),
882        (Err(primary), Ok(())) => Err(primary),
883        (Ok(_), Err(restore)) => Err(restore),
884        (Ok(value), Ok(())) => Ok(value),
885    }
886}
887
888#[cfg(feature = "rusqlite")]
889fn introspect_catalog_sqlite(
890    conn: &rusqlite::Connection,
891) -> Result<drizzle_migrations::repair::Catalog, CliError> {
892    let mut stmt = conn
893        .prepare(drizzle_migrations::repair::sqlite::OBJECTS_QUERY)
894        .map_err(|e| CliError::MigrationError(e.to_string()))?;
895    let rows = stmt
896        .query_map([], |row| {
897            Ok((
898                row.get::<_, String>(0)?,
899                row.get::<_, String>(1)?,
900                row.get::<_, Option<String>>(2)?,
901            ))
902        })
903        .map_err(|e| CliError::MigrationError(e.to_string()))?;
904
905    let rows = rows
906        .collect::<Result<Vec<_>, _>>()
907        .map_err(|e| CliError::MigrationError(e.to_string()))?;
908    Ok(drizzle_migrations::repair::sqlite::catalog(&rows))
909}
910
911#[cfg(feature = "rusqlite")]
912fn repair_dirty_migrations_sqlite(
913    conn: &rusqlite::Connection,
914    set: &Migrations,
915    repair: bool,
916) -> Result<Vec<String>, CliError> {
917    let dirty = {
918        let mut stmt = match conn.prepare(&set.dirty_names_sql()) {
919            Ok(stmt) => stmt,
920            Err(error) if is_sqlite_missing_table(&error) => return Ok(Vec::new()),
921            Err(error) => return Err(CliError::MigrationError(error.to_string())),
922        };
923        stmt.query_map([], |row| row.get::<_, String>(0))
924            .map_err(|e| CliError::MigrationError(e.to_string()))?
925            .collect::<Result<Vec<String>, _>>()
926            .map_err(|e| CliError::MigrationError(e.to_string()))?
927    };
928
929    let migrations = dirty_migrations_to_repair(set, &dirty, repair)?;
930    ensure_sqlite_dirty_repairs_are_safe(&migrations)?;
931    let table_ident = set.table_ident_sql();
932    let mut repaired = Vec::new();
933    for migration in migrations {
934        let catalog = introspect_catalog_sqlite(conn)?;
935        let plan =
936            drizzle_migrations::repair::plan(drizzle_types::Dialect::SQLite, migration, &catalog);
937        for statement in plan
938            .into_executable(&table_ident)
939            .map_err(migrator_cli_error)?
940        {
941            conn.execute(&statement, [])
942                .map_err(|e| CliError::MigrationError(format!("Repair statement failed: {e}")))?;
943        }
944        conn.execute(&set.record_migration_finished_sql(migration), [])
945            .map_err(|e| CliError::MigrationError(e.to_string()))?;
946        repaired.push(migration.tag().to_string());
947    }
948    Ok(repaired)
949}
950
951#[cfg(any(feature = "libsql", feature = "turso"))]
952async fn introspect_catalog_libsql(
953    conn: &libsql::Connection,
954) -> Result<drizzle_migrations::repair::Catalog, CliError> {
955    let mut rows = conn
956        .query(drizzle_migrations::repair::sqlite::OBJECTS_QUERY, ())
957        .await
958        .map_err(|e| CliError::MigrationError(e.to_string()))?;
959
960    let mut master_rows = Vec::new();
961    while let Some(row) = rows
962        .next()
963        .await
964        .map_err(|e| CliError::MigrationError(e.to_string()))?
965    {
966        master_rows.push((
967            row.get::<String>(0)
968                .map_err(|e| CliError::MigrationError(e.to_string()))?,
969            row.get::<String>(1)
970                .map_err(|e| CliError::MigrationError(e.to_string()))?,
971            row.get::<Option<String>>(2).ok().flatten(),
972        ));
973    }
974    Ok(drizzle_migrations::repair::sqlite::catalog(&master_rows))
975}
976
977#[cfg(any(feature = "libsql", feature = "turso"))]
978async fn repair_dirty_migrations_libsql(
979    conn: &libsql::Connection,
980    set: &Migrations,
981    repair: bool,
982) -> Result<Vec<String>, CliError> {
983    let mut rows = conn
984        .query(&set.dirty_names_sql(), ())
985        .await
986        .map_err(|e| CliError::MigrationError(e.to_string()))?;
987    let mut dirty = Vec::new();
988    while let Some(row) = rows
989        .next()
990        .await
991        .map_err(|e| CliError::MigrationError(e.to_string()))?
992    {
993        dirty.push(
994            row.get::<String>(0)
995                .map_err(|e| CliError::MigrationError(e.to_string()))?,
996        );
997    }
998
999    let migrations = dirty_migrations_to_repair(set, &dirty, repair)?;
1000    ensure_sqlite_dirty_repairs_are_safe(&migrations)?;
1001    let table_ident = set.table_ident_sql();
1002    let mut repaired = Vec::new();
1003    for migration in migrations {
1004        let catalog = introspect_catalog_libsql(conn).await?;
1005        let plan =
1006            drizzle_migrations::repair::plan(drizzle_types::Dialect::SQLite, migration, &catalog);
1007        for statement in plan
1008            .into_executable(&table_ident)
1009            .map_err(migrator_cli_error)?
1010        {
1011            conn.execute(&statement, ())
1012                .await
1013                .map_err(|e| CliError::MigrationError(format!("Repair statement failed: {e}")))?;
1014        }
1015        conn.execute(&set.record_migration_finished_sql(migration), ())
1016            .await
1017            .map_err(|e| CliError::MigrationError(e.to_string()))?;
1018        repaired.push(migration.tag().to_string());
1019    }
1020    Ok(repaired)
1021}
1022
1023#[cfg(feature = "postgres-sync")]
1024fn introspect_catalog_postgres_sync(
1025    client: &mut postgres::Client,
1026) -> Result<drizzle_migrations::repair::Catalog, CliError> {
1027    use drizzle_migrations::repair::postgres as pg;
1028
1029    let err = |e: postgres::Error| CliError::MigrationError(e.to_string());
1030    let two =
1031        |client: &mut postgres::Client, sql: &str| -> Result<Vec<(String, String)>, CliError> {
1032            Ok(client
1033                .query(sql, &[])
1034                .map_err(err)?
1035                .iter()
1036                .map(|row| (row.get::<_, String>(0), row.get::<_, String>(1)))
1037                .collect())
1038        };
1039    let three = |client: &mut postgres::Client,
1040                 sql: &str|
1041     -> Result<Vec<(String, String, String)>, CliError> {
1042        Ok(client
1043            .query(sql, &[])
1044            .map_err(err)?
1045            .iter()
1046            .map(|row| {
1047                (
1048                    row.get::<_, String>(0),
1049                    row.get::<_, String>(1),
1050                    row.get::<_, String>(2),
1051                )
1052            })
1053            .collect())
1054    };
1055
1056    let indexes = client
1057        .query(pg::INDEXES_QUERY, &[])
1058        .map_err(err)?
1059        .iter()
1060        .map(|row| {
1061            (
1062                row.get::<_, String>(0),
1063                row.get::<_, String>(1),
1064                row.get::<_, bool>(2),
1065            )
1066        })
1067        .collect::<Vec<_>>();
1068
1069    let tables = two(client, pg::TABLES_QUERY)?;
1070    let columns = three(client, pg::COLUMNS_QUERY)?;
1071    let views = two(client, pg::VIEWS_QUERY)?;
1072    let index_columns = three(client, pg::INDEX_COLUMNS_QUERY)?;
1073    let enums = three(client, pg::ENUMS_QUERY)?;
1074
1075    Ok(pg::catalog(
1076        &tables,
1077        &columns,
1078        &views,
1079        &indexes,
1080        &index_columns,
1081        &enums,
1082    ))
1083}
1084
1085#[cfg(feature = "postgres-sync")]
1086fn repair_dirty_migrations_postgres_sync(
1087    client: &mut postgres::Client,
1088    set: &Migrations,
1089    repair: bool,
1090) -> Result<Vec<String>, CliError> {
1091    let dirty = client
1092        .query(&set.dirty_names_sql(), &[])
1093        .map_err(|e| CliError::MigrationError(e.to_string()))?
1094        .iter()
1095        .map(|row| row.try_get::<_, String>(0))
1096        .collect::<Result<Vec<_>, _>>()
1097        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1098
1099    let table_ident = set.table_ident_sql();
1100    let to_repair = dirty_migrations_to_repair(set, &dirty, repair)?
1101        .into_iter()
1102        .cloned()
1103        .collect::<Vec<_>>();
1104
1105    let mut repaired = Vec::new();
1106    for migration in &to_repair {
1107        let catalog = introspect_catalog_postgres_sync(client)?;
1108        let plan = drizzle_migrations::repair::plan(
1109            drizzle_types::Dialect::PostgreSQL,
1110            migration,
1111            &catalog,
1112        );
1113        for statement in plan
1114            .into_executable(&table_ident)
1115            .map_err(migrator_cli_error)?
1116        {
1117            client
1118                .execute(statement.as_str(), &[])
1119                .map_err(|e| CliError::MigrationError(format!("Repair statement failed: {e}")))?;
1120        }
1121        client
1122            .execute(&set.record_migration_finished_sql(migration), &[])
1123            .map_err(|e| CliError::MigrationError(e.to_string()))?;
1124        repaired.push(migration.tag().to_string());
1125    }
1126    Ok(repaired)
1127}
1128
1129#[cfg(feature = "tokio-postgres")]
1130async fn introspect_catalog_postgres_async(
1131    client: &tokio_postgres::Client,
1132) -> Result<drizzle_migrations::repair::Catalog, CliError> {
1133    use drizzle_migrations::repair::postgres as pg;
1134
1135    async fn two(
1136        client: &tokio_postgres::Client,
1137        sql: &str,
1138    ) -> Result<Vec<(String, String)>, CliError> {
1139        Ok(client
1140            .query(sql, &[])
1141            .await
1142            .map_err(|e| CliError::MigrationError(e.to_string()))?
1143            .iter()
1144            .map(|row| (row.get::<_, String>(0), row.get::<_, String>(1)))
1145            .collect())
1146    }
1147
1148    async fn three(
1149        client: &tokio_postgres::Client,
1150        sql: &str,
1151    ) -> Result<Vec<(String, String, String)>, CliError> {
1152        Ok(client
1153            .query(sql, &[])
1154            .await
1155            .map_err(|e| CliError::MigrationError(e.to_string()))?
1156            .iter()
1157            .map(|row| {
1158                (
1159                    row.get::<_, String>(0),
1160                    row.get::<_, String>(1),
1161                    row.get::<_, String>(2),
1162                )
1163            })
1164            .collect())
1165    }
1166
1167    let indexes = client
1168        .query(pg::INDEXES_QUERY, &[])
1169        .await
1170        .map_err(|e| CliError::MigrationError(e.to_string()))?
1171        .iter()
1172        .map(|row| {
1173            (
1174                row.get::<_, String>(0),
1175                row.get::<_, String>(1),
1176                row.get::<_, bool>(2),
1177            )
1178        })
1179        .collect::<Vec<_>>();
1180
1181    let tables = two(client, pg::TABLES_QUERY).await?;
1182    let columns = three(client, pg::COLUMNS_QUERY).await?;
1183    let views = two(client, pg::VIEWS_QUERY).await?;
1184    let index_columns = three(client, pg::INDEX_COLUMNS_QUERY).await?;
1185    let enums = three(client, pg::ENUMS_QUERY).await?;
1186
1187    Ok(pg::catalog(
1188        &tables,
1189        &columns,
1190        &views,
1191        &indexes,
1192        &index_columns,
1193        &enums,
1194    ))
1195}
1196
1197#[cfg(feature = "tokio-postgres")]
1198async fn repair_dirty_migrations_postgres_async(
1199    client: &tokio_postgres::Client,
1200    set: &Migrations,
1201    repair: bool,
1202) -> Result<Vec<String>, CliError> {
1203    let dirty = client
1204        .query(&set.dirty_names_sql(), &[])
1205        .await
1206        .map_err(|e| CliError::MigrationError(e.to_string()))?
1207        .iter()
1208        .map(|row| row.try_get::<_, String>(0))
1209        .collect::<Result<Vec<_>, _>>()
1210        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1211
1212    let table_ident = set.table_ident_sql();
1213    let to_repair = dirty_migrations_to_repair(set, &dirty, repair)?
1214        .into_iter()
1215        .cloned()
1216        .collect::<Vec<_>>();
1217
1218    let mut repaired = Vec::new();
1219    for migration in &to_repair {
1220        let catalog = introspect_catalog_postgres_async(client).await?;
1221        let plan = drizzle_migrations::repair::plan(
1222            drizzle_types::Dialect::PostgreSQL,
1223            migration,
1224            &catalog,
1225        );
1226        for statement in plan
1227            .into_executable(&table_ident)
1228            .map_err(migrator_cli_error)?
1229        {
1230            client
1231                .execute(statement.as_str(), &[])
1232                .await
1233                .map_err(|e| CliError::MigrationError(format!("Repair statement failed: {e}")))?;
1234        }
1235        client
1236            .execute(&set.record_migration_finished_sql(migration), &[])
1237            .await
1238            .map_err(|e| CliError::MigrationError(e.to_string()))?;
1239        repaired.push(migration.tag().to_string());
1240    }
1241    Ok(repaired)
1242}
1243
1244/// Reads the tracking table's state without changing it.
1245#[cfg(feature = "rusqlite")]
1246fn read_sqlite_tracking_state(
1247    conn: &rusqlite::Connection,
1248    set: &Migrations,
1249) -> Result<TrackingState, CliError> {
1250    let pragma_sql = format!(
1251        "SELECT name FROM pragma_table_info('{}')",
1252        escape_sql_literal(set.table_name())
1253    );
1254    let mut stmt = conn
1255        .prepare(&pragma_sql)
1256        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1257    let columns = stmt
1258        .query_map([], |row| row.get::<_, String>(0))
1259        .map_err(|e| CliError::MigrationError(e.to_string()))?
1260        .collect::<Result<Vec<_>, _>>()
1261        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1262    if columns.is_empty() {
1263        return Ok(TrackingState::Missing);
1264    }
1265    if columns.iter().any(|column| column == "name") {
1266        return Ok(TrackingState::Current);
1267    }
1268
1269    let mut stmt = conn
1270        .prepare(&format!(
1271            "SELECT id, hash, created_at FROM {} ORDER BY id ASC",
1272            set.table_ident_sql()
1273        ))
1274        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1275    let rows = stmt
1276        .query_map([], |row| {
1277            Ok(drizzle_migrations::AppliedMigrationMetadata {
1278                id: row.get::<_, Option<i64>>(0)?,
1279                hash: row.get::<_, String>(1)?,
1280                created_at: row.get::<_, i64>(2)?,
1281            })
1282        })
1283        .map_err(|e| CliError::MigrationError(e.to_string()))?
1284        .collect::<Result<Vec<_>, _>>()
1285        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1286    Ok(TrackingState::Legacy(rows))
1287}
1288
1289#[cfg(feature = "rusqlite")]
1290fn ensure_sqlite_tracking_table(
1291    conn: &rusqlite::Connection,
1292    set: &Migrations,
1293) -> Result<(), CliError> {
1294    conn.execute(&set.create_table_sql(), [])
1295        .map_err(|e| CliError::MigrationError(format!("Failed to create migrations table: {e}")))?;
1296
1297    let TrackingState::Legacy(applied) = read_sqlite_tracking_state(conn, set)? else {
1298        return Ok(());
1299    };
1300
1301    let matched = drizzle_migrations::match_applied_migration_metadata(set.all(), &applied)
1302        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1303
1304    conn.execute("BEGIN", [])
1305        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1306    let result = (|| -> Result<(), CliError> {
1307        conn.execute(
1308            &format!(
1309                "ALTER TABLE {} ADD COLUMN \"name\" text",
1310                set.table_ident_sql()
1311            ),
1312            [],
1313        )
1314        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1315        conn.execute(
1316            &format!(
1317                "ALTER TABLE {} ADD COLUMN \"applied_at\" TEXT",
1318                set.table_ident_sql()
1319            ),
1320            [],
1321        )
1322        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1323
1324        for row in matched {
1325            conn.execute(&set.backfill_migration_metadata_sql(&row), [])
1326                .map_err(|e| CliError::MigrationError(e.to_string()))?;
1327        }
1328
1329        Ok(())
1330    })();
1331
1332    match result {
1333        Ok(()) => {
1334            conn.execute("COMMIT", [])
1335                .map_err(|e| CliError::MigrationError(e.to_string()))?;
1336            Ok(())
1337        }
1338        Err(err) => {
1339            let _ = conn.execute("ROLLBACK", []);
1340            Err(err)
1341        }
1342    }
1343}
1344
1345/// Reads the tracking table's state without changing it.
1346#[cfg(any(feature = "libsql", feature = "turso"))]
1347async fn read_sqlite_tracking_state_libsql(
1348    conn: &libsql::Connection,
1349    set: &Migrations,
1350) -> Result<TrackingState, CliError> {
1351    let pragma_sql = format!(
1352        "SELECT name FROM pragma_table_info('{}')",
1353        escape_sql_literal(set.table_name())
1354    );
1355    let mut rows = conn
1356        .query(&pragma_sql, ())
1357        .await
1358        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1359    let mut any_column = false;
1360    let mut has_name = false;
1361    while let Some(row) = rows
1362        .next()
1363        .await
1364        .map_err(|e| CliError::MigrationError(e.to_string()))?
1365    {
1366        any_column = true;
1367        let name = row
1368            .get::<String>(0)
1369            .map_err(|e| CliError::MigrationError(e.to_string()))?;
1370        if name == "name" {
1371            has_name = true;
1372        }
1373    }
1374    if !any_column {
1375        return Ok(TrackingState::Missing);
1376    }
1377    if has_name {
1378        return Ok(TrackingState::Current);
1379    }
1380
1381    let mut rows = conn
1382        .query(
1383            &format!(
1384                "SELECT id, hash, created_at FROM {} ORDER BY id ASC",
1385                set.table_ident_sql()
1386            ),
1387            (),
1388        )
1389        .await
1390        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1391    let mut applied = Vec::new();
1392    while let Some(row) = rows
1393        .next()
1394        .await
1395        .map_err(|e| CliError::MigrationError(e.to_string()))?
1396    {
1397        applied.push(drizzle_migrations::AppliedMigrationMetadata {
1398            id: row
1399                .get::<Option<i64>>(0)
1400                .map_err(|e| CliError::MigrationError(e.to_string()))?,
1401            hash: row
1402                .get::<String>(1)
1403                .map_err(|e| CliError::MigrationError(e.to_string()))?,
1404            created_at: row
1405                .get::<i64>(2)
1406                .map_err(|e| CliError::MigrationError(e.to_string()))?,
1407        });
1408    }
1409    Ok(TrackingState::Legacy(applied))
1410}
1411
1412#[cfg(any(feature = "libsql", feature = "turso"))]
1413async fn ensure_sqlite_tracking_table_libsql(
1414    conn: &libsql::Connection,
1415    set: &Migrations,
1416) -> Result<(), CliError> {
1417    conn.execute(&set.create_table_sql(), ())
1418        .await
1419        .map_err(|e| CliError::MigrationError(format!("Failed to create migrations table: {e}")))?;
1420
1421    let TrackingState::Legacy(applied) = read_sqlite_tracking_state_libsql(conn, set).await? else {
1422        return Ok(());
1423    };
1424
1425    let matched = drizzle_migrations::match_applied_migration_metadata(set.all(), &applied)
1426        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1427
1428    let tx = conn
1429        .transaction()
1430        .await
1431        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1432    tx.execute(
1433        &format!(
1434            "ALTER TABLE {} ADD COLUMN \"name\" text",
1435            set.table_ident_sql()
1436        ),
1437        (),
1438    )
1439    .await
1440    .map_err(|e| CliError::MigrationError(e.to_string()))?;
1441    tx.execute(
1442        &format!(
1443            "ALTER TABLE {} ADD COLUMN \"applied_at\" TEXT",
1444            set.table_ident_sql()
1445        ),
1446        (),
1447    )
1448    .await
1449    .map_err(|e| CliError::MigrationError(e.to_string()))?;
1450
1451    for row in matched {
1452        tx.execute(&set.backfill_migration_metadata_sql(&row), ())
1453            .await
1454            .map_err(|e| CliError::MigrationError(e.to_string()))?;
1455    }
1456
1457    tx.commit()
1458        .await
1459        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1460    Ok(())
1461}
1462
1463/// Reads the tracking table's state without changing it.
1464#[cfg(feature = "postgres-sync")]
1465fn read_postgres_tracking_state_sync(
1466    client: &mut postgres::Client,
1467    set: &Migrations,
1468) -> Result<TrackingState, CliError> {
1469    let schema = set.schema_name().unwrap_or("public");
1470    let rows = client
1471        .query(
1472            "SELECT column_name FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2",
1473            &[&schema, &set.table_name()],
1474        )
1475        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1476    let columns = rows
1477        .iter()
1478        .map(|row| row.try_get::<_, String>(0))
1479        .collect::<Result<Vec<_>, postgres::Error>>()
1480        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1481    if columns.is_empty() {
1482        return Ok(TrackingState::Missing);
1483    }
1484    // Both columns, checked separately: an interrupted pre-transactional
1485    // upgrade could leave `name` without `applied_at`.
1486    if has_current_tracking_columns(&columns) {
1487        return Ok(TrackingState::Current);
1488    }
1489
1490    let rows = client
1491        .query(
1492            &format!(
1493                "SELECT id::bigint, hash, created_at FROM {} ORDER BY id ASC",
1494                set.table_ident_sql()
1495            ),
1496            &[],
1497        )
1498        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1499    let applied = rows
1500        .iter()
1501        .map(|row| {
1502            Ok(drizzle_migrations::AppliedMigrationMetadata {
1503                id: row.try_get::<_, Option<i64>>(0)?,
1504                hash: row.try_get::<_, String>(1)?,
1505                created_at: row.try_get::<_, i64>(2)?,
1506            })
1507        })
1508        .collect::<Result<Vec<_>, postgres::Error>>()
1509        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1510    Ok(TrackingState::Legacy(applied))
1511}
1512
1513#[cfg(feature = "postgres-sync")]
1514fn ensure_postgres_tracking_table_sync(
1515    client: &mut postgres::Client,
1516    set: &Migrations,
1517) -> Result<(), CliError> {
1518    client
1519        .execute(&set.create_table_sql(), &[])
1520        .map_err(|e| CliError::MigrationError(format!("Failed to create migrations table: {e}")))?;
1521
1522    let TrackingState::Legacy(applied) = read_postgres_tracking_state_sync(client, set)? else {
1523        return Ok(());
1524    };
1525
1526    let matched = drizzle_migrations::match_applied_migration_metadata(set.all(), &applied)
1527        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1528    let (has_name, has_applied_at) = postgres_tracking_columns_sync(client, set)?;
1529
1530    // One simple-query batch is one implicit transaction: the column
1531    // additions and backfills land together or not at all.
1532    client
1533        .batch_execute(
1534            &set.tracking_upgrade_sql(has_name, has_applied_at, &matched)
1535                .join(";\n"),
1536        )
1537        .map_err(|e| {
1538            CliError::MigrationError(format!(
1539                "Failed to upgrade the migrations table: {}",
1540                postgres_error_text(&e)
1541            ))
1542        })?;
1543
1544    Ok(())
1545}
1546
1547/// Whether the tracking table has the `name` and `applied_at` columns.
1548#[cfg(feature = "postgres-sync")]
1549fn postgres_tracking_columns_sync(
1550    client: &mut postgres::Client,
1551    set: &Migrations,
1552) -> Result<(bool, bool), CliError> {
1553    let schema = set.schema_name().unwrap_or("public");
1554    let columns = client
1555        .query(
1556            "SELECT column_name FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2",
1557            &[&schema, &set.table_name()],
1558        )
1559        .map_err(|e| CliError::MigrationError(e.to_string()))?
1560        .iter()
1561        .map(|row| row.try_get::<_, String>(0))
1562        .collect::<Result<Vec<_>, postgres::Error>>()
1563        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1564    Ok((
1565        columns.iter().any(|column| column == "name"),
1566        columns.iter().any(|column| column == "applied_at"),
1567    ))
1568}
1569
1570/// `true` when a tracking table already has both v1 columns.
1571#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
1572fn has_current_tracking_columns(columns: &[String]) -> bool {
1573    columns.iter().any(|column| column == "name")
1574        && columns.iter().any(|column| column == "applied_at")
1575}
1576
1577/// A PostgreSQL error with the server's message: the client error alone
1578/// only displays `db error`.
1579#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
1580fn postgres_error_text(error: &(dyn std::error::Error + 'static)) -> String {
1581    match error.source() {
1582        Some(source) => format!("{error}: {source}"),
1583        None => error.to_string(),
1584    }
1585}
1586
1587/// Error for a failed migration statement, naming the migration, the
1588/// statement, and the server's message.
1589#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
1590fn migration_statement_error(
1591    migration: &drizzle_migrations::Migration,
1592    statement: &str,
1593    error: &(dyn std::error::Error + 'static),
1594) -> CliError {
1595    CliError::MigrationError(format!(
1596        "Migration '{}' failed: {}\n  statement: {}",
1597        migration.tag(),
1598        postgres_error_text(error),
1599        statement.trim()
1600    ))
1601}
1602
1603/// Reads the tracking table's state without changing it.
1604#[cfg(feature = "tokio-postgres")]
1605async fn read_postgres_tracking_state_async(
1606    client: &tokio_postgres::Client,
1607    set: &Migrations,
1608) -> Result<TrackingState, CliError> {
1609    let schema = set.schema_name().unwrap_or("public");
1610    let rows = client
1611        .query(
1612            "SELECT column_name FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2",
1613            &[&schema, &set.table_name()],
1614        )
1615        .await
1616        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1617    let columns = rows
1618        .iter()
1619        .map(|row| row.try_get::<_, String>(0))
1620        .collect::<Result<Vec<_>, tokio_postgres::Error>>()
1621        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1622    if columns.is_empty() {
1623        return Ok(TrackingState::Missing);
1624    }
1625    // Both columns, checked separately: an interrupted pre-transactional
1626    // upgrade could leave `name` without `applied_at`.
1627    if has_current_tracking_columns(&columns) {
1628        return Ok(TrackingState::Current);
1629    }
1630
1631    let rows = client
1632        .query(
1633            &format!(
1634                "SELECT id::bigint, hash, created_at FROM {} ORDER BY id ASC",
1635                set.table_ident_sql()
1636            ),
1637            &[],
1638        )
1639        .await
1640        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1641    let applied = rows
1642        .iter()
1643        .map(|row| {
1644            Ok(drizzle_migrations::AppliedMigrationMetadata {
1645                id: row.try_get::<_, Option<i64>>(0)?,
1646                hash: row.try_get::<_, String>(1)?,
1647                created_at: row.try_get::<_, i64>(2)?,
1648            })
1649        })
1650        .collect::<Result<Vec<_>, tokio_postgres::Error>>()
1651        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1652    Ok(TrackingState::Legacy(applied))
1653}
1654
1655#[cfg(feature = "tokio-postgres")]
1656async fn ensure_postgres_tracking_table_async(
1657    client: &tokio_postgres::Client,
1658    set: &Migrations,
1659) -> Result<(), CliError> {
1660    client
1661        .execute(&set.create_table_sql(), &[])
1662        .await
1663        .map_err(|e| CliError::MigrationError(format!("Failed to create migrations table: {e}")))?;
1664
1665    let TrackingState::Legacy(applied) = read_postgres_tracking_state_async(client, set).await?
1666    else {
1667        return Ok(());
1668    };
1669
1670    let matched = drizzle_migrations::match_applied_migration_metadata(set.all(), &applied)
1671        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1672    let schema = set.schema_name().unwrap_or("public");
1673    let columns = client
1674        .query(
1675            "SELECT column_name FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2",
1676            &[&schema, &set.table_name()],
1677        )
1678        .await
1679        .map_err(|e| CliError::MigrationError(e.to_string()))?
1680        .iter()
1681        .map(|row| row.try_get::<_, String>(0))
1682        .collect::<Result<Vec<_>, tokio_postgres::Error>>()
1683        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1684    let has_name = columns.iter().any(|column| column == "name");
1685    let has_applied_at = columns.iter().any(|column| column == "applied_at");
1686
1687    // One simple-query batch is one implicit transaction: the column
1688    // additions and backfills land together or not at all.
1689    client
1690        .batch_execute(
1691            &set.tracking_upgrade_sql(has_name, has_applied_at, &matched)
1692                .join(";\n"),
1693        )
1694        .await
1695        .map_err(|e| {
1696            CliError::MigrationError(format!(
1697                "Failed to upgrade the migrations table: {}",
1698                postgres_error_text(&e)
1699            ))
1700        })?;
1701
1702    Ok(())
1703}
1704
1705fn is_destructive_statement(sql: &str) -> bool {
1706    let s = sql.trim().to_uppercase();
1707    s.contains("DROP TABLE")
1708        || s.contains("DROP COLUMN")
1709        || s.contains("DROP INDEX")
1710        || s.contains("DROP VIEW")
1711        || s.contains("DROP MATERIALIZED VIEW")
1712        || s.contains("DROP TYPE")
1713        || s.contains("DROP SCHEMA")
1714        || s.contains("DROP SEQUENCE")
1715        || s.contains("DROP ROLE")
1716        || s.contains("DROP POLICY")
1717        || s.contains("TRUNCATE")
1718        || (s.contains("ALTER TABLE") && s.contains(" DROP "))
1719}
1720
1721fn push_requires_confirmation(
1722    dialect: Dialect,
1723    statements: &[String],
1724    warnings: &[String],
1725) -> bool {
1726    statements
1727        .iter()
1728        .any(|statement| is_destructive_statement(statement))
1729        // MySQL's ALTER TABLE ... MODIFY COLUMN syntax does not say DROP even
1730        // when it can truncate, reject, or recode stored values. Every warning
1731        // produced by the typed MySQL diff represents one of those risky
1732        // structural operations, so it belongs behind the same confirmation
1733        // boundary as explicit DROP/TRUNCATE statements.
1734        || (dialect == Dialect::Mysql && !warnings.is_empty())
1735}
1736
1737#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
1738fn is_postgres_concurrent_index_statement(sql: &str) -> bool {
1739    drizzle_migrations::is_postgres_concurrent_index_statement(sql)
1740}
1741
1742#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
1743fn has_postgres_concurrent_index(statements: &[String]) -> bool {
1744    statements
1745        .iter()
1746        .any(|stmt| is_postgres_concurrent_index_statement(stmt))
1747}
1748
1749fn confirm_destructive() -> Result<bool, CliError> {
1750    use std::io::{self, IsTerminal, Write};
1751
1752    if !io::stdin().is_terminal() {
1753        return Err(CliError::Other(
1754            "Refusing to apply potentially destructive changes in non-interactive mode. Use --explain or --force."
1755                .into(),
1756        ));
1757    }
1758
1759    println!(
1760        "{}",
1761        output::warning("Potentially destructive changes detected (DROP/TRUNCATE/etc).")
1762    );
1763    print!("Apply anyway? [y/N]: ");
1764    io::stdout()
1765        .flush()
1766        .map_err(|e| CliError::IoError(e.to_string()))?;
1767
1768    let mut line = String::new();
1769    io::stdin()
1770        .read_line(&mut line)
1771        .map_err(|e| CliError::IoError(e.to_string()))?;
1772    let ans = line.trim().to_ascii_lowercase();
1773    Ok(ans == "y" || ans == "yes")
1774}
1775
1776fn generate_push_sql(
1777    current: &Snapshot,
1778    desired: &Snapshot,
1779    _breakpoints: bool,
1780    options: &drizzle_migrations::DiffOptions,
1781) -> Result<(Vec<String>, Vec<String>), CliError> {
1782    let plan = drizzle_migrations::diff_with(current, desired, options)
1783        .map_err(|error| CliError::MigrationError(error.to_string()))?;
1784    Ok((plan.statements, plan.warnings))
1785}
1786
1787fn execute_statements(
1788    connection: &ResolvedConnection,
1789    statements: &[String],
1790) -> Result<(), CliError> {
1791    // In some feature combinations (no drivers), the match arms that would use `statements`
1792    // are compiled out. Touch it to avoid unused-parameter warnings.
1793    let _ = statements;
1794
1795    match &connection.credentials {
1796        #[cfg(feature = "rusqlite")]
1797        Credentials::Sqlite { path } if connection.driver == Driver::Rusqlite => {
1798            execute_sqlite_statements(path, statements)
1799        }
1800
1801        #[cfg(feature = "rusqlite")]
1802        Credentials::Sqlite { .. } => Err(connection_driver_mismatch(connection)),
1803
1804        #[cfg(not(feature = "rusqlite"))]
1805        Credentials::Sqlite { .. } => Err(CliError::MissingDriver {
1806            dialect: "SQLite",
1807            feature: "rusqlite",
1808        }),
1809
1810        #[cfg(any(feature = "libsql", feature = "turso"))]
1811        Credentials::Turso { url, auth_token } => {
1812            if connection.driver == Driver::Libsql {
1813                #[cfg(feature = "libsql")]
1814                {
1815                    execute_libsql_local_statements(url, statements)
1816                }
1817                #[cfg(not(feature = "libsql"))]
1818                {
1819                    Err(CliError::MissingDriver {
1820                        dialect: "LibSQL (local)",
1821                        feature: "libsql",
1822                    })
1823                }
1824            } else if connection.driver == Driver::Turso {
1825                #[cfg(feature = "turso")]
1826                {
1827                    execute_turso_statements(url, auth_token.as_deref(), statements)
1828                }
1829                #[cfg(not(feature = "turso"))]
1830                {
1831                    let _ = auth_token;
1832                    Err(CliError::MissingDriver {
1833                        dialect: "Turso (remote)",
1834                        feature: "turso",
1835                    })
1836                }
1837            } else {
1838                Err(connection_driver_mismatch(connection))
1839            }
1840        }
1841
1842        #[cfg(all(not(feature = "turso"), not(feature = "libsql")))]
1843        Credentials::Turso { .. } => Err(CliError::MissingDriver {
1844            dialect: "Turso",
1845            feature: "turso or libsql",
1846        }),
1847
1848        Credentials::Postgres(creds) => {
1849            let _ = creds;
1850            match connection.driver {
1851                Driver::PostgresSync => {
1852                    #[cfg(feature = "postgres-sync")]
1853                    {
1854                        execute_postgres_sync_statements(creds, statements)
1855                    }
1856                    #[cfg(not(feature = "postgres-sync"))]
1857                    {
1858                        Err(CliError::MissingDriver {
1859                            dialect: "PostgreSQL",
1860                            feature: "postgres-sync",
1861                        })
1862                    }
1863                }
1864                Driver::TokioPostgres => {
1865                    #[cfg(feature = "tokio-postgres")]
1866                    {
1867                        execute_postgres_async_statements(creds, statements)
1868                    }
1869                    #[cfg(not(feature = "tokio-postgres"))]
1870                    {
1871                        Err(CliError::MissingDriver {
1872                            dialect: "PostgreSQL",
1873                            feature: "tokio-postgres",
1874                        })
1875                    }
1876                }
1877                _ => Err(connection_driver_mismatch(connection)),
1878            }
1879        }
1880
1881        Credentials::MySQL(creds) => {
1882            mysql::execute_statements(connection.driver, creds, statements)
1883        }
1884
1885        #[cfg(feature = "d1-http")]
1886        Credentials::D1 {
1887            account_id,
1888            database_id,
1889            token,
1890        } if connection.driver == Driver::D1Http => {
1891            d1_http::execute_statements(account_id, database_id, token, statements)
1892        }
1893
1894        #[cfg(feature = "d1-http")]
1895        Credentials::D1 { .. } => Err(connection_driver_mismatch(connection)),
1896
1897        #[cfg(not(feature = "d1-http"))]
1898        Credentials::D1 { .. } => Err(CliError::MissingDriver {
1899            dialect: "Cloudflare D1 (HTTP)",
1900            feature: "d1-http",
1901        }),
1902
1903        Credentials::AwsDataApi { .. } => Err(CliError::UnsupportedForDriver {
1904            operation: "Direct SQL execution against AWS Data API",
1905            driver: "aws-data-api",
1906            hint: "Use `aws rds-data execute-statement --sql=\"...\"` with the matching \
1907                   --resource-arn / --secret-arn / --database, or connect directly via \
1908                   tokio-postgres.",
1909        }),
1910    }
1911}
1912
1913/// Check if a Turso URL is a local libsql database
1914#[allow(dead_code)]
1915fn is_local_libsql(url: &str) -> bool {
1916    url.starts_with("file:")
1917        || url.starts_with("./")
1918        || url.starts_with('/')
1919        || !url.contains("://")
1920}
1921
1922// ============================================================================
1923// SQLite (rusqlite)
1924// ============================================================================
1925
1926#[cfg(feature = "rusqlite")]
1927fn execute_sqlite_statements(path: &str, statements: &[String]) -> Result<(), CliError> {
1928    let conn = rusqlite::Connection::open(path).map_err(|e| {
1929        CliError::ConnectionError(format!("Failed to open SQLite database '{path}': {e}"))
1930    })?;
1931
1932    execute_sqlite_statements_on_connection(&conn, statements)
1933}
1934
1935#[cfg(feature = "rusqlite")]
1936fn execute_sqlite_statements_on_connection(
1937    conn: &rusqlite::Connection,
1938    statements: &[String],
1939) -> Result<(), CliError> {
1940    let operation =
1941        drizzle_migrations::Migration::with_hash("sqlite_push", "", 0, statements.to_vec());
1942    let execution = operation
1943        .sqlite_execution()
1944        .map_err(sqlite_execution_cli_error)?;
1945    let foreign_keys_were_enabled = sqlite_foreign_keys_enabled_cli(conn)?;
1946    let suspend_foreign_keys = execution.suspends_foreign_keys() && foreign_keys_were_enabled;
1947    if suspend_foreign_keys && let Err(error) = set_sqlite_foreign_keys_cli(conn, false) {
1948        let restore = set_sqlite_foreign_keys_cli(conn, true);
1949        return finish_sqlite_foreign_key_scope(Err(error), restore);
1950    }
1951
1952    let result = (|| -> Result<(), CliError> {
1953        conn.execute("BEGIN IMMEDIATE", [])
1954            .map_err(|error| CliError::MigrationError(error.to_string()))?;
1955        for statement in execution
1956            .statements()
1957            .filter(|statement| !statement.trim().is_empty())
1958        {
1959            conn.execute(statement, []).map_err(|error| {
1960                CliError::MigrationError(format!("Statement failed: {error}\n{statement}"))
1961            })?;
1962        }
1963        if execution.suspends_foreign_keys() {
1964            verify_sqlite_foreign_keys_cli(conn)?;
1965        }
1966        conn.execute("COMMIT", [])
1967            .map_err(|error| CliError::MigrationError(error.to_string()))?;
1968        Ok(())
1969    })();
1970    let result = match result {
1971        Ok(()) => Ok(()),
1972        Err(error) => {
1973            let _ = conn.execute("ROLLBACK", []);
1974            Err(error)
1975        }
1976    };
1977    let restore = if suspend_foreign_keys {
1978        set_sqlite_foreign_keys_cli(conn, true)
1979    } else {
1980        Ok(())
1981    };
1982    finish_sqlite_foreign_key_scope(result, restore)
1983}
1984
1985#[cfg(feature = "rusqlite")]
1986fn run_sqlite_migrations(
1987    set: &Migrations,
1988    path: &str,
1989    repair: bool,
1990) -> Result<MigrationResult, CliError> {
1991    let conn = rusqlite::Connection::open(path).map_err(|e| {
1992        CliError::ConnectionError(format!("Failed to open SQLite database '{path}': {e}"))
1993    })?;
1994
1995    run_sqlite_migrations_on_connection(set, &conn, repair)
1996}
1997
1998#[cfg(feature = "rusqlite")]
1999fn run_sqlite_migrations_on_connection(
2000    set: &Migrations,
2001    conn: &rusqlite::Connection,
2002    repair: bool,
2003) -> Result<MigrationResult, CliError> {
2004    ensure_sqlite_tracking_table(conn, set)?;
2005    conn.busy_timeout(std::time::Duration::from_secs(30))
2006        .map_err(|e| CliError::MigrationError(e.to_string()))?;
2007    let applied_before_transaction = query_applied_names_sqlite(conn, set)?;
2008    let suspends_foreign_keys = set
2009        .pending(&applied_before_transaction)
2010        .map(|migration| migration.sqlite_execution())
2011        .collect::<Result<Vec<_>, _>>()
2012        .map_err(sqlite_execution_cli_error)?
2013        .iter()
2014        .any(|execution| execution.suspends_foreign_keys());
2015    let foreign_keys_were_enabled = sqlite_foreign_keys_enabled_cli(conn)?;
2016    let suspend_foreign_keys = suspends_foreign_keys && foreign_keys_were_enabled;
2017    if suspend_foreign_keys && let Err(error) = set_sqlite_foreign_keys_cli(conn, false) {
2018        let restore = set_sqlite_foreign_keys_cli(conn, true);
2019        return finish_sqlite_foreign_key_scope(Err(error), restore);
2020    }
2021
2022    let result = (|| -> Result<MigrationResult, CliError> {
2023        conn.execute("BEGIN IMMEDIATE", [])
2024            .map_err(|e| CliError::MigrationError(e.to_string()))?;
2025        let repaired = repair_dirty_migrations_sqlite(conn, set, repair)?;
2026        let applied_names = query_applied_names_sqlite(conn, set)?;
2027        let pending = set
2028            .pending(&applied_names)
2029            .map(|migration| {
2030                migration
2031                    .sqlite_execution()
2032                    .map(|execution| (migration, execution))
2033            })
2034            .collect::<Result<Vec<_>, _>>()
2035            .map_err(sqlite_execution_cli_error)?;
2036
2037        let mut applied = Vec::with_capacity(pending.len());
2038        for (migration, execution) in pending {
2039            for statement in execution
2040                .statements()
2041                .filter(|statement| !statement.trim().is_empty())
2042            {
2043                conn.execute(statement, []).map_err(|error| {
2044                    CliError::MigrationError(format!(
2045                        "Migration '{}' failed: {error}",
2046                        migration.hash()
2047                    ))
2048                })?;
2049            }
2050            conn.execute(&set.record_migration_sql(migration), [])
2051                .map_err(|e| CliError::MigrationError(e.to_string()))?;
2052            applied.push(migration.hash().to_string());
2053        }
2054
2055        Ok(MigrationResult {
2056            applied_count: applied.len(),
2057            applied_migrations: applied,
2058            repaired_migrations: repaired,
2059        })
2060    })();
2061
2062    let result = match result {
2063        Ok(outcome) => match suspends_foreign_keys
2064            .then(|| verify_sqlite_foreign_keys_cli(conn))
2065            .transpose()
2066        {
2067            Ok(_) => conn
2068                .execute("COMMIT", [])
2069                .map(|_| outcome)
2070                .map_err(|e| CliError::MigrationError(e.to_string())),
2071            Err(error) => {
2072                let _ = conn.execute("ROLLBACK", []);
2073                Err(error)
2074            }
2075        },
2076        Err(error) => {
2077            let _ = conn.execute("ROLLBACK", []);
2078            Err(error)
2079        }
2080    };
2081    let restore = if suspend_foreign_keys {
2082        set_sqlite_foreign_keys_cli(conn, true)
2083    } else {
2084        Ok(())
2085    };
2086    finish_sqlite_foreign_key_scope(result, restore)
2087}
2088
2089#[cfg(feature = "rusqlite")]
2090fn sqlite_foreign_keys_enabled_cli(conn: &rusqlite::Connection) -> Result<bool, CliError> {
2091    conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))
2092        .map(|enabled| enabled != 0)
2093        .map_err(|error| CliError::MigrationError(error.to_string()))
2094}
2095
2096#[cfg(feature = "rusqlite")]
2097fn set_sqlite_foreign_keys_cli(conn: &rusqlite::Connection, enabled: bool) -> Result<(), CliError> {
2098    conn.execute_batch(if enabled {
2099        "PRAGMA foreign_keys=ON"
2100    } else {
2101        "PRAGMA foreign_keys=OFF"
2102    })
2103    .map_err(|error| CliError::MigrationError(error.to_string()))?;
2104    if sqlite_foreign_keys_enabled_cli(conn)? != enabled {
2105        return Err(CliError::MigrationError(format!(
2106            "SQLite refused to set foreign_keys={}",
2107            if enabled { "ON" } else { "OFF" }
2108        )));
2109    }
2110    Ok(())
2111}
2112
2113#[cfg(feature = "rusqlite")]
2114fn verify_sqlite_foreign_keys_cli(conn: &rusqlite::Connection) -> Result<(), CliError> {
2115    let mut statement = conn
2116        .prepare("PRAGMA foreign_key_check")
2117        .map_err(|error| CliError::MigrationError(error.to_string()))?;
2118    let mut rows = statement
2119        .query([])
2120        .map_err(|error| CliError::MigrationError(error.to_string()))?;
2121    if rows
2122        .next()
2123        .map_err(|error| CliError::MigrationError(error.to_string()))?
2124        .is_some()
2125    {
2126        return Err(CliError::MigrationError(
2127            "SQLite foreign_key_check failed after migration rebuild".to_string(),
2128        ));
2129    }
2130    Ok(())
2131}
2132
2133#[cfg(feature = "rusqlite")]
2134fn inspect_sqlite_migrations(set: &Migrations, path: &str) -> Result<MigrationPlan, CliError> {
2135    if !sqlite_database_exists(path) {
2136        return build_migration_plan(set, &[]);
2137    }
2138    let conn = rusqlite::Connection::open(path).map_err(|e| {
2139        CliError::ConnectionError(format!("Failed to open SQLite database '{path}': {e}"))
2140    })?;
2141
2142    let applied = match read_sqlite_tracking_state(&conn, set)? {
2143        TrackingState::Missing => Vec::new(),
2144        TrackingState::Legacy(rows) => legacy_applied_records(set, &rows)?,
2145        TrackingState::Current => query_applied_records_sqlite(&conn, set)?,
2146    };
2147    build_migration_plan(set, &applied)
2148}
2149
2150#[cfg(feature = "rusqlite")]
2151fn is_sqlite_missing_table(error: &rusqlite::Error) -> bool {
2152    matches!(
2153        error,
2154        rusqlite::Error::SqliteFailure(_, Some(message)) if message.contains("no such table")
2155    )
2156}
2157
2158#[cfg(feature = "rusqlite")]
2159fn query_applied_records_sqlite(
2160    conn: &rusqlite::Connection,
2161    set: &Migrations,
2162) -> Result<Vec<AppliedMigrationRecord>, CliError> {
2163    let sql = set.applied_records_sql();
2164    let mut stmt = match conn.prepare(&sql) {
2165        Ok(stmt) => stmt,
2166        Err(error) if is_sqlite_missing_table(&error) => return Ok(vec![]),
2167        Err(error) => return Err(CliError::MigrationError(error.to_string())),
2168    };
2169
2170    let mut applied = Vec::new();
2171    let rows = stmt
2172        .query_map([], |row| {
2173            Ok((
2174                row.get::<_, String>(0)?,
2175                row.get::<_, String>(1)?,
2176                row.get::<_, i64>(2)? != 0,
2177            ))
2178        })
2179        .map_err(|e| CliError::MigrationError(e.to_string()))?;
2180
2181    for row in rows {
2182        let (hash, name, dirty) = row.map_err(|e| CliError::MigrationError(e.to_string()))?;
2183        applied.push(AppliedMigrationRecord { hash, name, dirty });
2184    }
2185
2186    Ok(applied)
2187}
2188
2189#[cfg(feature = "rusqlite")]
2190fn query_applied_names_sqlite(
2191    conn: &rusqlite::Connection,
2192    set: &Migrations,
2193) -> Result<Vec<String>, CliError> {
2194    let mut stmt = match conn.prepare(&set.applied_names_sql()) {
2195        Ok(stmt) => stmt,
2196        Err(error) if is_sqlite_missing_table(&error) => return Ok(vec![]),
2197        Err(error) => return Err(CliError::MigrationError(error.to_string())),
2198    };
2199
2200    let names = stmt
2201        .query_map([], |row| row.get::<_, String>(0))
2202        .map_err(|e| CliError::MigrationError(e.to_string()))?
2203        .collect::<Result<Vec<_>, _>>()
2204        .map_err(|error| CliError::MigrationError(error.to_string()))?;
2205
2206    Ok(names)
2207}
2208
2209// ============================================================================
2210// PostgreSQL (postgres - sync)
2211// ============================================================================
2212
2213#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
2214fn postgres_tls_connector(
2215    mode: crate::config::PostgresSslMode,
2216) -> Result<postgres_native_tls::MakeTlsConnector, CliError> {
2217    let mut builder = native_tls::TlsConnector::builder();
2218    if mode == crate::config::PostgresSslMode::VerifyCa {
2219        builder.danger_accept_invalid_hostnames(true);
2220    }
2221    let connector = builder
2222        .build()
2223        .map_err(|error| CliError::ConnectionError(error.to_string()))?;
2224    Ok(postgres_native_tls::MakeTlsConnector::new(connector))
2225}
2226
2227#[cfg(feature = "postgres-sync")]
2228fn connect_postgres_sync(creds: &PostgresCreds) -> Result<postgres::Client, CliError> {
2229    let connection = creds
2230        .connection_config()
2231        .map_err(CliError::ConnectionError)?;
2232    let config = match creds {
2233        PostgresCreds::Url(url) => url
2234            .parse::<postgres::Config>()
2235            .map_err(|error| CliError::ConnectionError(error.to_string()))?,
2236        PostgresCreds::Host {
2237            host,
2238            port,
2239            user,
2240            password,
2241            database,
2242            ..
2243        } => {
2244            let mut config = postgres::Config::new();
2245            config
2246                .host(host.as_ref())
2247                .port(*port)
2248                .dbname(database.as_ref());
2249            if let Some(user) = user {
2250                config.user(user.as_ref());
2251            }
2252            if let Some(password) = password {
2253                config.password(password.as_bytes());
2254            }
2255            config.ssl_mode(connection.config.get_ssl_mode());
2256            config
2257        }
2258    };
2259    if connection.ssl == crate::config::PostgresSslMode::Disable {
2260        config
2261            .connect(postgres::NoTls)
2262            .map_err(|error| CliError::ConnectionError(error.to_string()))
2263    } else {
2264        let connector = postgres_tls_connector(connection.ssl)?;
2265        config
2266            .connect(connector)
2267            .map_err(|error| CliError::ConnectionError(error.to_string()))
2268    }
2269}
2270
2271#[cfg(feature = "tokio-postgres")]
2272async fn connect_postgres_async(creds: &PostgresCreds) -> Result<tokio_postgres::Client, CliError> {
2273    let connection = creds
2274        .connection_config()
2275        .map_err(CliError::ConnectionError)?;
2276    if connection.ssl == crate::config::PostgresSslMode::Disable {
2277        let (client, driver) = connection
2278            .config
2279            .connect(tokio_postgres::NoTls)
2280            .await
2281            .map_err(|error| CliError::ConnectionError(error.to_string()))?;
2282        tokio::spawn(async move {
2283            if let Err(error) = driver.await {
2284                eprintln!("PostgreSQL connection error: {error}");
2285            }
2286        });
2287        Ok(client)
2288    } else {
2289        let connector = postgres_tls_connector(connection.ssl)?;
2290        let (client, driver) = connection
2291            .config
2292            .connect(connector)
2293            .await
2294            .map_err(|error| CliError::ConnectionError(error.to_string()))?;
2295        tokio::spawn(async move {
2296            if let Err(error) = driver.await {
2297                eprintln!("PostgreSQL connection error: {error}");
2298            }
2299        });
2300        Ok(client)
2301    }
2302}
2303
2304#[cfg(feature = "postgres-sync")]
2305fn execute_postgres_sync_statements(
2306    creds: &PostgresCreds,
2307    statements: &[String],
2308) -> Result<(), CliError> {
2309    let mut client = connect_postgres_sync(creds)?;
2310
2311    if has_postgres_concurrent_index(statements) {
2312        // CREATE/DROP INDEX CONCURRENTLY cannot run inside a transaction block.
2313        for stmt in statements {
2314            let s = stmt.trim();
2315            if s.is_empty() {
2316                continue;
2317            }
2318            client
2319                .execute(s, &[])
2320                .map_err(|e| CliError::MigrationError(format!("Statement failed: {e}\n{s}")))?;
2321        }
2322    } else {
2323        let mut tx = client
2324            .transaction()
2325            .map_err(|e| CliError::MigrationError(e.to_string()))?;
2326
2327        for stmt in statements {
2328            let s = stmt.trim();
2329            if s.is_empty() {
2330                continue;
2331            }
2332            tx.execute(s, &[])
2333                .map_err(|e| CliError::MigrationError(format!("Statement failed: {e}\n{s}")))?;
2334        }
2335
2336        tx.commit()
2337            .map_err(|e| CliError::MigrationError(e.to_string()))?;
2338    }
2339
2340    Ok(())
2341}
2342
2343#[cfg(feature = "postgres-sync")]
2344fn run_postgres_sync_migrations(
2345    set: &Migrations,
2346    creds: &PostgresCreds,
2347    repair: bool,
2348) -> Result<MigrationResult, CliError> {
2349    let mut client = connect_postgres_sync(creds)?;
2350
2351    // Take the lock before creating the schema: concurrent first runs
2352    // otherwise race on `CREATE SCHEMA/TABLE IF NOT EXISTS`.
2353    let lock_key = set.postgres_advisory_lock_key();
2354    client
2355        .query_one("SELECT pg_advisory_lock($1)", &[&lock_key])
2356        .map_err(|error| CliError::MigrationError(error.to_string()))?;
2357    let result = run_postgres_sync_migrations_locked(&mut client, set, repair);
2358    let unlock = client
2359        .query_one("SELECT pg_advisory_unlock($1)", &[&lock_key])
2360        .map_err(|error| CliError::MigrationError(error.to_string()));
2361    match (result, unlock) {
2362        (Ok(result), Ok(_)) => Ok(result),
2363        (Err(error), _) => Err(error),
2364        (Ok(_), Err(error)) => Err(error),
2365    }
2366}
2367
2368#[cfg(feature = "postgres-sync")]
2369fn run_postgres_sync_migrations_locked(
2370    client: &mut postgres::Client,
2371    set: &Migrations,
2372    repair: bool,
2373) -> Result<MigrationResult, CliError> {
2374    if let Some(schema_sql) = set.create_schema_sql() {
2375        client
2376            .execute(&schema_sql, &[])
2377            .map_err(|e| CliError::MigrationError(postgres_error_text(&e)))?;
2378    }
2379    ensure_postgres_tracking_table_sync(client, set)?;
2380    let repaired = repair_dirty_migrations_postgres_sync(client, set, repair)?;
2381    let rows = client
2382        .query(&set.applied_names_sql(), &[])
2383        .map_err(|error| CliError::MigrationError(error.to_string()))?;
2384    let applied_names = rows
2385        .iter()
2386        .map(|row| row.try_get(0))
2387        .collect::<Result<Vec<String>, _>>()
2388        .map_err(|error| CliError::MigrationError(error.to_string()))?;
2389    let pending: Vec<_> = set.pending(&applied_names).collect();
2390    if pending.is_empty() {
2391        return Ok(MigrationResult {
2392            applied_count: 0,
2393            applied_migrations: vec![],
2394            repaired_migrations: repaired,
2395        });
2396    }
2397
2398    let mut applied = Vec::new();
2399    if set.has_postgres_concurrent_index() {
2400        // CREATE/DROP INDEX CONCURRENTLY cannot run in a transaction, so this
2401        // path uses two-phase tracking: mark the migration dirty, run the
2402        // statements autocommit, then stamp applied_at. A crash in between
2403        // leaves a recoverable dirty row instead of an untracked partial
2404        // schema.
2405        for migration in &pending {
2406            client
2407                .execute(&set.record_migration_started_sql(migration), &[])
2408                .map_err(|error| CliError::MigrationError(error.to_string()))?;
2409
2410            let mut executed = 0usize;
2411            for statement in migration.statements() {
2412                if statement.trim().is_empty() {
2413                    continue;
2414                }
2415                if let Err(error) = client.batch_execute(statement) {
2416                    // Nothing applied yet - drop the marker instead of
2417                    // demanding a pointless repair.
2418                    if executed == 0 {
2419                        let _ = client.execute(&set.clear_migration_started_sql(migration), &[]);
2420                    }
2421                    return Err(migration_statement_error(migration, statement, &error));
2422                }
2423                executed += 1;
2424            }
2425
2426            client
2427                .execute(&set.record_migration_finished_sql(migration), &[])
2428                .map_err(|error| CliError::MigrationError(error.to_string()))?;
2429            applied.push(migration.hash().to_string());
2430        }
2431    } else {
2432        let mut transaction = client
2433            .transaction()
2434            .map_err(|error| CliError::MigrationError(error.to_string()))?;
2435        for migration in &pending {
2436            for statement in migration.statements() {
2437                if !statement.trim().is_empty() {
2438                    // Simple-query protocol: a breakpoint chunk runs whole.
2439                    transaction
2440                        .batch_execute(statement)
2441                        .map_err(|error| migration_statement_error(migration, statement, &error))?;
2442                }
2443            }
2444            transaction
2445                .execute(&set.record_migration_sql(migration), &[])
2446                .map_err(|error| CliError::MigrationError(error.to_string()))?;
2447            applied.push(migration.hash().to_string());
2448        }
2449        transaction
2450            .commit()
2451            .map_err(|error| CliError::MigrationError(error.to_string()))?;
2452    }
2453
2454    Ok(MigrationResult {
2455        applied_count: applied.len(),
2456        applied_migrations: applied,
2457        repaired_migrations: repaired,
2458    })
2459}
2460
2461#[cfg(feature = "postgres-sync")]
2462fn inspect_postgres_sync_migrations(
2463    set: &Migrations,
2464    creds: &PostgresCreds,
2465) -> Result<MigrationPlan, CliError> {
2466    let mut client = connect_postgres_sync(creds)?;
2467
2468    // Read-only: no CREATE SCHEMA, no tracking table.
2469    let applied = match read_postgres_tracking_state_sync(&mut client, set)? {
2470        TrackingState::Missing => Vec::new(),
2471        TrackingState::Legacy(rows) => legacy_applied_records(set, &rows)?,
2472        TrackingState::Current => query_applied_records_postgres_sync(&mut client, set)?,
2473    };
2474    build_migration_plan(set, &applied)
2475}
2476
2477#[cfg(feature = "postgres-sync")]
2478fn query_applied_records_postgres_sync(
2479    client: &mut postgres::Client,
2480    set: &Migrations,
2481) -> Result<Vec<AppliedMigrationRecord>, CliError> {
2482    let rows = client
2483        .query(&set.applied_records_sql(), &[])
2484        .map_err(|error| CliError::MigrationError(error.to_string()))?;
2485
2486    let mut applied = Vec::new();
2487    for row in rows {
2488        let hash = row
2489            .try_get::<_, Option<String>>(0)
2490            .map_err(|error| CliError::MigrationError(error.to_string()))?;
2491        let name = row
2492            .try_get::<_, Option<String>>(1)
2493            .map_err(|error| CliError::MigrationError(error.to_string()))?;
2494        let dirty = row
2495            .try_get::<_, bool>(2)
2496            .map_err(|error| CliError::MigrationError(error.to_string()))?;
2497        if let (Some(hash), Some(name)) = (hash, name) {
2498            applied.push(AppliedMigrationRecord { hash, name, dirty });
2499        }
2500    }
2501
2502    Ok(applied)
2503}
2504
2505// ============================================================================
2506// PostgreSQL (tokio-postgres - async)
2507// ============================================================================
2508
2509#[cfg(feature = "tokio-postgres")]
2510fn execute_postgres_async_statements(
2511    creds: &PostgresCreds,
2512    statements: &[String],
2513) -> Result<(), CliError> {
2514    let rt = tokio::runtime::Builder::new_current_thread()
2515        .enable_all()
2516        .build()
2517        .map_err(|e| CliError::Other(format!("Failed to create async runtime: {}", e)))?;
2518
2519    rt.block_on(execute_postgres_async_inner(creds, statements))
2520}
2521
2522#[cfg(feature = "tokio-postgres")]
2523async fn execute_postgres_async_inner(
2524    creds: &PostgresCreds,
2525    statements: &[String],
2526) -> Result<(), CliError> {
2527    let mut client = connect_postgres_async(creds).await?;
2528
2529    if has_postgres_concurrent_index(statements) {
2530        // CREATE/DROP INDEX CONCURRENTLY cannot run inside a transaction block.
2531        for stmt in statements {
2532            let s = stmt.trim();
2533            if s.is_empty() {
2534                continue;
2535            }
2536            client
2537                .execute(s, &[])
2538                .await
2539                .map_err(|e| CliError::MigrationError(format!("Statement failed: {}\n{}", e, s)))?;
2540        }
2541    } else {
2542        let tx = client
2543            .transaction()
2544            .await
2545            .map_err(|e| CliError::MigrationError(e.to_string()))?;
2546
2547        for stmt in statements {
2548            let s = stmt.trim();
2549            if s.is_empty() {
2550                continue;
2551            }
2552            tx.execute(s, &[])
2553                .await
2554                .map_err(|e| CliError::MigrationError(format!("Statement failed: {}\n{}", e, s)))?;
2555        }
2556
2557        tx.commit()
2558            .await
2559            .map_err(|e| CliError::MigrationError(e.to_string()))?;
2560    }
2561
2562    Ok(())
2563}
2564
2565#[cfg(feature = "tokio-postgres")]
2566#[allow(dead_code)] // Used when postgres-sync is not enabled
2567fn run_postgres_async_migrations(
2568    set: &Migrations,
2569    creds: &PostgresCreds,
2570    repair: bool,
2571) -> Result<MigrationResult, CliError> {
2572    let rt = tokio::runtime::Builder::new_current_thread()
2573        .enable_all()
2574        .build()
2575        .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
2576
2577    rt.block_on(run_postgres_async_inner(set, creds, repair))
2578}
2579
2580#[cfg(feature = "tokio-postgres")]
2581#[allow(dead_code)] // Used when postgres-sync is not enabled
2582fn inspect_postgres_async_migrations(
2583    set: &Migrations,
2584    creds: &PostgresCreds,
2585) -> Result<MigrationPlan, CliError> {
2586    let rt = tokio::runtime::Builder::new_current_thread()
2587        .enable_all()
2588        .build()
2589        .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
2590
2591    rt.block_on(inspect_postgres_async_inner(set, creds))
2592}
2593
2594#[cfg(feature = "tokio-postgres")]
2595#[allow(dead_code)]
2596async fn inspect_postgres_async_inner(
2597    set: &Migrations,
2598    creds: &PostgresCreds,
2599) -> Result<MigrationPlan, CliError> {
2600    let client = connect_postgres_async(creds).await?;
2601
2602    // Read-only: no CREATE SCHEMA, no tracking table.
2603    let applied = match read_postgres_tracking_state_async(&client, set).await? {
2604        TrackingState::Missing => Vec::new(),
2605        TrackingState::Legacy(rows) => legacy_applied_records(set, &rows)?,
2606        TrackingState::Current => query_applied_records_postgres_async(&client, set).await?,
2607    };
2608    build_migration_plan(set, &applied)
2609}
2610
2611#[cfg(feature = "tokio-postgres")]
2612async fn query_applied_records_postgres_async(
2613    client: &tokio_postgres::Client,
2614    set: &Migrations,
2615) -> Result<Vec<AppliedMigrationRecord>, CliError> {
2616    let rows = client
2617        .query(&set.applied_records_sql(), &[])
2618        .await
2619        .map_err(|error| CliError::MigrationError(error.to_string()))?;
2620
2621    let mut applied = Vec::new();
2622    for row in rows {
2623        let hash = row
2624            .try_get::<_, Option<String>>(0)
2625            .map_err(|error| CliError::MigrationError(error.to_string()))?;
2626        let name = row
2627            .try_get::<_, Option<String>>(1)
2628            .map_err(|error| CliError::MigrationError(error.to_string()))?;
2629        let dirty = row
2630            .try_get::<_, bool>(2)
2631            .map_err(|error| CliError::MigrationError(error.to_string()))?;
2632        if let (Some(hash), Some(name)) = (hash, name) {
2633            applied.push(AppliedMigrationRecord { hash, name, dirty });
2634        }
2635    }
2636
2637    Ok(applied)
2638}
2639
2640#[cfg(feature = "tokio-postgres")]
2641#[allow(dead_code)]
2642async fn run_postgres_async_inner(
2643    set: &Migrations,
2644    creds: &PostgresCreds,
2645    repair: bool,
2646) -> Result<MigrationResult, CliError> {
2647    let mut client = connect_postgres_async(creds).await?;
2648
2649    // Take the lock before creating the schema: concurrent first runs
2650    // otherwise race on `CREATE SCHEMA/TABLE IF NOT EXISTS`.
2651    let lock_key = set.postgres_advisory_lock_key();
2652    client
2653        .query_one("SELECT pg_advisory_lock($1)", &[&lock_key])
2654        .await
2655        .map_err(|error| CliError::MigrationError(error.to_string()))?;
2656    let result = run_postgres_async_migrations_locked(&mut client, set, repair).await;
2657    let unlock = client
2658        .query_one("SELECT pg_advisory_unlock($1)", &[&lock_key])
2659        .await
2660        .map_err(|error| CliError::MigrationError(error.to_string()));
2661    match (result, unlock) {
2662        (Ok(result), Ok(_)) => Ok(result),
2663        (Err(error), _) => Err(error),
2664        (Ok(_), Err(error)) => Err(error),
2665    }
2666}
2667
2668#[cfg(feature = "tokio-postgres")]
2669async fn run_postgres_async_migrations_locked(
2670    client: &mut tokio_postgres::Client,
2671    set: &Migrations,
2672    repair: bool,
2673) -> Result<MigrationResult, CliError> {
2674    if let Some(schema_sql) = set.create_schema_sql() {
2675        client
2676            .execute(&schema_sql, &[])
2677            .await
2678            .map_err(|e| CliError::MigrationError(postgres_error_text(&e)))?;
2679    }
2680    ensure_postgres_tracking_table_async(client, set).await?;
2681    let repaired = repair_dirty_migrations_postgres_async(client, set, repair).await?;
2682    let rows = client
2683        .query(&set.applied_names_sql(), &[])
2684        .await
2685        .map_err(|error| CliError::MigrationError(error.to_string()))?;
2686    let applied_names = rows
2687        .iter()
2688        .map(|row| row.try_get(0))
2689        .collect::<Result<Vec<String>, _>>()
2690        .map_err(|error| CliError::MigrationError(error.to_string()))?;
2691    let pending: Vec<_> = set.pending(&applied_names).collect();
2692    if pending.is_empty() {
2693        return Ok(MigrationResult {
2694            applied_count: 0,
2695            applied_migrations: vec![],
2696            repaired_migrations: repaired,
2697        });
2698    }
2699
2700    let mut applied = Vec::new();
2701    if set.has_postgres_concurrent_index() {
2702        // CREATE/DROP INDEX CONCURRENTLY cannot run in a transaction, so this
2703        // path uses two-phase tracking: mark the migration dirty, run the
2704        // statements autocommit, then stamp applied_at. A crash in between
2705        // leaves a recoverable dirty row instead of an untracked partial
2706        // schema.
2707        for migration in &pending {
2708            client
2709                .execute(&set.record_migration_started_sql(migration), &[])
2710                .await
2711                .map_err(|error| CliError::MigrationError(error.to_string()))?;
2712
2713            let mut executed = 0usize;
2714            for statement in migration.statements() {
2715                if statement.trim().is_empty() {
2716                    continue;
2717                }
2718                if let Err(error) = client.batch_execute(statement).await {
2719                    // Nothing applied yet - drop the marker instead of
2720                    // demanding a pointless repair.
2721                    if executed == 0 {
2722                        let _ = client
2723                            .execute(&set.clear_migration_started_sql(migration), &[])
2724                            .await;
2725                    }
2726                    return Err(migration_statement_error(migration, statement, &error));
2727                }
2728                executed += 1;
2729            }
2730
2731            client
2732                .execute(&set.record_migration_finished_sql(migration), &[])
2733                .await
2734                .map_err(|error| CliError::MigrationError(error.to_string()))?;
2735            applied.push(migration.hash().to_string());
2736        }
2737    } else {
2738        let transaction = client
2739            .transaction()
2740            .await
2741            .map_err(|error| CliError::MigrationError(error.to_string()))?;
2742        for migration in &pending {
2743            for statement in migration.statements() {
2744                if !statement.trim().is_empty() {
2745                    // Simple-query protocol: a breakpoint chunk runs whole.
2746                    transaction
2747                        .batch_execute(statement)
2748                        .await
2749                        .map_err(|error| migration_statement_error(migration, statement, &error))?;
2750                }
2751            }
2752            transaction
2753                .execute(&set.record_migration_sql(migration), &[])
2754                .await
2755                .map_err(|error| CliError::MigrationError(error.to_string()))?;
2756            applied.push(migration.hash().to_string());
2757        }
2758        transaction
2759            .commit()
2760            .await
2761            .map_err(|error| CliError::MigrationError(error.to_string()))?;
2762    }
2763
2764    Ok(MigrationResult {
2765        applied_count: applied.len(),
2766        applied_migrations: applied,
2767        repaired_migrations: repaired,
2768    })
2769}
2770
2771// ============================================================================
2772// LibSQL (local)
2773// ============================================================================
2774
2775#[cfg(feature = "libsql")]
2776fn execute_libsql_local_statements(path: &str, statements: &[String]) -> Result<(), CliError> {
2777    let rt = tokio::runtime::Builder::new_current_thread()
2778        .enable_all()
2779        .build()
2780        .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
2781
2782    rt.block_on(execute_libsql_local_inner(path, statements))
2783}
2784
2785#[cfg(feature = "libsql")]
2786async fn execute_libsql_local_inner(path: &str, statements: &[String]) -> Result<(), CliError> {
2787    let db = libsql::Builder::new_local(path)
2788        .build()
2789        .await
2790        .map_err(|e| {
2791            CliError::ConnectionError(format!("Failed to open LibSQL database '{path}': {e}"))
2792        })?;
2793
2794    let conn = db
2795        .connect()
2796        .map_err(|e| CliError::ConnectionError(e.to_string()))?;
2797
2798    execute_libsql_sqlite_statements_on_connection(&conn, statements).await
2799}
2800
2801#[cfg(any(feature = "libsql", feature = "turso"))]
2802async fn execute_libsql_sqlite_statements_on_connection(
2803    conn: &libsql::Connection,
2804    statements: &[String],
2805) -> Result<(), CliError> {
2806    let operation =
2807        drizzle_migrations::Migration::with_hash("libsql_push", "", 0, statements.to_vec());
2808    let execution = operation
2809        .sqlite_execution()
2810        .map_err(sqlite_execution_cli_error)?;
2811    let foreign_keys_were_enabled = sqlite_foreign_keys_enabled_libsql_cli(conn).await?;
2812    let suspend_foreign_keys = execution.suspends_foreign_keys() && foreign_keys_were_enabled;
2813    if suspend_foreign_keys
2814        && let Err(error) = set_sqlite_foreign_keys_libsql_cli(conn, false).await
2815    {
2816        let restore = set_sqlite_foreign_keys_libsql_cli(conn, true).await;
2817        return finish_sqlite_foreign_key_scope(Err(error), restore);
2818    }
2819
2820    let result = async {
2821        let tx = conn
2822            .transaction_with_behavior(libsql::TransactionBehavior::Immediate)
2823            .await
2824            .map_err(|error| CliError::MigrationError(error.to_string()))?;
2825        let transaction_result = async {
2826            for statement in execution
2827                .statements()
2828                .filter(|statement| !statement.trim().is_empty())
2829            {
2830                tx.execute(statement, ()).await.map_err(|error| {
2831                    CliError::MigrationError(format!("Statement failed: {error}\n{statement}"))
2832                })?;
2833            }
2834            if execution.suspends_foreign_keys() {
2835                verify_sqlite_foreign_keys_libsql_cli(&tx).await?;
2836            }
2837            Ok::<_, CliError>(())
2838        }
2839        .await;
2840        match transaction_result {
2841            Ok(()) => tx
2842                .commit()
2843                .await
2844                .map_err(|error| CliError::MigrationError(error.to_string())),
2845            Err(error) => {
2846                tx.rollback().await.ok();
2847                Err(error)
2848            }
2849        }
2850    }
2851    .await;
2852    let restore = if suspend_foreign_keys {
2853        set_sqlite_foreign_keys_libsql_cli(conn, true).await
2854    } else {
2855        Ok(())
2856    };
2857    finish_sqlite_foreign_key_scope(result, restore)
2858}
2859
2860#[cfg(feature = "libsql")]
2861fn run_libsql_local_migrations(
2862    set: &Migrations,
2863    path: &str,
2864    repair: bool,
2865) -> Result<MigrationResult, CliError> {
2866    let rt = tokio::runtime::Builder::new_current_thread()
2867        .enable_all()
2868        .build()
2869        .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
2870
2871    rt.block_on(run_libsql_local_inner(set, path, repair))
2872}
2873
2874#[cfg(feature = "libsql")]
2875fn inspect_libsql_local_migrations(
2876    set: &Migrations,
2877    path: &str,
2878) -> Result<MigrationPlan, CliError> {
2879    let rt = tokio::runtime::Builder::new_current_thread()
2880        .enable_all()
2881        .build()
2882        .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
2883
2884    rt.block_on(inspect_libsql_local_inner(set, path))
2885}
2886
2887#[cfg(feature = "libsql")]
2888async fn inspect_libsql_local_inner(
2889    set: &Migrations,
2890    path: &str,
2891) -> Result<MigrationPlan, CliError> {
2892    if !sqlite_database_exists(path) {
2893        return build_migration_plan(set, &[]);
2894    }
2895    let db = libsql::Builder::new_local(path)
2896        .build()
2897        .await
2898        .map_err(|e| {
2899            CliError::ConnectionError(format!("Failed to open LibSQL database '{path}': {e}"))
2900        })?;
2901
2902    let conn = db
2903        .connect()
2904        .map_err(|e| CliError::ConnectionError(e.to_string()))?;
2905
2906    let applied = match read_sqlite_tracking_state_libsql(&conn, set).await? {
2907        TrackingState::Missing => Vec::new(),
2908        TrackingState::Legacy(rows) => legacy_applied_records(set, &rows)?,
2909        TrackingState::Current => query_applied_records_libsql(&conn, set).await?,
2910    };
2911    build_migration_plan(set, &applied)
2912}
2913
2914#[cfg(feature = "libsql")]
2915async fn run_libsql_local_inner(
2916    set: &Migrations,
2917    path: &str,
2918    repair: bool,
2919) -> Result<MigrationResult, CliError> {
2920    let db = libsql::Builder::new_local(path)
2921        .build()
2922        .await
2923        .map_err(|e| {
2924            CliError::ConnectionError(format!("Failed to open LibSQL database '{path}': {e}"))
2925        })?;
2926
2927    let conn = db
2928        .connect()
2929        .map_err(|e| CliError::ConnectionError(e.to_string()))?;
2930
2931    run_libsql_sqlite_migrations_on_connection(set, &conn, repair).await
2932}
2933
2934#[cfg(any(feature = "libsql", feature = "turso"))]
2935async fn query_applied_names_libsql(
2936    conn: &libsql::Connection,
2937    set: &Migrations,
2938) -> Result<Vec<String>, CliError> {
2939    let mut rows = conn
2940        .query(&set.applied_names_sql(), ())
2941        .await
2942        .map_err(|error| CliError::MigrationError(error.to_string()))?;
2943
2944    let mut names = Vec::new();
2945    while let Some(row) = rows
2946        .next()
2947        .await
2948        .map_err(|error| CliError::MigrationError(error.to_string()))?
2949    {
2950        names.push(
2951            row.get::<String>(0)
2952                .map_err(|error| CliError::MigrationError(error.to_string()))?,
2953        );
2954    }
2955
2956    Ok(names)
2957}
2958
2959#[cfg(any(feature = "libsql", feature = "turso"))]
2960async fn run_libsql_sqlite_migrations_on_connection(
2961    set: &Migrations,
2962    conn: &libsql::Connection,
2963    repair: bool,
2964) -> Result<MigrationResult, CliError> {
2965    ensure_sqlite_tracking_table_libsql(conn, set).await?;
2966    let applied_before_transaction = query_applied_names_libsql(conn, set).await?;
2967    let suspends_foreign_keys = set
2968        .pending(&applied_before_transaction)
2969        .map(|migration| migration.sqlite_execution())
2970        .collect::<Result<Vec<_>, _>>()
2971        .map_err(sqlite_execution_cli_error)?
2972        .iter()
2973        .any(|execution| execution.suspends_foreign_keys());
2974    let foreign_keys_were_enabled = sqlite_foreign_keys_enabled_libsql_cli(conn).await?;
2975    let suspend_foreign_keys = suspends_foreign_keys && foreign_keys_were_enabled;
2976    if suspend_foreign_keys
2977        && let Err(error) = set_sqlite_foreign_keys_libsql_cli(conn, false).await
2978    {
2979        let restore = set_sqlite_foreign_keys_libsql_cli(conn, true).await;
2980        return finish_sqlite_foreign_key_scope(Err(error), restore);
2981    }
2982
2983    let result = async {
2984        let repaired = repair_dirty_migrations_libsql(conn, set, repair).await?;
2985        let tx = conn
2986            .transaction_with_behavior(libsql::TransactionBehavior::Immediate)
2987            .await
2988            .map_err(|error| CliError::MigrationError(error.to_string()))?;
2989        let transaction_result = async {
2990            let applied_names = query_applied_names_libsql(&tx, set).await?;
2991            let pending = set
2992                .pending(&applied_names)
2993                .map(|migration| {
2994                    migration
2995                        .sqlite_execution()
2996                        .map(|execution| (migration, execution))
2997                })
2998                .collect::<Result<Vec<_>, _>>()
2999                .map_err(sqlite_execution_cli_error)?;
3000
3001            let mut applied = Vec::with_capacity(pending.len());
3002            for (migration, execution) in pending {
3003                for statement in execution
3004                    .statements()
3005                    .filter(|statement| !statement.trim().is_empty())
3006                {
3007                    tx.execute(statement, ()).await.map_err(|error| {
3008                        CliError::MigrationError(format!(
3009                            "Migration '{}' failed: {error}",
3010                            migration.hash()
3011                        ))
3012                    })?;
3013                }
3014                tx.execute(&set.record_migration_sql(migration), ())
3015                    .await
3016                    .map_err(|error| CliError::MigrationError(error.to_string()))?;
3017                applied.push(migration.hash().to_string());
3018            }
3019
3020            Ok::<_, CliError>(MigrationResult {
3021                applied_count: applied.len(),
3022                applied_migrations: applied,
3023                repaired_migrations: repaired,
3024            })
3025        }
3026        .await;
3027
3028        match transaction_result {
3029            Ok(outcome) => {
3030                if suspends_foreign_keys
3031                    && let Err(error) = verify_sqlite_foreign_keys_libsql_cli(&tx).await
3032                {
3033                    tx.rollback().await.ok();
3034                    return Err(error);
3035                }
3036                tx.commit()
3037                    .await
3038                    .map_err(|error| CliError::MigrationError(error.to_string()))?;
3039                Ok(outcome)
3040            }
3041            Err(error) => {
3042                tx.rollback().await.ok();
3043                Err(error)
3044            }
3045        }
3046    }
3047    .await;
3048    let restore = if suspend_foreign_keys {
3049        set_sqlite_foreign_keys_libsql_cli(conn, true).await
3050    } else {
3051        Ok(())
3052    };
3053    finish_sqlite_foreign_key_scope(result, restore)
3054}
3055
3056#[cfg(any(feature = "libsql", feature = "turso"))]
3057async fn sqlite_foreign_keys_enabled_libsql_cli(
3058    conn: &libsql::Connection,
3059) -> Result<bool, CliError> {
3060    let mut rows = conn
3061        .query("PRAGMA foreign_keys", ())
3062        .await
3063        .map_err(|error| CliError::MigrationError(error.to_string()))?;
3064    let row = rows
3065        .next()
3066        .await
3067        .map_err(|error| CliError::MigrationError(error.to_string()))?
3068        .ok_or_else(|| CliError::MigrationError("PRAGMA foreign_keys returned no row".into()))?;
3069    row.get::<i64>(0)
3070        .map(|enabled| enabled != 0)
3071        .map_err(|error| CliError::MigrationError(error.to_string()))
3072}
3073
3074#[cfg(any(feature = "libsql", feature = "turso"))]
3075async fn set_sqlite_foreign_keys_libsql_cli(
3076    conn: &libsql::Connection,
3077    enabled: bool,
3078) -> Result<(), CliError> {
3079    conn.execute(
3080        if enabled {
3081            "PRAGMA foreign_keys=ON"
3082        } else {
3083            "PRAGMA foreign_keys=OFF"
3084        },
3085        (),
3086    )
3087    .await
3088    .map_err(|error| CliError::MigrationError(error.to_string()))?;
3089    if sqlite_foreign_keys_enabled_libsql_cli(conn).await? != enabled {
3090        return Err(CliError::MigrationError(format!(
3091            "SQLite refused to set foreign_keys={}",
3092            if enabled { "ON" } else { "OFF" }
3093        )));
3094    }
3095    Ok(())
3096}
3097
3098#[cfg(any(feature = "libsql", feature = "turso"))]
3099async fn verify_sqlite_foreign_keys_libsql_cli(conn: &libsql::Connection) -> Result<(), CliError> {
3100    let mut rows = conn
3101        .query("PRAGMA foreign_key_check", ())
3102        .await
3103        .map_err(|error| CliError::MigrationError(error.to_string()))?;
3104    if rows
3105        .next()
3106        .await
3107        .map_err(|error| CliError::MigrationError(error.to_string()))?
3108        .is_some()
3109    {
3110        return Err(CliError::MigrationError(
3111            "SQLite foreign_key_check failed after migration rebuild".into(),
3112        ));
3113    }
3114    Ok(())
3115}
3116
3117#[cfg(feature = "libsql")]
3118async fn query_applied_records_libsql(
3119    conn: &libsql::Connection,
3120    set: &Migrations,
3121) -> Result<Vec<AppliedMigrationRecord>, CliError> {
3122    let mut rows = conn
3123        .query(&set.applied_records_sql(), ())
3124        .await
3125        .map_err(|error| CliError::MigrationError(error.to_string()))?;
3126
3127    let mut applied = Vec::new();
3128    while let Some(row) = rows
3129        .next()
3130        .await
3131        .map_err(|error| CliError::MigrationError(error.to_string()))?
3132    {
3133        let hash = row
3134            .get::<String>(0)
3135            .map_err(|error| CliError::MigrationError(error.to_string()))?;
3136        let name = row
3137            .get::<String>(1)
3138            .map_err(|error| CliError::MigrationError(error.to_string()))?;
3139        let dirty = row
3140            .get::<i64>(2)
3141            .map_err(|error| CliError::MigrationError(error.to_string()))?
3142            != 0;
3143        applied.push(AppliedMigrationRecord { hash, name, dirty });
3144    }
3145
3146    Ok(applied)
3147}
3148
3149// ============================================================================
3150// Turso (remote)
3151// ============================================================================
3152
3153#[cfg(feature = "turso")]
3154fn execute_turso_statements(
3155    url: &str,
3156    auth_token: Option<&str>,
3157    statements: &[String],
3158) -> Result<(), CliError> {
3159    let rt = tokio::runtime::Builder::new_current_thread()
3160        .enable_all()
3161        .build()
3162        .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
3163
3164    rt.block_on(execute_turso_inner(url, auth_token, statements))
3165}
3166
3167#[cfg(feature = "turso")]
3168async fn execute_turso_inner(
3169    url: &str,
3170    auth_token: Option<&str>,
3171    statements: &[String],
3172) -> Result<(), CliError> {
3173    let builder =
3174        libsql::Builder::new_remote(url.to_string(), auth_token.unwrap_or("").to_string());
3175
3176    let db = builder.build().await.map_err(|e| {
3177        CliError::ConnectionError(format!("Failed to connect to Turso '{url}': {e}"))
3178    })?;
3179
3180    let conn = db
3181        .connect()
3182        .map_err(|e| CliError::ConnectionError(e.to_string()))?;
3183
3184    execute_libsql_sqlite_statements_on_connection(&conn, statements).await
3185}
3186
3187#[cfg(feature = "turso")]
3188fn run_turso_migrations(
3189    set: &Migrations,
3190    url: &str,
3191    auth_token: Option<&str>,
3192    repair: bool,
3193) -> Result<MigrationResult, CliError> {
3194    let rt = tokio::runtime::Builder::new_current_thread()
3195        .enable_all()
3196        .build()
3197        .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
3198
3199    rt.block_on(run_turso_inner(set, url, auth_token, repair))
3200}
3201
3202#[cfg(feature = "turso")]
3203fn inspect_turso_migrations(
3204    set: &Migrations,
3205    url: &str,
3206    auth_token: Option<&str>,
3207) -> Result<MigrationPlan, CliError> {
3208    let rt = tokio::runtime::Builder::new_current_thread()
3209        .enable_all()
3210        .build()
3211        .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
3212
3213    rt.block_on(inspect_turso_inner(set, url, auth_token))
3214}
3215
3216#[cfg(feature = "turso")]
3217async fn inspect_turso_inner(
3218    set: &Migrations,
3219    url: &str,
3220    auth_token: Option<&str>,
3221) -> Result<MigrationPlan, CliError> {
3222    let builder =
3223        libsql::Builder::new_remote(url.to_string(), auth_token.unwrap_or("").to_string());
3224
3225    let db = builder.build().await.map_err(|e| {
3226        CliError::ConnectionError(format!("Failed to connect to Turso '{url}': {e}"))
3227    })?;
3228
3229    let conn = db
3230        .connect()
3231        .map_err(|e| CliError::ConnectionError(e.to_string()))?;
3232
3233    let applied = match read_sqlite_tracking_state_libsql(&conn, set).await? {
3234        TrackingState::Missing => Vec::new(),
3235        TrackingState::Legacy(rows) => legacy_applied_records(set, &rows)?,
3236        TrackingState::Current => query_applied_records_turso(&conn, set).await?,
3237    };
3238    build_migration_plan(set, &applied)
3239}
3240
3241#[cfg(feature = "turso")]
3242async fn run_turso_inner(
3243    set: &Migrations,
3244    url: &str,
3245    auth_token: Option<&str>,
3246    repair: bool,
3247) -> Result<MigrationResult, CliError> {
3248    let builder =
3249        libsql::Builder::new_remote(url.to_string(), auth_token.unwrap_or("").to_string());
3250
3251    let db = builder.build().await.map_err(|e| {
3252        CliError::ConnectionError(format!("Failed to connect to Turso '{url}': {e}"))
3253    })?;
3254
3255    let conn = db
3256        .connect()
3257        .map_err(|e| CliError::ConnectionError(e.to_string()))?;
3258
3259    run_libsql_sqlite_migrations_on_connection(set, &conn, repair).await
3260}
3261
3262#[cfg(feature = "turso")]
3263async fn query_applied_records_turso(
3264    conn: &libsql::Connection,
3265    set: &Migrations,
3266) -> Result<Vec<AppliedMigrationRecord>, CliError> {
3267    let mut rows = conn
3268        .query(&set.applied_records_sql(), ())
3269        .await
3270        .map_err(|error| CliError::MigrationError(error.to_string()))?;
3271
3272    let mut applied = Vec::new();
3273    while let Some(row) = rows
3274        .next()
3275        .await
3276        .map_err(|error| CliError::MigrationError(error.to_string()))?
3277    {
3278        let hash = row
3279            .get::<String>(0)
3280            .map_err(|error| CliError::MigrationError(error.to_string()))?;
3281        let name = row
3282            .get::<String>(1)
3283            .map_err(|error| CliError::MigrationError(error.to_string()))?;
3284        let dirty = row
3285            .get::<i64>(2)
3286            .map_err(|error| CliError::MigrationError(error.to_string()))?
3287            != 0;
3288        applied.push(AppliedMigrationRecord { hash, name, dirty });
3289    }
3290
3291    Ok(applied)
3292}
3293
3294// ============================================================================
3295// Database Introspection
3296// ============================================================================
3297
3298/// What [`run_introspection`] produced.
3299#[derive(Debug)]
3300pub struct IntrospectResult {
3301    /// Generated Rust schema code
3302    pub schema_code: String,
3303    /// Number of tables found
3304    pub table_count: usize,
3305    /// Number of indexes found
3306    pub index_count: usize,
3307    /// Number of views found
3308    pub view_count: usize,
3309    /// Any warnings during introspection
3310    pub warnings: Vec<String>,
3311    /// Live MySQL defaults used only while comparing an introspected schema
3312    /// with a desired schema during push planning.
3313    pub mysql_catalog_defaults: Option<drizzle_migrations::mysql::MySQLCatalogDefaults>,
3314    /// The schema snapshot for migration tracking
3315    pub snapshot: Snapshot,
3316    /// Path to the generated snapshot file
3317    pub snapshot_path: std::path::PathBuf,
3318}
3319
3320/// Introspects a database and writes `<out_dir>/schema.rs` and a baseline
3321/// `<out_dir>/<tag>/` folder (`snapshot.json` and a `migration.sql` that
3322/// creates the whole schema).
3323///
3324/// The tracking table is excluded and `filters` applied before anything is
3325/// written. With `init_metadata`, the baseline is then recorded as applied.
3326///
3327/// # Errors
3328///
3329/// Returns [`CliError`] if connecting or querying the catalogs fails,
3330/// applying the filters fails, `out_dir` uses the legacy journal layout, a
3331/// file cannot be written, or `init_metadata` is refused because migrations
3332/// are already recorded or the folder holds more than one migration.
3333#[allow(clippy::too_many_arguments)]
3334pub fn run_introspection(
3335    connection: &ResolvedConnection,
3336    out_dir: &Path,
3337    init_metadata: bool,
3338    breakpoints: bool,
3339    introspect_casing: Option<IntrospectCasing>,
3340    filters: &SnapshotFilters,
3341    migrations_table: &str,
3342    migrations_schema: &str,
3343) -> Result<IntrospectResult, CliError> {
3344    use drizzle_migrations::naming::generate_migration_tag;
3345
3346    // Perform introspection
3347    let mut result = introspect_database(connection)?;
3348    exclude_tracking_table(&mut result.snapshot, connection.dialect, migrations_table)?;
3349    apply_snapshot_filters(&mut result.snapshot, connection.dialect, filters)?;
3350    if !filters.is_empty() || introspect_casing.is_some() {
3351        regenerate_schema_from_snapshot(&mut result, connection.dialect, introspect_casing);
3352    }
3353
3354    // Write schema file
3355    let schema_path = out_dir.join("schema.rs");
3356    if let Some(parent) = schema_path.parent() {
3357        std::fs::create_dir_all(parent).map_err(|e| {
3358            CliError::Other(format!(
3359                "Failed to create output directory '{}': {}",
3360                parent.display(),
3361                e
3362            ))
3363        })?;
3364    }
3365    std::fs::write(&schema_path, &result.schema_code).map_err(|e| {
3366        CliError::Other(format!(
3367            "Failed to write schema file '{}': {}",
3368            schema_path.display(),
3369            e
3370        ))
3371    })?;
3372
3373    let journal_path = out_dir.join("meta").join("_journal.json");
3374    if journal_path.exists() {
3375        return Err(CliError::Other(
3376            "Detected old drizzle-kit migration folders. Upgrade them before writing new migrations."
3377                .to_string(),
3378        ));
3379    }
3380
3381    // Generate migration tag (V3 format: timestamp-based)
3382    let tag = generate_migration_tag(None);
3383
3384    // Create migration directory: {out}/{tag}/
3385    let migration_dir = out_dir.join(&tag);
3386    std::fs::create_dir_all(&migration_dir).map_err(|e| {
3387        CliError::Other(format!(
3388            "Failed to create migration directory '{}': {}",
3389            migration_dir.display(),
3390            e
3391        ))
3392    })?;
3393
3394    // Save snapshot JSON: {out}/{tag}/snapshot.json
3395    let snapshot_path = migration_dir.join("snapshot.json");
3396    result.snapshot.save(&snapshot_path).map_err(|e| {
3397        CliError::Other(format!(
3398            "Failed to write snapshot file '{}': {}",
3399            snapshot_path.display(),
3400            e
3401        ))
3402    })?;
3403
3404    // Generate initial migration SQL by diffing against empty snapshot
3405    let base_dialect = connection.dialect.to_base();
3406    let empty_snapshot = Snapshot::empty(base_dialect);
3407    let sql_statements =
3408        generate_introspect_migration(&empty_snapshot, &result.snapshot, breakpoints)?;
3409
3410    // Write migration.sql: {out}/{tag}/migration.sql
3411    let migration_sql_path = migration_dir.join("migration.sql");
3412    let sql_content = format_migration_sql(&sql_statements, breakpoints);
3413    std::fs::write(&migration_sql_path, &sql_content).map_err(|e| {
3414        CliError::Other(format!(
3415            "Failed to write migration file '{}': {}",
3416            migration_sql_path.display(),
3417            e
3418        ))
3419    })?;
3420
3421    // Update result with path
3422    result.snapshot_path = snapshot_path;
3423
3424    if init_metadata {
3425        apply_init_metadata(connection, out_dir, migrations_table, migrations_schema)?;
3426    }
3427
3428    Ok(result)
3429}
3430
3431// =============================================================================
3432// Init metadata handling
3433// =============================================================================
3434
3435#[allow(unused_variables)] // params consumed inside feature-gated block
3436fn apply_init_metadata(
3437    connection: &ResolvedConnection,
3438    out_dir: &Path,
3439    migrations_table: &str,
3440    migrations_schema: &str,
3441) -> Result<(), CliError> {
3442    let set = load_migration_set(
3443        connection.dialect,
3444        out_dir,
3445        migrations_table,
3446        migrations_schema,
3447    )?;
3448
3449    match &connection.credentials {
3450        #[cfg(feature = "rusqlite")]
3451        Credentials::Sqlite { path } if connection.driver == Driver::Rusqlite => {
3452            init_sqlite_metadata(path, &set)
3453        }
3454
3455        #[cfg(feature = "rusqlite")]
3456        Credentials::Sqlite { .. } => Err(connection_driver_mismatch(connection)),
3457
3458        #[cfg(not(feature = "rusqlite"))]
3459        Credentials::Sqlite { .. } => Err(CliError::MissingDriver {
3460            dialect: "SQLite",
3461            feature: "rusqlite",
3462        }),
3463
3464        #[cfg(any(feature = "libsql", feature = "turso"))]
3465        Credentials::Turso { url, auth_token } => {
3466            if connection.driver == Driver::Libsql {
3467                #[cfg(feature = "libsql")]
3468                {
3469                    init_libsql_local_metadata(url, &set)
3470                }
3471                #[cfg(not(feature = "libsql"))]
3472                {
3473                    Err(CliError::MissingDriver {
3474                        dialect: "LibSQL (local)",
3475                        feature: "libsql",
3476                    })
3477                }
3478            } else if connection.driver == Driver::Turso {
3479                #[cfg(feature = "turso")]
3480                {
3481                    init_turso_metadata(url, auth_token.as_deref(), &set)
3482                }
3483                #[cfg(not(feature = "turso"))]
3484                {
3485                    let _ = auth_token;
3486                    Err(CliError::MissingDriver {
3487                        dialect: "Turso (remote)",
3488                        feature: "turso",
3489                    })
3490                }
3491            } else {
3492                Err(connection_driver_mismatch(connection))
3493            }
3494        }
3495
3496        #[cfg(all(not(feature = "turso"), not(feature = "libsql")))]
3497        Credentials::Turso { .. } => Err(CliError::MissingDriver {
3498            dialect: "Turso",
3499            feature: "turso or libsql",
3500        }),
3501
3502        Credentials::Postgres(creds) => {
3503            let _ = creds;
3504            match connection.driver {
3505                Driver::PostgresSync => {
3506                    #[cfg(feature = "postgres-sync")]
3507                    {
3508                        init_postgres_sync_metadata(creds, &set)
3509                    }
3510                    #[cfg(not(feature = "postgres-sync"))]
3511                    {
3512                        Err(CliError::MissingDriver {
3513                            dialect: "PostgreSQL",
3514                            feature: "postgres-sync",
3515                        })
3516                    }
3517                }
3518                Driver::TokioPostgres => {
3519                    #[cfg(feature = "tokio-postgres")]
3520                    {
3521                        init_postgres_async_metadata(creds, &set)
3522                    }
3523                    #[cfg(not(feature = "tokio-postgres"))]
3524                    {
3525                        Err(CliError::MissingDriver {
3526                            dialect: "PostgreSQL",
3527                            feature: "tokio-postgres",
3528                        })
3529                    }
3530                }
3531                _ => Err(connection_driver_mismatch(connection)),
3532            }
3533        }
3534
3535        Credentials::MySQL(creds) => mysql::init_metadata(connection.driver, creds, &set),
3536
3537        #[cfg(feature = "d1-http")]
3538        Credentials::D1 {
3539            account_id,
3540            database_id,
3541            token,
3542        } if connection.driver == Driver::D1Http => {
3543            d1_http::init_metadata(&set, account_id, database_id, token)
3544        }
3545
3546        #[cfg(feature = "d1-http")]
3547        Credentials::D1 { .. } => Err(connection_driver_mismatch(connection)),
3548
3549        #[cfg(not(feature = "d1-http"))]
3550        Credentials::D1 { .. } => Err(CliError::MissingDriver {
3551            dialect: "Cloudflare D1 (HTTP)",
3552            feature: "d1-http",
3553        }),
3554
3555        Credentials::AwsDataApi { .. } => Err(CliError::UnsupportedForDriver {
3556            operation: "Migration metadata init against AWS Data API",
3557            driver: "aws-data-api",
3558            hint: "AWS RDS Data API schema ops are not yet wired into this CLI. Seed the \
3559                   migrations table manually via `aws rds-data execute-statement` or \
3560                   tokio-postgres.",
3561        }),
3562    }
3563}
3564
3565#[cfg(any(
3566    feature = "rusqlite",
3567    feature = "libsql",
3568    feature = "turso",
3569    feature = "postgres-sync",
3570    feature = "tokio-postgres",
3571    feature = "mysql-sync",
3572    feature = "mysql-async",
3573    feature = "d1-http",
3574))]
3575pub(crate) fn validate_init_metadata(
3576    applied_names: &[String],
3577    set: &Migrations,
3578) -> Result<(), CliError> {
3579    if !applied_names.is_empty() {
3580        return Err(CliError::Other(
3581            "--init can't be used when database already has migrations set".into(),
3582        ));
3583    }
3584
3585    if set.all().len() > 1 {
3586        return Err(CliError::Other(
3587            "--init can't be used with existing migrations".into(),
3588        ));
3589    }
3590
3591    Ok(())
3592}
3593
3594// =============================================================================
3595// Init metadata implementations
3596// =============================================================================
3597
3598#[cfg(feature = "rusqlite")]
3599fn init_sqlite_metadata(path: &str, set: &Migrations) -> Result<(), CliError> {
3600    let conn = rusqlite::Connection::open(path).map_err(|e| {
3601        CliError::ConnectionError(format!("Failed to open SQLite database '{path}': {e}"))
3602    })?;
3603
3604    ensure_sqlite_tracking_table(&conn, set)?;
3605
3606    let applied_names = query_applied_names_sqlite(&conn, set)?;
3607    validate_init_metadata(&applied_names, set)?;
3608
3609    let Some(first) = set.all().first() else {
3610        return Ok(());
3611    };
3612
3613    conn.execute(&set.record_migration_sql(first), [])
3614        .map_err(|e| CliError::MigrationError(e.to_string()))?;
3615
3616    Ok(())
3617}
3618
3619#[cfg(feature = "libsql")]
3620fn init_libsql_local_metadata(path: &str, set: &Migrations) -> Result<(), CliError> {
3621    let rt = tokio::runtime::Builder::new_current_thread()
3622        .enable_all()
3623        .build()
3624        .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
3625
3626    rt.block_on(init_libsql_local_metadata_inner(path, set))
3627}
3628
3629#[cfg(feature = "libsql")]
3630async fn init_libsql_local_metadata_inner(path: &str, set: &Migrations) -> Result<(), CliError> {
3631    let db = libsql::Builder::new_local(path)
3632        .build()
3633        .await
3634        .map_err(|e| {
3635            CliError::ConnectionError(format!("Failed to open LibSQL database '{path}': {e}"))
3636        })?;
3637
3638    let conn = db
3639        .connect()
3640        .map_err(|e| CliError::ConnectionError(e.to_string()))?;
3641
3642    ensure_sqlite_tracking_table_libsql(&conn, set).await?;
3643
3644    let applied_names = query_applied_names_libsql(&conn, set).await?;
3645    validate_init_metadata(&applied_names, set)?;
3646
3647    let Some(first) = set.all().first() else {
3648        return Ok(());
3649    };
3650
3651    conn.execute(&set.record_migration_sql(first), ())
3652        .await
3653        .map_err(|e| CliError::MigrationError(e.to_string()))?;
3654
3655    Ok(())
3656}
3657
3658#[cfg(feature = "turso")]
3659fn init_turso_metadata(
3660    url: &str,
3661    auth_token: Option<&str>,
3662    set: &Migrations,
3663) -> Result<(), CliError> {
3664    let rt = tokio::runtime::Builder::new_current_thread()
3665        .enable_all()
3666        .build()
3667        .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
3668
3669    rt.block_on(init_turso_metadata_inner(url, auth_token, set))
3670}
3671
3672#[cfg(feature = "turso")]
3673async fn init_turso_metadata_inner(
3674    url: &str,
3675    auth_token: Option<&str>,
3676    set: &Migrations,
3677) -> Result<(), CliError> {
3678    let builder =
3679        libsql::Builder::new_remote(url.to_string(), auth_token.unwrap_or("").to_string());
3680
3681    let db = builder.build().await.map_err(|e| {
3682        CliError::ConnectionError(format!("Failed to connect to Turso '{url}': {e}"))
3683    })?;
3684
3685    let conn = db
3686        .connect()
3687        .map_err(|e| CliError::ConnectionError(e.to_string()))?;
3688
3689    ensure_sqlite_tracking_table_libsql(&conn, set).await?;
3690
3691    let applied_names = query_applied_names_libsql(&conn, set).await?;
3692    validate_init_metadata(&applied_names, set)?;
3693
3694    let Some(first) = set.all().first() else {
3695        return Ok(());
3696    };
3697
3698    conn.execute(&set.record_migration_sql(first), ())
3699        .await
3700        .map_err(|e| CliError::MigrationError(e.to_string()))?;
3701
3702    Ok(())
3703}
3704
3705#[cfg(feature = "postgres-sync")]
3706fn init_postgres_sync_metadata(creds: &PostgresCreds, set: &Migrations) -> Result<(), CliError> {
3707    let mut client = connect_postgres_sync(creds)?;
3708
3709    if let Some(schema_sql) = set.create_schema_sql() {
3710        client
3711            .execute(&schema_sql, &[])
3712            .map_err(|e| CliError::MigrationError(e.to_string()))?;
3713    }
3714
3715    ensure_postgres_tracking_table_sync(&mut client, set)?;
3716
3717    let rows = client
3718        .query(&set.applied_names_sql(), &[])
3719        .map_err(|error| CliError::MigrationError(error.to_string()))?;
3720    let applied_names = rows
3721        .iter()
3722        .map(|row| row.try_get(0))
3723        .collect::<Result<Vec<String>, _>>()
3724        .map_err(|error| CliError::MigrationError(error.to_string()))?;
3725
3726    validate_init_metadata(&applied_names, set)?;
3727
3728    let Some(first) = set.all().first() else {
3729        return Ok(());
3730    };
3731
3732    client
3733        .execute(&set.record_migration_sql(first), &[])
3734        .map_err(|e| CliError::MigrationError(e.to_string()))?;
3735
3736    Ok(())
3737}
3738
3739#[cfg(feature = "tokio-postgres")]
3740fn init_postgres_async_metadata(creds: &PostgresCreds, set: &Migrations) -> Result<(), CliError> {
3741    let rt = tokio::runtime::Builder::new_current_thread()
3742        .enable_all()
3743        .build()
3744        .map_err(|e| CliError::Other(format!("Failed to create async runtime: {}", e)))?;
3745
3746    rt.block_on(init_postgres_async_inner(creds, set))
3747}
3748
3749#[cfg(feature = "tokio-postgres")]
3750async fn init_postgres_async_inner(
3751    creds: &PostgresCreds,
3752    set: &Migrations,
3753) -> Result<(), CliError> {
3754    let client = connect_postgres_async(creds).await?;
3755
3756    if let Some(schema_sql) = set.create_schema_sql() {
3757        client
3758            .execute(&schema_sql, &[])
3759            .await
3760            .map_err(|e| CliError::MigrationError(e.to_string()))?;
3761    }
3762
3763    ensure_postgres_tracking_table_async(&client, set).await?;
3764
3765    let rows = client
3766        .query(&set.applied_names_sql(), &[])
3767        .await
3768        .map_err(|error| CliError::MigrationError(error.to_string()))?;
3769    let applied_names = rows
3770        .iter()
3771        .map(|row| row.try_get(0))
3772        .collect::<Result<Vec<String>, _>>()
3773        .map_err(|error| CliError::MigrationError(error.to_string()))?;
3774
3775    validate_init_metadata(&applied_names, set)?;
3776
3777    let Some(first) = set.all().first() else {
3778        return Ok(());
3779    };
3780
3781    client
3782        .execute(&set.record_migration_sql(first), &[])
3783        .await
3784        .map_err(|e| CliError::MigrationError(e.to_string()))?;
3785
3786    Ok(())
3787}
3788
3789/// Generate migration SQL from snapshot diff (for introspection)
3790fn generate_introspect_migration(
3791    prev: &Snapshot,
3792    current: &Snapshot,
3793    _breakpoints: bool,
3794) -> Result<Vec<String>, CliError> {
3795    drizzle_migrations::diff(prev, current)
3796        .map(|plan| plan.statements)
3797        .map_err(|error| CliError::MigrationError(error.to_string()))
3798}
3799
3800fn format_migration_sql(sql_statements: &[String], breakpoints: bool) -> String {
3801    if sql_statements.is_empty() {
3802        "-- No tables to create (empty database)\n".to_string()
3803    } else if breakpoints {
3804        sql_statements.join("\n--> statement-breakpoint\n")
3805    } else {
3806        sql_statements.join("\n\n")
3807    }
3808}
3809
3810fn regenerate_schema_from_snapshot(
3811    result: &mut IntrospectResult,
3812    dialect: Dialect,
3813    introspect_casing: Option<IntrospectCasing>,
3814) {
3815    if result.snapshot.dialect() != dialect.to_base() {
3816        return;
3817    }
3818    let generated = crate::codegen::schema_code(
3819        &result.snapshot,
3820        &crate::codegen::SchemaCodeOptions {
3821            casing: introspect_casing,
3822            module_doc: "Schema introspected from filtered database objects",
3823            schema_name: "Schema",
3824        },
3825    )
3826    .expect("initial MySQL introspection already validated lossless code generation");
3827
3828    result.schema_code = generated.code;
3829    result.table_count = generated.table_count;
3830    result.index_count = generated.index_count;
3831    result.view_count = generated.view_count;
3832    result.warnings = generated.warnings;
3833}
3834
3835/// Introspect a database and generate schema code
3836fn introspect_database(connection: &ResolvedConnection) -> Result<IntrospectResult, CliError> {
3837    match connection.dialect {
3838        Dialect::Sqlite | Dialect::Turso => introspect_sqlite_dialect(connection),
3839        Dialect::Postgresql => introspect_postgres_dialect(connection),
3840        Dialect::Mysql => match &connection.credentials {
3841            Credentials::MySQL(creds) => mysql::introspect(connection.driver, creds),
3842            _ => Err(connection_driver_mismatch(connection)),
3843        },
3844    }
3845}
3846
3847/// Introspect SQLite-family databases
3848fn introspect_sqlite_dialect(
3849    connection: &ResolvedConnection,
3850) -> Result<IntrospectResult, CliError> {
3851    match &connection.credentials {
3852        #[cfg(feature = "rusqlite")]
3853        Credentials::Sqlite { path } if connection.driver == Driver::Rusqlite => {
3854            introspect_rusqlite(path)
3855        }
3856
3857        #[cfg(feature = "rusqlite")]
3858        Credentials::Sqlite { .. } => Err(connection_driver_mismatch(connection)),
3859
3860        #[cfg(not(feature = "rusqlite"))]
3861        Credentials::Sqlite { .. } => Err(CliError::MissingDriver {
3862            dialect: "SQLite",
3863            feature: "rusqlite",
3864        }),
3865
3866        #[cfg(any(feature = "libsql", feature = "turso"))]
3867        Credentials::Turso { url, auth_token } => {
3868            if connection.driver == Driver::Libsql {
3869                #[cfg(feature = "libsql")]
3870                {
3871                    introspect_libsql_local(url)
3872                }
3873                #[cfg(not(feature = "libsql"))]
3874                {
3875                    Err(CliError::MissingDriver {
3876                        dialect: "LibSQL (local)",
3877                        feature: "libsql",
3878                    })
3879                }
3880            } else if connection.driver == Driver::Turso {
3881                #[cfg(feature = "turso")]
3882                {
3883                    introspect_turso(url, auth_token.as_deref())
3884                }
3885                #[cfg(not(feature = "turso"))]
3886                {
3887                    let _ = auth_token;
3888                    Err(CliError::MissingDriver {
3889                        dialect: "Turso (remote)",
3890                        feature: "turso",
3891                    })
3892                }
3893            } else {
3894                Err(connection_driver_mismatch(connection))
3895            }
3896        }
3897
3898        #[cfg(all(not(feature = "turso"), not(feature = "libsql")))]
3899        Credentials::Turso { .. } => Err(CliError::MissingDriver {
3900            dialect: "Turso",
3901            feature: "turso or libsql",
3902        }),
3903
3904        _ => Err(CliError::Other(
3905            "SQLite introspection requires sqlite path or turso credentials".into(),
3906        )),
3907    }
3908}
3909
3910/// Introspect `PostgreSQL` databases
3911fn introspect_postgres_dialect(
3912    connection: &ResolvedConnection,
3913) -> Result<IntrospectResult, CliError> {
3914    match &connection.credentials {
3915        Credentials::Postgres(creds) => {
3916            let _ = creds;
3917            match connection.driver {
3918                Driver::PostgresSync => {
3919                    #[cfg(feature = "postgres-sync")]
3920                    {
3921                        introspect_postgres_sync(creds)
3922                    }
3923                    #[cfg(not(feature = "postgres-sync"))]
3924                    {
3925                        Err(CliError::MissingDriver {
3926                            dialect: "PostgreSQL",
3927                            feature: "postgres-sync",
3928                        })
3929                    }
3930                }
3931                Driver::TokioPostgres => {
3932                    #[cfg(feature = "tokio-postgres")]
3933                    {
3934                        introspect_postgres_async(creds)
3935                    }
3936                    #[cfg(not(feature = "tokio-postgres"))]
3937                    {
3938                        Err(CliError::MissingDriver {
3939                            dialect: "PostgreSQL",
3940                            feature: "tokio-postgres",
3941                        })
3942                    }
3943                }
3944                _ => Err(connection_driver_mismatch(connection)),
3945            }
3946        }
3947
3948        _ => Err(CliError::Other(
3949            "PostgreSQL introspection requires postgres credentials".into(),
3950        )),
3951    }
3952}
3953
3954// ============================================================================
3955// SQLite Introspection (rusqlite)
3956// ============================================================================
3957
3958#[cfg(any(feature = "rusqlite", feature = "libsql", feature = "turso"))]
3959struct SqliteRawData {
3960    tables: Vec<(String, Option<String>)>,
3961    raw_columns: Vec<drizzle_migrations::sqlite::introspect::RawColumnInfo>,
3962    all_indexes: Vec<drizzle_migrations::sqlite::introspect::RawIndexInfo>,
3963    all_index_columns: Vec<drizzle_migrations::sqlite::introspect::RawIndexColumn>,
3964    all_fks: Vec<drizzle_migrations::sqlite::introspect::RawForeignKey>,
3965    all_views: Vec<drizzle_migrations::sqlite::introspect::RawViewInfo>,
3966    index_sql: Vec<(String, String)>,
3967}
3968
3969#[cfg(feature = "rusqlite")]
3970impl SqliteRawData {
3971    const fn empty() -> Self {
3972        Self {
3973            tables: Vec::new(),
3974            raw_columns: Vec::new(),
3975            all_indexes: Vec::new(),
3976            all_index_columns: Vec::new(),
3977            all_fks: Vec::new(),
3978            all_views: Vec::new(),
3979            index_sql: Vec::new(),
3980        }
3981    }
3982}
3983
3984#[cfg(feature = "rusqlite")]
3985fn query_rusqlite_tables_and_columns(
3986    conn: &rusqlite::Connection,
3987    raw: &mut SqliteRawData,
3988) -> Result<(), CliError> {
3989    use drizzle_migrations::sqlite::introspect::{RawColumnInfo, queries};
3990
3991    let mut tables_stmt = conn
3992        .prepare(queries::TABLES_QUERY)
3993        .map_err(|e| CliError::Other(format!("Failed to prepare tables query: {e}")))?;
3994
3995    raw.tables = tables_stmt
3996        .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
3997        .map_err(|e| CliError::Other(e.to_string()))?
3998        .collect::<Result<Vec<_>, _>>()
3999        .map_err(|e| CliError::Other(e.to_string()))?;
4000
4001    let mut columns_stmt = conn
4002        .prepare(queries::COLUMNS_QUERY)
4003        .map_err(|e| CliError::Other(format!("Failed to prepare columns query: {e}")))?;
4004
4005    raw.raw_columns = columns_stmt
4006        .query_map([], |row| {
4007            Ok(RawColumnInfo {
4008                table: row.get(0)?,
4009                cid: row.get(1)?,
4010                name: row.get(2)?,
4011                column_type: row.get(3)?,
4012                not_null: row.get(4)?,
4013                default_value: row.get(5)?,
4014                pk: row.get(6)?,
4015                hidden: row.get(7)?,
4016                sql: row.get(8)?,
4017            })
4018        })
4019        .map_err(|e| CliError::Other(e.to_string()))?
4020        .collect::<Result<Vec<_>, _>>()
4021        .map_err(|e| CliError::Other(e.to_string()))?;
4022
4023    Ok(())
4024}
4025
4026#[cfg(feature = "rusqlite")]
4027fn query_rusqlite_indexes_and_foreign_keys(
4028    conn: &rusqlite::Connection,
4029    raw: &mut SqliteRawData,
4030) -> Result<(), CliError> {
4031    use drizzle_migrations::sqlite::introspect::{
4032        RawForeignKey, RawIndexColumn, RawIndexInfo, queries,
4033    };
4034
4035    let mut indexes_stmt = conn
4036        .prepare(queries::INDEXES_QUERY)
4037        .map_err(|e| CliError::Other(e.to_string()))?;
4038    raw.all_indexes = indexes_stmt
4039        .query_map([], |row| {
4040            Ok(RawIndexInfo {
4041                table: row.get(0)?,
4042                name: row.get(1)?,
4043                unique: row.get::<_, i32>(2)? != 0,
4044                origin: row.get(3)?,
4045                partial: row.get::<_, i32>(4)? != 0,
4046            })
4047        })
4048        .map_err(|e| CliError::Other(e.to_string()))?
4049        .collect::<Result<Vec<_>, _>>()
4050        .map_err(|e| CliError::Other(e.to_string()))?;
4051
4052    let mut columns_stmt = conn
4053        .prepare(queries::INDEX_COLUMNS_QUERY)
4054        .map_err(|e| CliError::Other(e.to_string()))?;
4055    raw.all_index_columns = columns_stmt
4056        .query_map([], |row| {
4057            Ok(RawIndexColumn {
4058                index_name: row.get(0)?,
4059                seqno: row.get(1)?,
4060                cid: row.get(2)?,
4061                name: row.get(3)?,
4062                desc: row.get::<_, i32>(4)? != 0,
4063                coll: row.get(5)?,
4064                key: row.get::<_, i32>(6)? != 0,
4065            })
4066        })
4067        .map_err(|e| CliError::Other(e.to_string()))?
4068        .collect::<Result<Vec<_>, _>>()
4069        .map_err(|e| CliError::Other(e.to_string()))?;
4070
4071    let mut foreign_keys_stmt = conn
4072        .prepare(queries::FOREIGN_KEYS_QUERY)
4073        .map_err(|e| CliError::Other(e.to_string()))?;
4074    raw.all_fks = foreign_keys_stmt
4075        .query_map([], |row| {
4076            Ok(RawForeignKey {
4077                table: row.get(0)?,
4078                id: row.get(1)?,
4079                seq: row.get(2)?,
4080                to_table: row.get(3)?,
4081                from_column: row.get(4)?,
4082                to_column: row.get(5)?,
4083                on_update: row.get(6)?,
4084                on_delete: row.get(7)?,
4085                r#match: row.get(8)?,
4086            })
4087        })
4088        .map_err(|e| CliError::Other(e.to_string()))?
4089        .collect::<Result<Vec<_>, _>>()
4090        .map_err(|e| CliError::Other(e.to_string()))?;
4091
4092    Ok(())
4093}
4094
4095#[cfg(feature = "rusqlite")]
4096fn query_rusqlite_views_and_view_columns(
4097    conn: &rusqlite::Connection,
4098    raw: &mut SqliteRawData,
4099) -> Result<(), CliError> {
4100    use drizzle_migrations::sqlite::introspect::{RawColumnInfo, RawViewInfo, queries};
4101
4102    let mut views_stmt = conn
4103        .prepare(queries::VIEWS_QUERY)
4104        .map_err(|e| CliError::Other(e.to_string()))?;
4105    let view_iter = views_stmt
4106        .query_map([], |row| {
4107            Ok(RawViewInfo {
4108                name: row.get(0)?,
4109                sql: row.get(1)?,
4110            })
4111        })
4112        .map_err(|e| CliError::Other(e.to_string()))?;
4113    raw.all_views.extend(
4114        view_iter
4115            .collect::<Result<Vec<_>, _>>()
4116            .map_err(|e| CliError::Other(e.to_string()))?,
4117    );
4118
4119    let mut view_cols_stmt = conn
4120        .prepare(queries::VIEW_COLUMNS_QUERY)
4121        .map_err(|e| CliError::Other(e.to_string()))?;
4122    let col_iter = view_cols_stmt
4123        .query_map([], |row| {
4124            Ok(RawColumnInfo {
4125                table: row.get(0)?,
4126                cid: row.get(1)?,
4127                name: row.get(2)?,
4128                column_type: row.get(3)?,
4129                not_null: row.get::<_, i32>(4)? != 0,
4130                default_value: row.get(5)?,
4131                pk: row.get(6)?,
4132                hidden: row.get(7)?,
4133                sql: row.get(8)?,
4134            })
4135        })
4136        .map_err(|e| CliError::Other(e.to_string()))?;
4137    raw.raw_columns.extend(
4138        col_iter
4139            .collect::<Result<Vec<_>, _>>()
4140            .map_err(|e| CliError::Other(e.to_string()))?,
4141    );
4142    Ok(())
4143}
4144
4145#[cfg(feature = "rusqlite")]
4146fn query_rusqlite_raw(conn: &rusqlite::Connection) -> Result<SqliteRawData, CliError> {
4147    use drizzle_migrations::sqlite::introspect::queries;
4148
4149    let mut raw = SqliteRawData::empty();
4150    query_rusqlite_tables_and_columns(conn, &mut raw)?;
4151    query_rusqlite_indexes_and_foreign_keys(conn, &mut raw)?;
4152    query_rusqlite_views_and_view_columns(conn, &mut raw)?;
4153
4154    let mut index_sql_stmt = conn
4155        .prepare(queries::INDEX_SQL_QUERY)
4156        .map_err(|e| CliError::Other(format!("Failed to prepare index sql query: {e}")))?;
4157    raw.index_sql = index_sql_stmt
4158        .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
4159        .map_err(|e| CliError::Other(e.to_string()))?
4160        .collect::<Result<Vec<_>, _>>()
4161        .map_err(|e| CliError::Other(e.to_string()))?;
4162
4163    Ok(raw)
4164}
4165
4166#[cfg(any(feature = "rusqlite", feature = "libsql", feature = "turso"))]
4167fn build_sqlite_ddl(raw: SqliteRawData) -> drizzle_migrations::sqlite::SQLiteDDL {
4168    drizzle_migrations::sqlite::introspect::assemble_ddl(
4169        drizzle_migrations::sqlite::introspect::RawIntrospection {
4170            tables: raw.tables,
4171            columns: raw.raw_columns,
4172            indexes: raw.all_indexes,
4173            index_columns: raw.all_index_columns,
4174            foreign_keys: raw.all_fks,
4175            views: raw.all_views,
4176            index_sql: raw.index_sql,
4177        },
4178    )
4179}
4180
4181#[cfg(feature = "rusqlite")]
4182fn introspect_rusqlite(path: &str) -> Result<IntrospectResult, CliError> {
4183    use drizzle_migrations::sqlite::codegen::{CodegenOptions, FieldCasing, generate_rust_schema};
4184
4185    let conn = rusqlite::Connection::open(path).map_err(|e| {
4186        CliError::ConnectionError(format!("Failed to open SQLite database '{path}': {e}"))
4187    })?;
4188
4189    let raw = query_rusqlite_raw(&conn)?;
4190    let ddl = build_sqlite_ddl(raw);
4191
4192    let options = CodegenOptions {
4193        module_doc: Some(format!("Schema introspected from {path}")),
4194        include_schema: true,
4195        schema_name: "Schema".to_string(),
4196        use_pub: true,
4197        field_casing: FieldCasing::default(),
4198    };
4199
4200    let generated = generate_rust_schema(&ddl, &options);
4201
4202    let mut sqlite_snapshot = drizzle_migrations::sqlite::SQLiteSnapshot::new();
4203    for entity in ddl.to_entities() {
4204        sqlite_snapshot.add_entity(entity);
4205    }
4206    let snapshot = Snapshot::Sqlite(sqlite_snapshot);
4207
4208    Ok(IntrospectResult {
4209        schema_code: generated.code,
4210        table_count: generated.tables.len(),
4211        index_count: generated.indexes.len(),
4212        view_count: ddl.views.len(),
4213        warnings: generated.warnings,
4214        mysql_catalog_defaults: None,
4215        snapshot,
4216        snapshot_path: std::path::PathBuf::new(),
4217    })
4218}
4219
4220// ============================================================================
4221// LibSQL Introspection (local)
4222// ============================================================================
4223
4224#[cfg(feature = "libsql")]
4225fn introspect_libsql_local(path: &str) -> Result<IntrospectResult, CliError> {
4226    let rt = tokio::runtime::Builder::new_current_thread()
4227        .enable_all()
4228        .build()
4229        .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
4230
4231    rt.block_on(introspect_libsql_inner(path, None))
4232}
4233
4234#[cfg(any(feature = "libsql", feature = "turso"))]
4235async fn query_libsql_tables_and_columns(
4236    conn: &libsql::Connection,
4237) -> Result<
4238    (
4239        Vec<(String, Option<String>)>,
4240        Vec<drizzle_migrations::sqlite::introspect::RawColumnInfo>,
4241    ),
4242    CliError,
4243> {
4244    use drizzle_migrations::sqlite::introspect::{RawColumnInfo, queries};
4245
4246    let mut tables_rows = conn
4247        .query(queries::TABLES_QUERY, ())
4248        .await
4249        .map_err(|e| CliError::Other(format!("Failed to query tables: {e}")))?;
4250
4251    let mut tables: Vec<(String, Option<String>)> = Vec::new();
4252    while let Some(row) = tables_rows
4253        .next()
4254        .await
4255        .map_err(|e| CliError::Other(e.to_string()))?
4256    {
4257        let name: String = row.get(0).map_err(|e| CliError::Other(e.to_string()))?;
4258        let sql: Option<String> = row.get(1).ok();
4259        tables.push((name, sql));
4260    }
4261
4262    let mut columns_rows = conn
4263        .query(queries::COLUMNS_QUERY, ())
4264        .await
4265        .map_err(|e| CliError::Other(format!("Failed to query columns: {e}")))?;
4266
4267    let mut raw_columns: Vec<RawColumnInfo> = Vec::new();
4268    while let Some(row) = columns_rows
4269        .next()
4270        .await
4271        .map_err(|e| CliError::Other(e.to_string()))?
4272    {
4273        raw_columns.push(RawColumnInfo {
4274            table: row.get(0).map_err(|e| CliError::Other(e.to_string()))?,
4275            cid: row.get(1).map_err(|e| CliError::Other(e.to_string()))?,
4276            name: row.get(2).map_err(|e| CliError::Other(e.to_string()))?,
4277            column_type: row.get(3).map_err(|e| CliError::Other(e.to_string()))?,
4278            not_null: row
4279                .get::<i32>(4)
4280                .map_err(|e| CliError::Other(e.to_string()))?
4281                != 0,
4282            default_value: row.get(5).ok(),
4283            pk: row.get(6).map_err(|e| CliError::Other(e.to_string()))?,
4284            hidden: row.get(7).map_err(|e| CliError::Other(e.to_string()))?,
4285            sql: row.get(8).ok(),
4286        });
4287    }
4288
4289    Ok((tables, raw_columns))
4290}
4291
4292#[cfg(any(feature = "libsql", feature = "turso"))]
4293async fn query_libsql_indexes_and_foreign_keys(
4294    conn: &libsql::Connection,
4295) -> Result<
4296    (
4297        Vec<drizzle_migrations::sqlite::introspect::RawIndexInfo>,
4298        Vec<drizzle_migrations::sqlite::introspect::RawIndexColumn>,
4299        Vec<drizzle_migrations::sqlite::introspect::RawForeignKey>,
4300    ),
4301    CliError,
4302> {
4303    use drizzle_migrations::sqlite::introspect::{
4304        RawForeignKey, RawIndexColumn, RawIndexInfo, queries,
4305    };
4306
4307    let mut all_indexes = Vec::<RawIndexInfo>::new();
4308    let mut index_rows = conn
4309        .query(queries::INDEXES_QUERY, ())
4310        .await
4311        .map_err(|e| CliError::Other(e.to_string()))?;
4312    while let Some(row) = index_rows
4313        .next()
4314        .await
4315        .map_err(|e| CliError::Other(e.to_string()))?
4316    {
4317        all_indexes.push(RawIndexInfo {
4318            table: row.get(0).map_err(|e| CliError::Other(e.to_string()))?,
4319            name: row.get(1).map_err(|e| CliError::Other(e.to_string()))?,
4320            unique: row
4321                .get::<i32>(2)
4322                .map_err(|e| CliError::Other(e.to_string()))?
4323                != 0,
4324            origin: row.get(3).map_err(|e| CliError::Other(e.to_string()))?,
4325            partial: row
4326                .get::<i32>(4)
4327                .map_err(|e| CliError::Other(e.to_string()))?
4328                != 0,
4329        });
4330    }
4331
4332    let mut all_index_columns = Vec::<RawIndexColumn>::new();
4333    let mut column_rows = conn
4334        .query(queries::INDEX_COLUMNS_QUERY, ())
4335        .await
4336        .map_err(|e| CliError::Other(e.to_string()))?;
4337    while let Some(row) = column_rows
4338        .next()
4339        .await
4340        .map_err(|e| CliError::Other(e.to_string()))?
4341    {
4342        all_index_columns.push(RawIndexColumn {
4343            index_name: row.get(0).map_err(|e| CliError::Other(e.to_string()))?,
4344            seqno: row.get(1).map_err(|e| CliError::Other(e.to_string()))?,
4345            cid: row.get(2).map_err(|e| CliError::Other(e.to_string()))?,
4346            name: row.get(3).ok(),
4347            desc: row
4348                .get::<i32>(4)
4349                .map_err(|e| CliError::Other(e.to_string()))?
4350                != 0,
4351            coll: row.get(5).map_err(|e| CliError::Other(e.to_string()))?,
4352            key: row
4353                .get::<i32>(6)
4354                .map_err(|e| CliError::Other(e.to_string()))?
4355                != 0,
4356        });
4357    }
4358
4359    let mut all_fks = Vec::<RawForeignKey>::new();
4360    let mut foreign_key_rows = conn
4361        .query(queries::FOREIGN_KEYS_QUERY, ())
4362        .await
4363        .map_err(|e| CliError::Other(e.to_string()))?;
4364    while let Some(row) = foreign_key_rows
4365        .next()
4366        .await
4367        .map_err(|e| CliError::Other(e.to_string()))?
4368    {
4369        all_fks.push(RawForeignKey {
4370            table: row.get(0).map_err(|e| CliError::Other(e.to_string()))?,
4371            id: row.get(1).map_err(|e| CliError::Other(e.to_string()))?,
4372            seq: row.get(2).map_err(|e| CliError::Other(e.to_string()))?,
4373            to_table: row.get(3).map_err(|e| CliError::Other(e.to_string()))?,
4374            from_column: row.get(4).map_err(|e| CliError::Other(e.to_string()))?,
4375            to_column: row.get(5).map_err(|e| CliError::Other(e.to_string()))?,
4376            on_update: row.get(6).map_err(|e| CliError::Other(e.to_string()))?,
4377            on_delete: row.get(7).map_err(|e| CliError::Other(e.to_string()))?,
4378            r#match: row.get(8).map_err(|e| CliError::Other(e.to_string()))?,
4379        });
4380    }
4381
4382    Ok((all_indexes, all_index_columns, all_fks))
4383}
4384
4385#[cfg(any(feature = "libsql", feature = "turso"))]
4386async fn query_libsql_views_and_view_columns(
4387    conn: &libsql::Connection,
4388    raw_columns: &mut Vec<drizzle_migrations::sqlite::introspect::RawColumnInfo>,
4389) -> Result<Vec<drizzle_migrations::sqlite::introspect::RawViewInfo>, CliError> {
4390    use drizzle_migrations::sqlite::introspect::{RawColumnInfo, RawViewInfo, queries};
4391
4392    let mut all_views: Vec<RawViewInfo> = Vec::new();
4393    let mut views_rows = conn
4394        .query(queries::VIEWS_QUERY, ())
4395        .await
4396        .map_err(|e| CliError::Other(e.to_string()))?;
4397    while let Some(row) = views_rows
4398        .next()
4399        .await
4400        .map_err(|e| CliError::Other(e.to_string()))?
4401    {
4402        let name: String = row.get(0).map_err(|e| CliError::Other(e.to_string()))?;
4403        let sql: String = row.get(1).map_err(|e| CliError::Other(e.to_string()))?;
4404        all_views.push(RawViewInfo { name, sql });
4405    }
4406
4407    let mut view_cols_rows = conn
4408        .query(queries::VIEW_COLUMNS_QUERY, ())
4409        .await
4410        .map_err(|e| CliError::Other(e.to_string()))?;
4411    while let Some(row) = view_cols_rows
4412        .next()
4413        .await
4414        .map_err(|e| CliError::Other(e.to_string()))?
4415    {
4416        raw_columns.push(RawColumnInfo {
4417            table: row.get(0).map_err(|e| CliError::Other(e.to_string()))?,
4418            cid: row.get(1).map_err(|e| CliError::Other(e.to_string()))?,
4419            name: row.get(2).map_err(|e| CliError::Other(e.to_string()))?,
4420            column_type: row.get(3).map_err(|e| CliError::Other(e.to_string()))?,
4421            not_null: row
4422                .get::<i32>(4)
4423                .map_err(|e| CliError::Other(e.to_string()))?
4424                != 0,
4425            default_value: row.get(5).ok(),
4426            pk: row.get(6).map_err(|e| CliError::Other(e.to_string()))?,
4427            hidden: row.get(7).map_err(|e| CliError::Other(e.to_string()))?,
4428            sql: row.get(8).ok(),
4429        });
4430    }
4431    Ok(all_views)
4432}
4433
4434#[cfg(any(feature = "libsql", feature = "turso"))]
4435async fn query_libsql_raw(conn: &libsql::Connection) -> Result<SqliteRawData, CliError> {
4436    use drizzle_migrations::sqlite::introspect::queries;
4437
4438    let (tables, mut raw_columns) = query_libsql_tables_and_columns(conn).await?;
4439    let (all_indexes, all_index_columns, all_fks) =
4440        query_libsql_indexes_and_foreign_keys(conn).await?;
4441    let all_views = query_libsql_views_and_view_columns(conn, &mut raw_columns).await?;
4442
4443    let mut index_sql = Vec::new();
4444    let mut rows = conn
4445        .query(queries::INDEX_SQL_QUERY, ())
4446        .await
4447        .map_err(|e| CliError::Other(e.to_string()))?;
4448    while let Some(row) = rows
4449        .next()
4450        .await
4451        .map_err(|e| CliError::Other(e.to_string()))?
4452    {
4453        let name: String = row.get(0).map_err(|e| CliError::Other(e.to_string()))?;
4454        let sql: String = row.get(1).map_err(|e| CliError::Other(e.to_string()))?;
4455        index_sql.push((name, sql));
4456    }
4457
4458    Ok(SqliteRawData {
4459        tables,
4460        raw_columns,
4461        all_indexes,
4462        all_index_columns,
4463        all_fks,
4464        all_views,
4465        index_sql,
4466    })
4467}
4468
4469#[cfg(feature = "libsql")]
4470async fn introspect_libsql_inner(
4471    path: &str,
4472    _auth_token: Option<&str>,
4473) -> Result<IntrospectResult, CliError> {
4474    use drizzle_migrations::sqlite::codegen::{CodegenOptions, FieldCasing, generate_rust_schema};
4475
4476    let db = libsql::Builder::new_local(path)
4477        .build()
4478        .await
4479        .map_err(|e| {
4480            CliError::ConnectionError(format!("Failed to open LibSQL database '{path}': {e}"))
4481        })?;
4482
4483    let conn = db
4484        .connect()
4485        .map_err(|e| CliError::ConnectionError(e.to_string()))?;
4486
4487    let raw = query_libsql_raw(&conn).await?;
4488    let ddl = build_sqlite_ddl(raw);
4489
4490    let options = CodegenOptions {
4491        module_doc: Some(format!("Schema introspected from {path}")),
4492        include_schema: true,
4493        schema_name: "Schema".to_string(),
4494        use_pub: true,
4495        field_casing: FieldCasing::default(),
4496    };
4497
4498    let generated = generate_rust_schema(&ddl, &options);
4499
4500    let mut sqlite_snapshot = drizzle_migrations::sqlite::SQLiteSnapshot::new();
4501    for entity in ddl.to_entities() {
4502        sqlite_snapshot.add_entity(entity);
4503    }
4504    let snapshot = Snapshot::Sqlite(sqlite_snapshot);
4505
4506    Ok(IntrospectResult {
4507        schema_code: generated.code,
4508        table_count: generated.tables.len(),
4509        index_count: generated.indexes.len(),
4510        view_count: ddl.views.len(),
4511        warnings: generated.warnings,
4512        mysql_catalog_defaults: None,
4513        snapshot,
4514        snapshot_path: std::path::PathBuf::new(),
4515    })
4516}
4517
4518// ============================================================================
4519// Turso Introspection (remote)
4520// ============================================================================
4521
4522#[cfg(feature = "turso")]
4523fn introspect_turso(url: &str, auth_token: Option<&str>) -> Result<IntrospectResult, CliError> {
4524    let rt = tokio::runtime::Builder::new_current_thread()
4525        .enable_all()
4526        .build()
4527        .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
4528
4529    rt.block_on(introspect_turso_inner(url, auth_token))
4530}
4531
4532#[cfg(feature = "turso")]
4533async fn introspect_turso_inner(
4534    url: &str,
4535    auth_token: Option<&str>,
4536) -> Result<IntrospectResult, CliError> {
4537    use drizzle_migrations::sqlite::codegen::{CodegenOptions, FieldCasing, generate_rust_schema};
4538
4539    let builder =
4540        libsql::Builder::new_remote(url.to_string(), auth_token.unwrap_or("").to_string());
4541
4542    let db = builder.build().await.map_err(|e| {
4543        CliError::ConnectionError(format!("Failed to connect to Turso '{url}': {e}"))
4544    })?;
4545
4546    let conn = db
4547        .connect()
4548        .map_err(|e| CliError::ConnectionError(e.to_string()))?;
4549
4550    let raw = query_libsql_raw(&conn).await?;
4551    let ddl = build_sqlite_ddl(raw);
4552
4553    let options = CodegenOptions {
4554        module_doc: Some(format!("Schema introspected from Turso: {url}")),
4555        include_schema: true,
4556        schema_name: "Schema".to_string(),
4557        use_pub: true,
4558        field_casing: FieldCasing::default(),
4559    };
4560
4561    let generated = generate_rust_schema(&ddl, &options);
4562
4563    let mut sqlite_snapshot = drizzle_migrations::sqlite::SQLiteSnapshot::new();
4564    for entity in ddl.to_entities() {
4565        sqlite_snapshot.add_entity(entity);
4566    }
4567    let snapshot = Snapshot::Sqlite(sqlite_snapshot);
4568
4569    Ok(IntrospectResult {
4570        schema_code: generated.code,
4571        table_count: generated.tables.len(),
4572        index_count: generated.indexes.len(),
4573        view_count: ddl.views.len(),
4574        warnings: generated.warnings,
4575        mysql_catalog_defaults: None,
4576        snapshot,
4577        snapshot_path: std::path::PathBuf::new(),
4578    })
4579}
4580
4581// ============================================================================
4582// PostgreSQL Introspection
4583// ============================================================================
4584
4585#[cfg(feature = "postgres-sync")]
4586fn introspect_postgres_sync(creds: &PostgresCreds) -> Result<IntrospectResult, CliError> {
4587    let mut client = connect_postgres_sync(creds)?;
4588
4589    let raw = query_postgres_sync_raw(&mut client)?;
4590    let ddl = build_postgres_ddl(raw);
4591
4592    Ok(finalize_postgres_introspection(
4593        &ddl,
4594        "configured PostgreSQL database",
4595    ))
4596}
4597
4598#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
4599impl PostgresRawData {
4600    const fn empty() -> Self {
4601        Self {
4602            schemas: Vec::new(),
4603            tables: Vec::new(),
4604            columns: Vec::new(),
4605            enums: Vec::new(),
4606            sequences: Vec::new(),
4607            views: Vec::new(),
4608            indexes: Vec::new(),
4609            foreign_keys: Vec::new(),
4610            primary_keys: Vec::new(),
4611            uniques: Vec::new(),
4612            checks: Vec::new(),
4613            roles: Vec::new(),
4614            policies: Vec::new(),
4615        }
4616    }
4617}
4618
4619#[cfg(feature = "postgres-sync")]
4620fn query_postgres_sync_raw(client: &mut postgres::Client) -> Result<PostgresRawData, CliError> {
4621    let mut raw = PostgresRawData::empty();
4622    query_pg_sync_core(client, &mut raw)?;
4623    query_pg_sync_codegen_meta(client, &mut raw)?;
4624    query_pg_sync_constraints(client, &mut raw)?;
4625    query_pg_sync_security(client, &mut raw)?;
4626    Ok(raw)
4627}
4628
4629#[cfg(feature = "postgres-sync")]
4630fn query_pg_sync_core(
4631    client: &mut postgres::Client,
4632    raw: &mut PostgresRawData,
4633) -> Result<(), CliError> {
4634    use drizzle_migrations::postgres::introspect::{RawColumnInfo, RawTableInfo, queries};
4635
4636    raw.schemas = client
4637        .query(queries::SCHEMAS_QUERY, &[])
4638        .map_err(|e| CliError::Other(format!("Failed to query schemas: {e}")))?
4639        .into_iter()
4640        .map(|row| RawSchemaInfo {
4641            name: row.get::<_, String>(0),
4642        })
4643        .collect();
4644
4645    raw.tables = client
4646        .query(queries::TABLES_QUERY, &[])
4647        .map_err(|e| CliError::Other(format!("Failed to query tables: {e}")))?
4648        .into_iter()
4649        .map(|row| RawTableInfo {
4650            schema: row.get::<_, String>(0),
4651            name: row.get::<_, String>(1),
4652            is_rls_enabled: row.get::<_, bool>(2),
4653            is_unlogged: row.get::<_, bool>(3),
4654            is_temporary: row.get::<_, bool>(4),
4655            tablespace: row.get::<_, Option<String>>(5),
4656            comment: row.get::<_, Option<String>>(6),
4657        })
4658        .collect();
4659
4660    raw.columns = client
4661        .query(queries::COLUMNS_QUERY, &[])
4662        .map_err(|e| CliError::Other(format!("Failed to query columns: {e}")))?
4663        .into_iter()
4664        .map(|row| RawColumnInfo {
4665            schema: row.get::<_, String>(0),
4666            table: row.get::<_, String>(1),
4667            name: row.get::<_, String>(2),
4668            column_type: row.get::<_, String>(3),
4669            type_schema: row.get::<_, Option<String>>(4),
4670            not_null: row.get::<_, bool>(5),
4671            default_value: row.get::<_, Option<String>>(6),
4672            is_identity: row.get::<_, bool>(7),
4673            identity_type: row.get::<_, Option<String>>(8),
4674            is_generated: row.get::<_, bool>(9),
4675            generated_expression: row.get::<_, Option<String>>(10),
4676            generated_stored: row.get::<_, bool>(11),
4677            dimensions: row.get::<_, Option<i32>>(12),
4678            comment: row.get::<_, Option<String>>(13),
4679            ordinal_position: row.get::<_, i32>(14),
4680        })
4681        .collect();
4682
4683    Ok(())
4684}
4685
4686#[cfg(feature = "postgres-sync")]
4687fn query_pg_sync_codegen_meta(
4688    client: &mut postgres::Client,
4689    raw: &mut PostgresRawData,
4690) -> Result<(), CliError> {
4691    use drizzle_migrations::postgres::introspect::{
4692        RawEnumInfo, RawSequenceInfo, RawViewInfo, queries,
4693    };
4694
4695    raw.enums = client
4696        .query(queries::ENUMS_QUERY, &[])
4697        .map_err(|e| CliError::Other(format!("Failed to query enums: {e}")))?
4698        .into_iter()
4699        .map(|row| RawEnumInfo {
4700            schema: row.get::<_, String>(0),
4701            name: row.get::<_, String>(1),
4702            values: row.get::<_, Vec<String>>(2),
4703        })
4704        .collect();
4705
4706    raw.sequences = client
4707        .query(queries::SEQUENCES_QUERY, &[])
4708        .map_err(|e| CliError::Other(format!("Failed to query sequences: {e}")))?
4709        .into_iter()
4710        .map(|row| RawSequenceInfo {
4711            schema: row.get::<_, String>(0),
4712            name: row.get::<_, String>(1),
4713            data_type: row.get::<_, Option<String>>(2),
4714            start_value: row.get::<_, Option<String>>(3),
4715            min_value: row.get::<_, Option<String>>(4),
4716            max_value: row.get::<_, Option<String>>(5),
4717            increment: row.get::<_, Option<String>>(6),
4718            cycle: row.get::<_, Option<bool>>(7),
4719            cache_value: row.get::<_, Option<String>>(8),
4720            owned_by: row.get::<_, Option<String>>(9),
4721        })
4722        .collect();
4723
4724    let view_schema_filters: Option<Vec<String>> = None;
4725    raw.views = client
4726        .query(queries::VIEWS_QUERY, &[&view_schema_filters])
4727        .map_err(|e| CliError::Other(format!("Failed to query views: {e}")))?
4728        .into_iter()
4729        .map(|row| RawViewInfo {
4730            schema: row.get::<_, String>(0),
4731            name: row.get::<_, String>(1),
4732            definition: row.get::<_, String>(2),
4733            is_materialized: row.get::<_, bool>(3),
4734        })
4735        .collect();
4736
4737    Ok(())
4738}
4739
4740#[cfg(feature = "postgres-sync")]
4741fn query_pg_sync_constraints(
4742    client: &mut postgres::Client,
4743    raw: &mut PostgresRawData,
4744) -> Result<(), CliError> {
4745    use drizzle_migrations::postgres::introspect::{
4746        RawCheckInfo, RawForeignKeyInfo, RawIndexInfo, RawPrimaryKeyInfo, RawUniqueInfo,
4747        action_code_to_string, parse_index_columns, queries,
4748    };
4749
4750    raw.indexes = client
4751        .query(queries::INDEXES_QUERY, &[])
4752        .map_err(|e| CliError::Other(format!("Failed to query indexes: {e}")))?
4753        .into_iter()
4754        .map(|row| {
4755            let cols: Vec<String> = row.get(6);
4756            RawIndexInfo {
4757                schema: row.get::<_, String>(0),
4758                table: row.get::<_, String>(1),
4759                name: row.get::<_, String>(2),
4760                is_unique: row.get::<_, bool>(3),
4761                is_primary: row.get::<_, bool>(4),
4762                method: row.get::<_, String>(5),
4763                columns: parse_index_columns(cols),
4764                where_clause: row.get::<_, Option<String>>(7),
4765                concurrent: false,
4766            }
4767        })
4768        .collect();
4769
4770    raw.foreign_keys = client
4771        .query(queries::FOREIGN_KEYS_QUERY, &[])
4772        .map_err(|e| CliError::Other(format!("Failed to query foreign keys: {e}")))?
4773        .into_iter()
4774        .map(|row| RawForeignKeyInfo {
4775            schema: row.get::<_, String>(0),
4776            table: row.get::<_, String>(1),
4777            name: row.get::<_, String>(2),
4778            columns: row.get::<_, Vec<String>>(3),
4779            schema_to: row.get::<_, String>(4),
4780            table_to: row.get::<_, String>(5),
4781            columns_to: row.get::<_, Vec<String>>(6),
4782            on_update: action_code_to_string(&row.get::<_, String>(7)),
4783            on_delete: action_code_to_string(&row.get::<_, String>(8)),
4784            deferrable: row.get::<_, bool>(9),
4785            initially_deferred: row.get::<_, bool>(10),
4786        })
4787        .collect();
4788
4789    raw.primary_keys = client
4790        .query(queries::PRIMARY_KEYS_QUERY, &[])
4791        .map_err(|e| CliError::Other(format!("Failed to query primary keys: {e}")))?
4792        .into_iter()
4793        .map(|row| RawPrimaryKeyInfo {
4794            schema: row.get::<_, String>(0),
4795            table: row.get::<_, String>(1),
4796            name: row.get::<_, String>(2),
4797            columns: row.get::<_, Vec<String>>(3),
4798        })
4799        .collect();
4800
4801    raw.uniques = client
4802        .query(queries::UNIQUES_QUERY, &[])
4803        .map_err(|e| CliError::Other(format!("Failed to query unique constraints: {e}")))?
4804        .into_iter()
4805        .map(|row| RawUniqueInfo {
4806            schema: row.get::<_, String>(0),
4807            table: row.get::<_, String>(1),
4808            name: row.get::<_, String>(2),
4809            columns: row.get::<_, Vec<String>>(3),
4810            nulls_not_distinct: row.get::<_, bool>(4),
4811            deferrable: row.get::<_, bool>(5),
4812            initially_deferred: row.get::<_, bool>(6),
4813        })
4814        .collect();
4815
4816    raw.checks = client
4817        .query(queries::CHECKS_QUERY, &[])
4818        .map_err(|e| CliError::Other(format!("Failed to query check constraints: {e}")))?
4819        .into_iter()
4820        .map(|row| RawCheckInfo {
4821            schema: row.get::<_, String>(0),
4822            table: row.get::<_, String>(1),
4823            name: row.get::<_, String>(2),
4824            expression: row.get::<_, String>(3),
4825        })
4826        .collect();
4827
4828    Ok(())
4829}
4830
4831#[cfg(feature = "postgres-sync")]
4832fn query_pg_sync_security(
4833    client: &mut postgres::Client,
4834    raw: &mut PostgresRawData,
4835) -> Result<(), CliError> {
4836    use drizzle_migrations::postgres::introspect::{RawPolicyInfo, RawRoleInfo, queries};
4837
4838    raw.roles = client
4839        .query(queries::ROLES_QUERY, &[])
4840        .map_err(|e| CliError::Other(format!("Failed to query roles: {e}")))?
4841        .into_iter()
4842        .map(|row| RawRoleInfo {
4843            name: row.get::<_, String>(0),
4844            create_db: row.get::<_, bool>(1),
4845            create_role: row.get::<_, bool>(2),
4846            inherit: row.get::<_, bool>(3),
4847        })
4848        .collect();
4849
4850    raw.policies = client
4851        .query(queries::POLICIES_QUERY, &[])
4852        .map_err(|e| CliError::Other(format!("Failed to query policies: {e}")))?
4853        .into_iter()
4854        .map(|row| RawPolicyInfo {
4855            schema: row.get::<_, String>(0),
4856            table: row.get::<_, String>(1),
4857            name: row.get::<_, String>(2),
4858            as_clause: row.get::<_, String>(3),
4859            for_clause: row.get::<_, String>(4),
4860            to: row.get::<_, Vec<String>>(5),
4861            using: row.get::<_, Option<String>>(6),
4862            with_check: row.get::<_, Option<String>>(7),
4863        })
4864        .collect();
4865
4866    Ok(())
4867}
4868
4869#[cfg(feature = "tokio-postgres")]
4870fn introspect_postgres_async(creds: &PostgresCreds) -> Result<IntrospectResult, CliError> {
4871    let rt = tokio::runtime::Builder::new_current_thread()
4872        .enable_all()
4873        .build()
4874        .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
4875
4876    rt.block_on(introspect_postgres_async_inner(creds))
4877}
4878
4879#[cfg(feature = "tokio-postgres")]
4880async fn introspect_postgres_async_inner(
4881    creds: &PostgresCreds,
4882) -> Result<IntrospectResult, CliError> {
4883    let client = connect_postgres_async(creds).await?;
4884
4885    let raw = query_postgres_async_raw(&client).await?;
4886    let ddl = build_postgres_ddl(raw);
4887
4888    Ok(finalize_postgres_introspection(
4889        &ddl,
4890        "configured PostgreSQL database",
4891    ))
4892}
4893
4894#[cfg(feature = "tokio-postgres")]
4895async fn query_postgres_async_raw(
4896    client: &tokio_postgres::Client,
4897) -> Result<PostgresRawData, CliError> {
4898    let mut raw = PostgresRawData::empty();
4899    query_pg_async_core(client, &mut raw).await?;
4900    query_pg_async_codegen_meta(client, &mut raw).await?;
4901    query_pg_async_constraints(client, &mut raw).await?;
4902    query_pg_async_security(client, &mut raw).await?;
4903    Ok(raw)
4904}
4905
4906#[cfg(feature = "tokio-postgres")]
4907async fn query_pg_async_core(
4908    client: &tokio_postgres::Client,
4909    raw: &mut PostgresRawData,
4910) -> Result<(), CliError> {
4911    use drizzle_migrations::postgres::introspect::{RawColumnInfo, RawTableInfo, queries};
4912
4913    raw.schemas = client
4914        .query(queries::SCHEMAS_QUERY, &[])
4915        .await
4916        .map_err(|e| CliError::Other(format!("Failed to query schemas: {e}")))?
4917        .into_iter()
4918        .map(|row| RawSchemaInfo {
4919            name: row.get::<_, String>(0),
4920        })
4921        .collect();
4922
4923    raw.tables = client
4924        .query(queries::TABLES_QUERY, &[])
4925        .await
4926        .map_err(|e| CliError::Other(format!("Failed to query tables: {e}")))?
4927        .into_iter()
4928        .map(|row| RawTableInfo {
4929            schema: row.get::<_, String>(0),
4930            name: row.get::<_, String>(1),
4931            is_rls_enabled: row.get::<_, bool>(2),
4932            is_unlogged: row.get::<_, bool>(3),
4933            is_temporary: row.get::<_, bool>(4),
4934            tablespace: row.get::<_, Option<String>>(5),
4935            comment: row.get::<_, Option<String>>(6),
4936        })
4937        .collect();
4938
4939    raw.columns = client
4940        .query(queries::COLUMNS_QUERY, &[])
4941        .await
4942        .map_err(|e| CliError::Other(format!("Failed to query columns: {e}")))?
4943        .into_iter()
4944        .map(|row| RawColumnInfo {
4945            schema: row.get::<_, String>(0),
4946            table: row.get::<_, String>(1),
4947            name: row.get::<_, String>(2),
4948            column_type: row.get::<_, String>(3),
4949            type_schema: row.get::<_, Option<String>>(4),
4950            not_null: row.get::<_, bool>(5),
4951            default_value: row.get::<_, Option<String>>(6),
4952            is_identity: row.get::<_, bool>(7),
4953            identity_type: row.get::<_, Option<String>>(8),
4954            is_generated: row.get::<_, bool>(9),
4955            generated_expression: row.get::<_, Option<String>>(10),
4956            generated_stored: row.get::<_, bool>(11),
4957            dimensions: row.get::<_, Option<i32>>(12),
4958            comment: row.get::<_, Option<String>>(13),
4959            ordinal_position: row.get::<_, i32>(14),
4960        })
4961        .collect();
4962
4963    Ok(())
4964}
4965
4966#[cfg(feature = "tokio-postgres")]
4967async fn query_pg_async_codegen_meta(
4968    client: &tokio_postgres::Client,
4969    raw: &mut PostgresRawData,
4970) -> Result<(), CliError> {
4971    use drizzle_migrations::postgres::introspect::{
4972        RawEnumInfo, RawSequenceInfo, RawViewInfo, queries,
4973    };
4974
4975    raw.enums = client
4976        .query(queries::ENUMS_QUERY, &[])
4977        .await
4978        .map_err(|e| CliError::Other(format!("Failed to query enums: {e}")))?
4979        .into_iter()
4980        .map(|row| RawEnumInfo {
4981            schema: row.get::<_, String>(0),
4982            name: row.get::<_, String>(1),
4983            values: row.get::<_, Vec<String>>(2),
4984        })
4985        .collect();
4986
4987    raw.sequences = client
4988        .query(queries::SEQUENCES_QUERY, &[])
4989        .await
4990        .map_err(|e| CliError::Other(format!("Failed to query sequences: {e}")))?
4991        .into_iter()
4992        .map(|row| RawSequenceInfo {
4993            schema: row.get::<_, String>(0),
4994            name: row.get::<_, String>(1),
4995            data_type: row.get::<_, Option<String>>(2),
4996            start_value: row.get::<_, Option<String>>(3),
4997            min_value: row.get::<_, Option<String>>(4),
4998            max_value: row.get::<_, Option<String>>(5),
4999            increment: row.get::<_, Option<String>>(6),
5000            cycle: row.get::<_, Option<bool>>(7),
5001            cache_value: row.get::<_, Option<String>>(8),
5002            owned_by: row.get::<_, Option<String>>(9),
5003        })
5004        .collect();
5005
5006    let view_schema_filters: Option<Vec<String>> = None;
5007    raw.views = client
5008        .query(queries::VIEWS_QUERY, &[&view_schema_filters])
5009        .await
5010        .map_err(|e| CliError::Other(format!("Failed to query views: {e}")))?
5011        .into_iter()
5012        .map(|row| RawViewInfo {
5013            schema: row.get::<_, String>(0),
5014            name: row.get::<_, String>(1),
5015            definition: row.get::<_, String>(2),
5016            is_materialized: row.get::<_, bool>(3),
5017        })
5018        .collect();
5019
5020    Ok(())
5021}
5022
5023#[cfg(feature = "tokio-postgres")]
5024async fn query_pg_async_constraints(
5025    client: &tokio_postgres::Client,
5026    raw: &mut PostgresRawData,
5027) -> Result<(), CliError> {
5028    use drizzle_migrations::postgres::introspect::{
5029        RawCheckInfo, RawForeignKeyInfo, RawIndexInfo, RawPrimaryKeyInfo, RawUniqueInfo,
5030        action_code_to_string, parse_index_columns, queries,
5031    };
5032
5033    raw.indexes = client
5034        .query(queries::INDEXES_QUERY, &[])
5035        .await
5036        .map_err(|e| CliError::Other(format!("Failed to query indexes: {e}")))?
5037        .into_iter()
5038        .map(|row| {
5039            let cols: Vec<String> = row.get(6);
5040            RawIndexInfo {
5041                schema: row.get::<_, String>(0),
5042                table: row.get::<_, String>(1),
5043                name: row.get::<_, String>(2),
5044                is_unique: row.get::<_, bool>(3),
5045                is_primary: row.get::<_, bool>(4),
5046                method: row.get::<_, String>(5),
5047                columns: parse_index_columns(cols),
5048                where_clause: row.get::<_, Option<String>>(7),
5049                concurrent: false,
5050            }
5051        })
5052        .collect();
5053
5054    raw.foreign_keys = client
5055        .query(queries::FOREIGN_KEYS_QUERY, &[])
5056        .await
5057        .map_err(|e| CliError::Other(format!("Failed to query foreign keys: {e}")))?
5058        .into_iter()
5059        .map(|row| RawForeignKeyInfo {
5060            schema: row.get::<_, String>(0),
5061            table: row.get::<_, String>(1),
5062            name: row.get::<_, String>(2),
5063            columns: row.get::<_, Vec<String>>(3),
5064            schema_to: row.get::<_, String>(4),
5065            table_to: row.get::<_, String>(5),
5066            columns_to: row.get::<_, Vec<String>>(6),
5067            on_update: action_code_to_string(&row.get::<_, String>(7)),
5068            on_delete: action_code_to_string(&row.get::<_, String>(8)),
5069            deferrable: row.get::<_, bool>(9),
5070            initially_deferred: row.get::<_, bool>(10),
5071        })
5072        .collect();
5073
5074    raw.primary_keys = client
5075        .query(queries::PRIMARY_KEYS_QUERY, &[])
5076        .await
5077        .map_err(|e| CliError::Other(format!("Failed to query primary keys: {e}")))?
5078        .into_iter()
5079        .map(|row| RawPrimaryKeyInfo {
5080            schema: row.get::<_, String>(0),
5081            table: row.get::<_, String>(1),
5082            name: row.get::<_, String>(2),
5083            columns: row.get::<_, Vec<String>>(3),
5084        })
5085        .collect();
5086
5087    raw.uniques = client
5088        .query(queries::UNIQUES_QUERY, &[])
5089        .await
5090        .map_err(|e| CliError::Other(format!("Failed to query unique constraints: {e}")))?
5091        .into_iter()
5092        .map(|row| RawUniqueInfo {
5093            schema: row.get::<_, String>(0),
5094            table: row.get::<_, String>(1),
5095            name: row.get::<_, String>(2),
5096            columns: row.get::<_, Vec<String>>(3),
5097            nulls_not_distinct: row.get::<_, bool>(4),
5098            deferrable: row.get::<_, bool>(5),
5099            initially_deferred: row.get::<_, bool>(6),
5100        })
5101        .collect();
5102
5103    raw.checks = client
5104        .query(queries::CHECKS_QUERY, &[])
5105        .await
5106        .map_err(|e| CliError::Other(format!("Failed to query check constraints: {e}")))?
5107        .into_iter()
5108        .map(|row| RawCheckInfo {
5109            schema: row.get::<_, String>(0),
5110            table: row.get::<_, String>(1),
5111            name: row.get::<_, String>(2),
5112            expression: row.get::<_, String>(3),
5113        })
5114        .collect();
5115
5116    Ok(())
5117}
5118
5119#[cfg(feature = "tokio-postgres")]
5120async fn query_pg_async_security(
5121    client: &tokio_postgres::Client,
5122    raw: &mut PostgresRawData,
5123) -> Result<(), CliError> {
5124    use drizzle_migrations::postgres::introspect::{RawPolicyInfo, RawRoleInfo, queries};
5125
5126    raw.roles = client
5127        .query(queries::ROLES_QUERY, &[])
5128        .await
5129        .map_err(|e| CliError::Other(format!("Failed to query roles: {e}")))?
5130        .into_iter()
5131        .map(|row| RawRoleInfo {
5132            name: row.get::<_, String>(0),
5133            create_db: row.get::<_, bool>(1),
5134            create_role: row.get::<_, bool>(2),
5135            inherit: row.get::<_, bool>(3),
5136        })
5137        .collect();
5138
5139    raw.policies = client
5140        .query(queries::POLICIES_QUERY, &[])
5141        .await
5142        .map_err(|e| CliError::Other(format!("Failed to query policies: {e}")))?
5143        .into_iter()
5144        .map(|row| RawPolicyInfo {
5145            schema: row.get::<_, String>(0),
5146            table: row.get::<_, String>(1),
5147            name: row.get::<_, String>(2),
5148            as_clause: row.get::<_, String>(3),
5149            for_clause: row.get::<_, String>(4),
5150            to: row.get::<_, Vec<String>>(5),
5151            using: row.get::<_, Option<String>>(6),
5152            with_check: row.get::<_, Option<String>>(7),
5153        })
5154        .collect();
5155
5156    Ok(())
5157}
5158
5159/// Minimal schema list for snapshot
5160#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
5161#[derive(Debug, Clone)]
5162struct RawSchemaInfo {
5163    name: String,
5164}
5165
5166/// Aggregated raw introspection data collected from a `PostgreSQL` database.
5167///
5168/// Shared between `postgres-sync` and `tokio-postgres` code paths — the
5169/// collection phase differs (blocking vs async), but the downstream
5170/// processing is identical.
5171#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
5172struct PostgresRawData {
5173    schemas: Vec<RawSchemaInfo>,
5174    tables: Vec<drizzle_migrations::postgres::introspect::RawTableInfo>,
5175    columns: Vec<drizzle_migrations::postgres::introspect::RawColumnInfo>,
5176    enums: Vec<drizzle_migrations::postgres::introspect::RawEnumInfo>,
5177    sequences: Vec<drizzle_migrations::postgres::introspect::RawSequenceInfo>,
5178    views: Vec<drizzle_migrations::postgres::introspect::RawViewInfo>,
5179    indexes: Vec<drizzle_migrations::postgres::introspect::RawIndexInfo>,
5180    foreign_keys: Vec<drizzle_migrations::postgres::introspect::RawForeignKeyInfo>,
5181    primary_keys: Vec<drizzle_migrations::postgres::introspect::RawPrimaryKeyInfo>,
5182    uniques: Vec<drizzle_migrations::postgres::introspect::RawUniqueInfo>,
5183    checks: Vec<drizzle_migrations::postgres::introspect::RawCheckInfo>,
5184    roles: Vec<drizzle_migrations::postgres::introspect::RawRoleInfo>,
5185    policies: Vec<drizzle_migrations::postgres::introspect::RawPolicyInfo>,
5186}
5187
5188/// Build a [`PostgresDDL`] from raw introspection data.
5189///
5190/// Identical across the sync and async paths — only the query phase differs.
5191#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
5192fn build_postgres_ddl(
5193    raw: PostgresRawData,
5194) -> drizzle_migrations::postgres::collection::PostgresDDL {
5195    use drizzle_migrations::postgres::ddl::Schema;
5196    use drizzle_migrations::postgres::introspect::{RawIntrospection, assemble_ddl};
5197
5198    assemble_ddl(RawIntrospection {
5199        schemas: raw
5200            .schemas
5201            .into_iter()
5202            .map(|schema| Schema::new(schema.name))
5203            .collect(),
5204        tables: raw.tables,
5205        columns: raw.columns,
5206        enums: raw.enums,
5207        sequences: raw.sequences,
5208        views: raw.views,
5209        indexes: raw.indexes,
5210        foreign_keys: raw.foreign_keys,
5211        primary_keys: raw.primary_keys,
5212        unique_constraints: raw.uniques,
5213        check_constraints: raw.checks,
5214        roles: raw.roles,
5215        policies: raw.policies,
5216    })
5217}
5218
5219/// Package a generated DDL + generated code into an [`IntrospectResult`].
5220#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
5221fn finalize_postgres_introspection(
5222    ddl: &drizzle_migrations::postgres::collection::PostgresDDL,
5223    url: &str,
5224) -> IntrospectResult {
5225    use drizzle_migrations::postgres::codegen::{
5226        CodegenOptions, FieldCasing, generate_rust_schema,
5227    };
5228
5229    let options = CodegenOptions {
5230        module_doc: Some(format!(
5231            "Schema introspected from {}",
5232            crate::output::mask_url(url)
5233        )),
5234        include_schema: true,
5235        schema_name: "Schema".to_string(),
5236        use_pub: true,
5237        field_casing: FieldCasing::default(),
5238    };
5239    let generated = generate_rust_schema(ddl, &options);
5240
5241    let mut snap = drizzle_migrations::postgres::PostgresSnapshot::new();
5242    for entity in ddl.to_entities() {
5243        snap.add_entity(entity);
5244    }
5245
5246    IntrospectResult {
5247        schema_code: generated.code,
5248        table_count: ddl.tables.list().len(),
5249        index_count: ddl.indexes.list().len(),
5250        view_count: ddl.views.list().len(),
5251        warnings: generated.warnings,
5252        mysql_catalog_defaults: None,
5253        snapshot: Snapshot::Postgres(snap),
5254        snapshot_path: std::path::PathBuf::new(),
5255    }
5256}
5257
5258#[cfg(test)]
5259mod tests {
5260    use super::*;
5261
5262    #[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
5263    fn test_postgres_url() -> String {
5264        std::env::var("DATABASE_URL")
5265            .unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/drizzle_test".into())
5266    }
5267
5268    #[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
5269    fn test_postgres_creds() -> crate::config::PostgresCreds {
5270        crate::config::PostgresCreds::Url(test_postgres_url().into_boxed_str())
5271    }
5272
5273    #[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
5274    fn unique_pg_name(prefix: &str) -> String {
5275        use std::time::{SystemTime, UNIX_EPOCH};
5276
5277        let nanos = SystemTime::now()
5278            .duration_since(UNIX_EPOCH)
5279            .expect("time went backwards")
5280            .as_nanos();
5281        format!("{}_{}_{}", prefix, std::process::id(), nanos)
5282    }
5283
5284    #[test]
5285    fn destructive_statement_detection_covers_drop_variants() {
5286        assert!(is_destructive_statement("DROP TABLE users;"));
5287        assert!(is_destructive_statement("DROP VIEW active_users;"));
5288        assert!(is_destructive_statement("DROP TYPE status;"));
5289        assert!(is_destructive_statement("DROP SCHEMA auth;"));
5290        assert!(is_destructive_statement("DROP ROLE app_user;"));
5291        assert!(is_destructive_statement(
5292            "DROP POLICY users_rls_policy ON users;"
5293        ));
5294        assert!(is_destructive_statement("TRUNCATE users;"));
5295        assert!(is_destructive_statement(
5296            "ALTER TABLE users DROP CONSTRAINT users_email_key;"
5297        ));
5298
5299        assert!(!is_destructive_statement("CREATE TABLE users(id INTEGER);"));
5300        assert!(!is_destructive_statement(
5301            "ALTER TABLE users ADD COLUMN email text;"
5302        ));
5303    }
5304
5305    #[test]
5306    fn mysql_structural_warnings_require_push_confirmation() {
5307        let statements =
5308            vec!["ALTER TABLE `users` MODIFY COLUMN `email` varchar(32) NOT NULL;".to_string()];
5309        let warnings = vec![
5310            "changing the type of users.email can truncate or reject existing values".to_string(),
5311        ];
5312
5313        assert!(push_requires_confirmation(
5314            Dialect::Mysql,
5315            &statements,
5316            &warnings,
5317        ));
5318        assert!(!push_requires_confirmation(
5319            Dialect::Mysql,
5320            &statements,
5321            &[],
5322        ));
5323    }
5324
5325    #[cfg(feature = "rusqlite")]
5326    #[test]
5327    fn sqlite_migrations_run_both_when_created_at_collides() {
5328        // Regression for drizzle-orm beta.19: migration identity is the folder
5329        // name, not the `created_at` timestamp. Two migrations that share a
5330        // wall-second must both apply.
5331        use drizzle_migrations::{Migration, Migrations};
5332
5333        let dir = tempfile::tempdir().expect("tempdir");
5334        let db_path = dir.path().join("migrate.sqlite");
5335        let db_path_str = db_path.to_string_lossy().to_string();
5336
5337        let first_set = Migrations::new(
5338            vec![Migration::with_hash(
5339                "20230331141203_first",
5340                "hash_one",
5341                1_680_271_923_000,
5342                vec!["CREATE TABLE created_at_dedupe_a (id INTEGER PRIMARY KEY)".to_string()],
5343            )],
5344            drizzle_types::Dialect::SQLite,
5345        );
5346
5347        let first =
5348            run_sqlite_migrations(&first_set, &db_path_str, false).expect("first migrate succeeds");
5349        assert_eq!(first.applied_count, 1);
5350
5351        let second_set = Migrations::new(
5352            vec![
5353                Migration::with_hash(
5354                    "20230331141203_first",
5355                    "hash_one",
5356                    1_680_271_923_000,
5357                    vec!["CREATE TABLE created_at_dedupe_a (id INTEGER PRIMARY KEY)".to_string()],
5358                ),
5359                Migration::with_hash(
5360                    "20230331141203_second",
5361                    "hash_two",
5362                    1_680_271_923_000,
5363                    vec!["CREATE TABLE created_at_dedupe_b (id INTEGER PRIMARY KEY)".to_string()],
5364                ),
5365            ],
5366            drizzle_types::Dialect::SQLite,
5367        );
5368
5369        let second = run_sqlite_migrations(&second_set, &db_path_str, false)
5370            .expect("second migrate succeeds");
5371        assert_eq!(
5372            second.applied_count, 1,
5373            "only the second (newly introduced by name) migration should apply"
5374        );
5375
5376        let conn = rusqlite::Connection::open(&db_path).expect("open sqlite");
5377        let rows: i64 = conn
5378            .query_row("SELECT COUNT(*) FROM __drizzle_migrations", [], |row| {
5379                row.get(0)
5380            })
5381            .expect("count migrations rows");
5382        assert_eq!(rows, 2, "both migration records should be stored");
5383
5384        let table_b_exists: i64 = conn
5385            .query_row(
5386                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='created_at_dedupe_b'",
5387                [],
5388                |row| row.get(0),
5389            )
5390            .expect("query sqlite_master");
5391        assert_eq!(
5392            table_b_exists, 1,
5393            "the new-name migration must execute even though created_at collides"
5394        );
5395    }
5396
5397    #[cfg(feature = "rusqlite")]
5398    #[test]
5399    fn sqlite_cli_rebuild_preserves_cascade_children() {
5400        use drizzle_migrations::{Migration, Migrations};
5401
5402        let dir = tempfile::tempdir().expect("tempdir");
5403        let db_path = dir.path().join("rebuild.sqlite");
5404        let conn = rusqlite::Connection::open(&db_path).expect("open sqlite");
5405        conn.execute_batch(
5406            "PRAGMA foreign_keys=ON;
5407             CREATE TABLE rebuild_parent (id INTEGER PRIMARY KEY);
5408             CREATE TABLE rebuild_child (
5409                 id INTEGER PRIMARY KEY,
5410                 parent_id INTEGER NOT NULL REFERENCES rebuild_parent(id) ON DELETE CASCADE
5411             );
5412             INSERT INTO rebuild_parent VALUES (1);
5413             INSERT INTO rebuild_child VALUES (1, 1);",
5414        )
5415        .expect("seed related tables");
5416
5417        let migration = Migration::new(
5418            "20260825000000_rebuild",
5419            "PRAGMA foreign_keys=OFF;
5420             --> statement-breakpoint
5421             CREATE TABLE rebuild_parent_new (id INTEGER PRIMARY KEY) STRICT;
5422             --> statement-breakpoint
5423             INSERT INTO rebuild_parent_new SELECT * FROM rebuild_parent;
5424             --> statement-breakpoint
5425             DROP TABLE rebuild_parent;
5426             --> statement-breakpoint
5427             ALTER TABLE rebuild_parent_new RENAME TO rebuild_parent;
5428             --> statement-breakpoint
5429             PRAGMA foreign_keys=ON;",
5430        );
5431        let set = Migrations::new(vec![migration], drizzle_types::Dialect::SQLite);
5432
5433        run_sqlite_migrations_on_connection(&set, &conn, false).expect("run safe rebuild");
5434
5435        let children: i64 = conn
5436            .query_row("SELECT count(*) FROM rebuild_child", [], |row| row.get(0))
5437            .expect("count cascade children");
5438        assert_eq!(children, 1, "rebuild must not trigger ON DELETE CASCADE");
5439        let violations: i64 = conn
5440            .query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |row| {
5441                row.get(0)
5442            })
5443            .expect("check rebuilt foreign keys");
5444        assert_eq!(violations, 0);
5445        let foreign_keys: i64 = conn
5446            .query_row("PRAGMA foreign_keys", [], |row| row.get(0))
5447            .expect("read restored foreign-key mode");
5448        assert_eq!(foreign_keys, 1);
5449    }
5450
5451    #[cfg(feature = "rusqlite")]
5452    #[test]
5453    fn sqlite_push_rebuild_preserves_cascade_children() {
5454        let conn = rusqlite::Connection::open_in_memory().expect("open sqlite");
5455        conn.execute_batch(
5456            "PRAGMA foreign_keys=ON;
5457             CREATE TABLE rebuild_parent (id INTEGER PRIMARY KEY);
5458             CREATE TABLE rebuild_child (
5459                 id INTEGER PRIMARY KEY,
5460                 parent_id INTEGER NOT NULL REFERENCES rebuild_parent(id) ON DELETE CASCADE
5461             );
5462             INSERT INTO rebuild_parent VALUES (1);
5463             INSERT INTO rebuild_child VALUES (1, 1);",
5464        )
5465        .expect("seed related tables");
5466        let migration = drizzle_migrations::Migration::new(
5467            "push_rebuild",
5468            "PRAGMA foreign_keys=OFF;
5469             --> statement-breakpoint
5470             CREATE TABLE rebuild_parent_new (id INTEGER PRIMARY KEY) STRICT;
5471             --> statement-breakpoint
5472             INSERT INTO rebuild_parent_new SELECT * FROM rebuild_parent;
5473             --> statement-breakpoint
5474             DROP TABLE rebuild_parent;
5475             --> statement-breakpoint
5476             ALTER TABLE rebuild_parent_new RENAME TO rebuild_parent;
5477             --> statement-breakpoint
5478             PRAGMA foreign_keys=ON;",
5479        );
5480
5481        execute_sqlite_statements_on_connection(&conn, migration.statements())
5482            .expect("apply push statements");
5483
5484        let children: i64 = conn
5485            .query_row("SELECT count(*) FROM rebuild_child", [], |row| row.get(0))
5486            .expect("count cascade children");
5487        assert_eq!(children, 1, "push rebuild must not trigger cascade");
5488        let foreign_keys: i64 = conn
5489            .query_row("PRAGMA foreign_keys", [], |row| row.get(0))
5490            .expect("read restored foreign-key mode");
5491        assert_eq!(foreign_keys, 1);
5492    }
5493
5494    #[cfg(feature = "rusqlite")]
5495    #[test]
5496    fn declined_destructive_push_is_an_abort_and_applies_nothing() {
5497        let dir = tempfile::tempdir().expect("tempdir");
5498        let db_path = dir.path().join("push.sqlite");
5499        rusqlite::Connection::open(&db_path)
5500            .expect("open sqlite")
5501            .execute_batch("CREATE TABLE keep_me (id INTEGER PRIMARY KEY);")
5502            .expect("seed table");
5503
5504        let connection = ResolvedConnection {
5505            dialect: Dialect::Sqlite,
5506            driver: Driver::Rusqlite,
5507            credentials: Credentials::Sqlite {
5508                path: db_path.to_string_lossy().into_owned().into_boxed_str(),
5509            },
5510        };
5511        let plan = PushPlan {
5512            sql_statements: vec!["DROP TABLE `keep_me`;".to_string()],
5513            warnings: Vec::new(),
5514            destructive: true,
5515        };
5516
5517        let error = apply_push_with_confirmation(&connection, &plan, false, || Ok(false))
5518            .expect_err("declining the prompt must not report success");
5519        assert!(matches!(error, CliError::Aborted(_)), "{error:?}");
5520        assert!(error.to_string().contains("aborted"), "{error}");
5521
5522        let kept: i64 = rusqlite::Connection::open(&db_path)
5523            .expect("reopen sqlite")
5524            .query_row(
5525                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='keep_me'",
5526                [],
5527                |row| row.get(0),
5528            )
5529            .expect("query sqlite_master");
5530        assert_eq!(kept, 1, "a declined push must not execute any statement");
5531    }
5532
5533    #[cfg(feature = "rusqlite")]
5534    #[test]
5535    fn run_migrations_creates_metadata_table_with_no_local_migrations() {
5536        let dir = tempfile::tempdir().expect("tempdir");
5537        let db_path = dir.path().join("empty.sqlite");
5538        let migrations_dir = dir.path().join("migrations");
5539        std::fs::create_dir_all(&migrations_dir).expect("create migrations dir");
5540
5541        let creds = crate::config::Credentials::Sqlite {
5542            path: db_path.to_string_lossy().to_string().into_boxed_str(),
5543        };
5544        let connection = ResolvedConnection {
5545            dialect: crate::config::Dialect::Sqlite,
5546            driver: Driver::Rusqlite,
5547            credentials: creds,
5548        };
5549
5550        let result = run_migrations(
5551            &connection,
5552            &migrations_dir,
5553            "__drizzle_migrations",
5554            "drizzle",
5555            false,
5556        )
5557        .expect("run migrations");
5558        assert_eq!(result.applied_count, 0);
5559
5560        let conn = rusqlite::Connection::open(&db_path).expect("open sqlite");
5561        let exists: i64 = conn
5562            .query_row(
5563                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='__drizzle_migrations'",
5564                [],
5565                |row| row.get(0),
5566            )
5567            .expect("check metadata table");
5568        assert_eq!(exists, 1, "migrations metadata table should be created");
5569    }
5570
5571    #[cfg(any(
5572        feature = "rusqlite",
5573        feature = "libsql",
5574        feature = "turso",
5575        feature = "postgres-sync",
5576        feature = "tokio-postgres",
5577        feature = "mysql-sync",
5578        feature = "mysql-async"
5579    ))]
5580    #[test]
5581    fn validate_init_metadata_matches_drizzle_orm_semantics() {
5582        use drizzle_migrations::{Migration, Migrations};
5583
5584        let empty_set = Migrations::empty(drizzle_types::Dialect::SQLite);
5585        validate_init_metadata(&[], &empty_set).expect("empty local migrations should be allowed");
5586
5587        let single = Migrations::new(
5588            vec![Migration::with_hash(
5589                "20230331141203_init",
5590                "hash_single",
5591                1_680_271_923_000,
5592                vec!["CREATE TABLE t(id INTEGER PRIMARY KEY)".to_string()],
5593            )],
5594            drizzle_types::Dialect::SQLite,
5595        );
5596        validate_init_metadata(&[], &single).expect("single local migration should be allowed");
5597
5598        let multiple = Migrations::new(
5599            vec![
5600                Migration::with_hash(
5601                    "20230331141203_first",
5602                    "hash_a",
5603                    1_680_271_923_000,
5604                    vec!["CREATE TABLE a(id INTEGER PRIMARY KEY)".to_string()],
5605                ),
5606                Migration::with_hash(
5607                    "20230331150000_second",
5608                    "hash_b",
5609                    1_680_275_400_000,
5610                    vec!["CREATE TABLE b(id INTEGER PRIMARY KEY)".to_string()],
5611                ),
5612            ],
5613            drizzle_types::Dialect::SQLite,
5614        );
5615
5616        let err =
5617            validate_init_metadata(&[], &multiple).expect_err("multiple local migrations rejected");
5618        assert_eq!(
5619            err.to_string(),
5620            "--init can't be used with existing migrations"
5621        );
5622
5623        let err = validate_init_metadata(&["20230331141203_init".to_string()], &single)
5624            .expect_err("existing db metadata should be rejected");
5625        assert_eq!(
5626            err.to_string(),
5627            "--init can't be used when database already has migrations set"
5628        );
5629    }
5630
5631    #[cfg(any(
5632        feature = "rusqlite",
5633        feature = "libsql",
5634        feature = "turso",
5635        feature = "postgres-sync",
5636        feature = "tokio-postgres",
5637        feature = "mysql-sync",
5638        feature = "mysql-async",
5639        feature = "d1-http",
5640    ))]
5641    #[test]
5642    fn integrity_findings_detect_hash_drift() {
5643        use drizzle_migrations::{Migration, Migrations};
5644
5645        let set = Migrations::new(
5646            vec![Migration::with_hash(
5647                "20230331141203_verify",
5648                "local_hash",
5649                1_680_271_923_000,
5650                vec!["CREATE TABLE t(id INTEGER PRIMARY KEY)".to_string()],
5651            )],
5652            drizzle_types::Dialect::SQLite,
5653        );
5654
5655        let applied = vec![AppliedMigrationRecord {
5656            hash: "db_hash".to_string(),
5657            name: "20230331141203_verify".to_string(),
5658            dirty: false,
5659        }];
5660
5661        let findings =
5662            collect_integrity_findings(&set, &applied).expect("local set is well-formed");
5663        assert_eq!(findings.len(), 1, "expected one finding: {findings:?}");
5664        assert!(
5665            findings[0].contains("20230331141203_verify") && findings[0].contains("drifted"),
5666            "unexpected finding: {}",
5667            findings[0]
5668        );
5669    }
5670
5671    #[cfg(any(
5672        feature = "rusqlite",
5673        feature = "libsql",
5674        feature = "turso",
5675        feature = "postgres-sync",
5676        feature = "tokio-postgres",
5677        feature = "mysql-sync",
5678        feature = "mysql-async",
5679        feature = "d1-http",
5680    ))]
5681    #[test]
5682    fn integrity_findings_report_dirty_and_missing_local_rows() {
5683        use drizzle_migrations::{Migration, Migrations};
5684
5685        let set = Migrations::new(
5686            vec![Migration::with_hash(
5687                "20230331141203_first",
5688                "hash_a",
5689                1_680_271_923_000,
5690                vec!["CREATE TABLE a(id INTEGER PRIMARY KEY)".to_string()],
5691            )],
5692            drizzle_types::Dialect::SQLite,
5693        );
5694
5695        let applied = vec![
5696            AppliedMigrationRecord {
5697                hash: "hash_a".to_string(),
5698                name: "20230331141203_first".to_string(),
5699                dirty: true,
5700            },
5701            AppliedMigrationRecord {
5702                hash: "hash_gone".to_string(),
5703                name: "20230401000000_deleted_locally".to_string(),
5704                dirty: false,
5705            },
5706        ];
5707
5708        let findings =
5709            collect_integrity_findings(&set, &applied).expect("local set is well-formed");
5710        assert_eq!(findings.len(), 2, "expected two findings: {findings:?}");
5711        assert!(
5712            findings[0].contains("interrupted mid-apply"),
5713            "unexpected finding: {}",
5714            findings[0]
5715        );
5716        assert!(
5717            findings[1].contains("no local migration"),
5718            "unexpected finding: {}",
5719            findings[1]
5720        );
5721    }
5722
5723    #[cfg(any(
5724        feature = "rusqlite",
5725        feature = "libsql",
5726        feature = "turso",
5727        feature = "postgres-sync",
5728        feature = "tokio-postgres",
5729        feature = "mysql-sync",
5730        feature = "mysql-async",
5731        feature = "d1-http",
5732    ))]
5733    #[test]
5734    fn mysql_integrity_findings_do_not_recommend_automatic_repair() {
5735        use drizzle_migrations::{Migration, Migrations};
5736
5737        let set = Migrations::new(
5738            vec![Migration::with_hash(
5739                "20230331141203_first",
5740                "hash_a",
5741                1_680_271_923_000,
5742                vec!["CREATE TABLE a(id INTEGER PRIMARY KEY)".to_string()],
5743            )],
5744            drizzle_types::Dialect::MySQL,
5745        );
5746        let findings = collect_integrity_findings(
5747            &set,
5748            &[AppliedMigrationRecord {
5749                hash: "hash_a".to_string(),
5750                name: "20230331141203_first".to_string(),
5751                dirty: true,
5752            }],
5753        )
5754        .expect("local set is well-formed");
5755
5756        assert!(findings[0].contains("resolve it manually"), "{findings:?}");
5757        assert!(!findings[0].contains("--repair"), "{findings:?}");
5758    }
5759
5760    #[cfg(any(
5761        feature = "rusqlite",
5762        feature = "libsql",
5763        feature = "turso",
5764        feature = "postgres-sync",
5765        feature = "tokio-postgres",
5766        feature = "mysql-sync",
5767        feature = "mysql-async",
5768        feature = "d1-http",
5769    ))]
5770    #[test]
5771    fn dirty_rows_are_not_counted_as_applied_in_plan() {
5772        use drizzle_migrations::{Migration, Migrations};
5773
5774        let set = Migrations::new(
5775            vec![Migration::with_hash(
5776                "20230331141203_first",
5777                "hash_a",
5778                1_680_271_923_000,
5779                vec!["CREATE TABLE a(id INTEGER PRIMARY KEY)".to_string()],
5780            )],
5781            drizzle_types::Dialect::SQLite,
5782        );
5783
5784        let applied = vec![AppliedMigrationRecord {
5785            hash: "hash_a".to_string(),
5786            name: "20230331141203_first".to_string(),
5787            dirty: true,
5788        }];
5789
5790        let plan = build_migration_plan(&set, &applied).expect("build migration plan");
5791        assert_eq!(plan.applied_count, 0);
5792        assert_eq!(plan.pending_count, 1, "interrupted migration is unfinished");
5793        assert_eq!(plan.findings.len(), 1);
5794    }
5795
5796    /// `migrate --plan`, `--dry-run` and `--verify` must not write: no
5797    /// database file, no tracking table.
5798    #[cfg(feature = "rusqlite")]
5799    #[test]
5800    fn sqlite_plan_creates_neither_the_database_nor_the_tracking_table() {
5801        use drizzle_migrations::{Migration, Migrations};
5802
5803        let set = Migrations::new(
5804            vec![Migration::with_hash(
5805                "20230331141203_first",
5806                "hash_a",
5807                1_680_271_923_000,
5808                vec!["CREATE TABLE a(id INTEGER PRIMARY KEY)".to_string()],
5809            )],
5810            drizzle_types::Dialect::SQLite,
5811        );
5812        let dir = tempfile::tempdir().expect("tempdir");
5813
5814        let missing = dir.path().join("missing.db");
5815        let plan = inspect_sqlite_migrations(&set, missing.to_str().expect("utf-8 path"))
5816            .expect("plan a missing database");
5817        assert_eq!(plan.pending_count, 1);
5818        assert!(!missing.exists(), "planning created the database file");
5819
5820        let existing = dir.path().join("existing.db");
5821        rusqlite::Connection::open(&existing)
5822            .and_then(|conn| conn.execute("CREATE TABLE users(id INTEGER PRIMARY KEY)", []))
5823            .expect("seed database");
5824        let plan = inspect_sqlite_migrations(&set, existing.to_str().expect("utf-8 path"))
5825            .expect("plan an existing database");
5826        assert_eq!(plan.pending_count, 1);
5827        let tracking_tables: i64 = rusqlite::Connection::open(&existing)
5828            .and_then(|conn| {
5829                conn.query_row(
5830                    "SELECT count(*) FROM sqlite_master WHERE name = ?1",
5831                    [set.table_name()],
5832                    |row| row.get(0),
5833                )
5834            })
5835            .expect("inspect database");
5836        assert_eq!(tracking_tables, 0, "planning created the tracking table");
5837    }
5838
5839    #[cfg(any(
5840        feature = "rusqlite",
5841        feature = "libsql",
5842        feature = "turso",
5843        feature = "postgres-sync",
5844        feature = "tokio-postgres",
5845        feature = "mysql-sync",
5846        feature = "mysql-async",
5847        feature = "d1-http",
5848    ))]
5849    #[test]
5850    fn build_migration_plan_counts_pending_statements() {
5851        use drizzle_migrations::{Migration, Migrations};
5852
5853        let set = Migrations::new(
5854            vec![
5855                Migration::with_hash(
5856                    "20230331141203_first",
5857                    "hash_a",
5858                    1_680_271_923_000,
5859                    vec![
5860                        "CREATE TABLE a(id INTEGER PRIMARY KEY)".to_string(),
5861                        "CREATE INDEX a_id_idx ON a(id)".to_string(),
5862                    ],
5863                ),
5864                Migration::with_hash(
5865                    "20230331150000_second",
5866                    "hash_b",
5867                    1_680_275_400_000,
5868                    vec!["CREATE TABLE b(id INTEGER PRIMARY KEY)".to_string()],
5869                ),
5870            ],
5871            drizzle_types::Dialect::SQLite,
5872        );
5873
5874        let applied = vec![AppliedMigrationRecord {
5875            hash: "hash_a".to_string(),
5876            name: "20230331141203_first".to_string(),
5877            dirty: false,
5878        }];
5879
5880        let plan = build_migration_plan(&set, &applied).expect("build migration plan");
5881        assert_eq!(plan.applied_count, 1);
5882        assert_eq!(plan.pending_count, 1);
5883        assert_eq!(plan.pending_statements, 1);
5884        assert_eq!(plan.pending_migrations, vec!["20230331150000_second"]);
5885        assert!(plan.findings.is_empty());
5886    }
5887
5888    #[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
5889    #[test]
5890    fn postgres_concurrent_index_detection_is_case_insensitive() {
5891        assert!(is_postgres_concurrent_index_statement(
5892            "CREATE INDEX CONCURRENTLY users_email_idx ON users (email);"
5893        ));
5894        assert!(is_postgres_concurrent_index_statement(
5895            "CREATE UNIQUE INDEX CONCURRENTLY users_email_idx ON users (email);"
5896        ));
5897        assert!(is_postgres_concurrent_index_statement(
5898            "drop index concurrently users_email_idx;"
5899        ));
5900        assert!(!is_postgres_concurrent_index_statement(
5901            "CREATE INDEX users_email_idx ON users (email);"
5902        ));
5903    }
5904
5905    #[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
5906    #[test]
5907    fn postgres_concurrent_index_detection_over_statement_list() {
5908        let with_concurrent = vec![
5909            "CREATE TABLE users(id integer);".to_string(),
5910            "CREATE INDEX CONCURRENTLY users_email_idx ON users (id);".to_string(),
5911        ];
5912        let without_concurrent = vec![
5913            "CREATE TABLE users(id integer);".to_string(),
5914            "CREATE INDEX users_email_idx ON users (id);".to_string(),
5915        ];
5916
5917        assert!(has_postgres_concurrent_index(&with_concurrent));
5918        assert!(!has_postgres_concurrent_index(&without_concurrent));
5919    }
5920
5921    #[test]
5922    fn generate_push_sql_includes_concurrent_postgres_index() {
5923        use drizzle_migrations::parser::SchemaParser;
5924        use drizzle_types::Dialect;
5925
5926        let previous = r#"
5927#[PostgresTable]
5928pub struct Users {
5929    #[column(primary)]
5930    pub id: i32,
5931    pub email: String,
5932}
5933"#;
5934
5935        let current = r#"
5936#[PostgresTable]
5937pub struct Users {
5938    #[column(primary)]
5939    pub id: i32,
5940    pub email: String,
5941}
5942
5943#[PostgresIndex(concurrent)]
5944pub struct UsersEmailIdx(Users::email);
5945"#;
5946
5947        let prev =
5948            Snapshot::from_parse_result(&SchemaParser::parse(previous), Dialect::PostgreSQL, None);
5949        let curr =
5950            Snapshot::from_parse_result(&SchemaParser::parse(current), Dialect::PostgreSQL, None);
5951
5952        let (sql, warnings) =
5953            generate_push_sql(&prev, &curr, false, &drizzle_migrations::DiffOptions::new())
5954                .expect("push sql generation");
5955        assert!(warnings.is_empty());
5956        assert_eq!(sql.len(), 1);
5957        assert_eq!(
5958            sql[0],
5959            "CREATE INDEX CONCURRENTLY \"users_email_idx\" ON \"users\"(\"email\");"
5960        );
5961    }
5962
5963    #[test]
5964    fn generate_push_sql_uses_live_mysql_catalog_defaults() {
5965        use drizzle_migrations::mysql::MySQLCatalogDefaults;
5966        use drizzle_migrations::parser::SchemaParser;
5967        use drizzle_types::Dialect;
5968
5969        let introspected = r#"
5970#[MySQLTable(NAME = "users")]
5971pub struct Users {
5972    #[column(PRIMARY)]
5973    pub id: i32,
5974}
5975"#;
5976        let desired = r#"
5977#[MySQLTable(NAME = "users", ENGINE = "InnoDB")]
5978pub struct Users {
5979    #[column(PRIMARY)]
5980    pub id: i32,
5981}
5982"#;
5983        let current =
5984            Snapshot::from_parse_result(&SchemaParser::parse(introspected), Dialect::MySQL, None);
5985        let desired =
5986            Snapshot::from_parse_result(&SchemaParser::parse(desired), Dialect::MySQL, None);
5987        let options = drizzle_migrations::DiffOptions::new()
5988            .mysql_catalog_defaults(MySQLCatalogDefaults::new().engine("InnoDB"));
5989
5990        let (sql, warnings) = generate_push_sql(&current, &desired, false, &options)
5991            .expect("push SQL with live MySQL defaults");
5992        assert!(sql.is_empty(), "inherited defaults must not churn: {sql:?}");
5993        assert!(warnings.is_empty());
5994    }
5995
5996    #[test]
5997    fn filter_patterns_support_negation_globs() {
5998        let raw = vec![
5999            "users_*".to_string(),
6000            "!users_4".to_string(),
6001            "!ad*".to_string(),
6002        ];
6003        let patterns = compile_patterns(Some(&raw)).expect("compile patterns");
6004
6005        assert!(matches_patterns("users_1", patterns.as_deref()));
6006        assert!(!matches_patterns("users_4", patterns.as_deref()));
6007        assert!(!matches_patterns("admin", patterns.as_deref()));
6008        assert!(!matches_patterns("audit", patterns.as_deref()));
6009    }
6010
6011    #[test]
6012    fn postgres_table_filter_matches_table_name_only() {
6013        use drizzle_migrations::parser::SchemaParser;
6014        use drizzle_migrations::schema::Snapshot;
6015        use drizzle_types::Dialect as BaseDialect;
6016
6017        let code = r#"
6018#[PostgresTable(schema = "admin")]
6019pub struct AuditLog {
6020    #[column(primary)]
6021    pub id: i32,
6022}
6023
6024#[PostgresTable(schema = "public")]
6025pub struct Users {
6026    #[column(primary)]
6027    pub id: i32,
6028}
6029"#;
6030
6031        let parsed = SchemaParser::parse(code);
6032        let mut snapshot = Snapshot::from_parse_result(&parsed, BaseDialect::PostgreSQL, None);
6033        let filters = SnapshotFilters {
6034            tables: Some(vec!["admin.*".to_string()]),
6035            schemas: None,
6036            extensions: None,
6037            roles: None,
6038        };
6039
6040        apply_snapshot_filters(&mut snapshot, crate::config::Dialect::Postgresql, &filters)
6041            .expect("apply filters");
6042
6043        let remaining_tables = match snapshot {
6044            Snapshot::Postgres(s) => s
6045                .ddl
6046                .iter()
6047                .filter_map(|e| {
6048                    if let drizzle_types::postgres::ddl::PostgresEntity::Table(t) = e {
6049                        Some((t.schema.to_string(), t.name.to_string()))
6050                    } else {
6051                        None
6052                    }
6053                })
6054                .collect::<Vec<_>>(),
6055            _ => panic!("expected postgres snapshot"),
6056        };
6057
6058        assert!(remaining_tables.is_empty());
6059    }
6060
6061    #[test]
6062    fn postgres_schema_and_table_filters_intersect() {
6063        use drizzle_migrations::parser::SchemaParser;
6064        use drizzle_migrations::schema::Snapshot;
6065        use drizzle_types::Dialect as BaseDialect;
6066
6067        let code = r#"
6068#[PostgresTable(schema = "dev")]
6069pub struct UsersDev {
6070    #[column(primary)]
6071    pub id: i32,
6072}
6073
6074#[PostgresTable(schema = "public")]
6075pub struct UsersPublic {
6076    #[column(primary)]
6077    pub id: i32,
6078}
6079"#;
6080
6081        let parsed = SchemaParser::parse(code);
6082        let mut snapshot = Snapshot::from_parse_result(&parsed, BaseDialect::PostgreSQL, None);
6083        let filters = SnapshotFilters {
6084            tables: Some(vec!["users_*".to_string()]),
6085            schemas: Some(vec!["!dev".to_string()]),
6086            extensions: None,
6087            roles: None,
6088        };
6089
6090        apply_snapshot_filters(&mut snapshot, crate::config::Dialect::Postgresql, &filters)
6091            .expect("apply filters");
6092
6093        let remaining_tables = match snapshot {
6094            Snapshot::Postgres(s) => s
6095                .ddl
6096                .iter()
6097                .filter_map(|e| {
6098                    if let drizzle_types::postgres::ddl::PostgresEntity::Table(t) = e {
6099                        Some((t.schema.to_string(), t.name.to_string()))
6100                    } else {
6101                        None
6102                    }
6103                })
6104                .collect::<Vec<_>>(),
6105            _ => panic!("expected postgres snapshot"),
6106        };
6107
6108        assert_eq!(
6109            remaining_tables,
6110            vec![("public".to_string(), "users_public".to_string())]
6111        );
6112    }
6113
6114    #[test]
6115    fn postgres_extensions_filter_excludes_postgis_internal_objects() {
6116        use drizzle_migrations::parser::SchemaParser;
6117        use drizzle_migrations::schema::Snapshot;
6118        use drizzle_types::Dialect as BaseDialect;
6119
6120        let code = r#"
6121#[PostgresTable(schema = "topology")]
6122pub struct TopologyLayer {
6123    #[column(primary)]
6124    pub id: i32,
6125}
6126
6127#[PostgresTable]
6128pub struct SpatialRefSys {
6129    #[column(primary)]
6130    pub id: i32,
6131}
6132
6133#[PostgresTable]
6134pub struct Users {
6135    #[column(primary)]
6136    pub id: i32,
6137}
6138"#;
6139
6140        let parsed = SchemaParser::parse(code);
6141        let mut snapshot = Snapshot::from_parse_result(&parsed, BaseDialect::PostgreSQL, None);
6142        let filters = SnapshotFilters {
6143            tables: None,
6144            schemas: None,
6145            extensions: Some(vec![Extension::Postgis]),
6146            roles: None,
6147        };
6148
6149        apply_snapshot_filters(&mut snapshot, crate::config::Dialect::Postgresql, &filters)
6150            .expect("apply filters");
6151
6152        let remaining_tables = match snapshot {
6153            Snapshot::Postgres(s) => s
6154                .ddl
6155                .iter()
6156                .filter_map(|e| {
6157                    if let drizzle_types::postgres::ddl::PostgresEntity::Table(t) = e {
6158                        Some((t.schema.to_string(), t.name.to_string()))
6159                    } else {
6160                        None
6161                    }
6162                })
6163                .collect::<Vec<_>>(),
6164            _ => panic!("expected postgres snapshot"),
6165        };
6166
6167        assert_eq!(
6168            remaining_tables,
6169            vec![("public".to_string(), "users".to_string())]
6170        );
6171    }
6172
6173    #[test]
6174    fn format_migration_sql_respects_breakpoints_flag() {
6175        let sql = vec![
6176            "CREATE TABLE users(id integer);".to_string(),
6177            "CREATE INDEX users_id_idx ON users(id);".to_string(),
6178        ];
6179
6180        let with_breakpoints = format_migration_sql(&sql, true);
6181        assert_eq!(
6182            with_breakpoints,
6183            "CREATE TABLE users(id integer);\n--> statement-breakpoint\nCREATE INDEX users_id_idx ON users(id);"
6184        );
6185
6186        let without_breakpoints = format_migration_sql(&sql, false);
6187        assert_eq!(
6188            without_breakpoints,
6189            "CREATE TABLE users(id integer);\n\nCREATE INDEX users_id_idx ON users(id);"
6190        );
6191
6192        let empty = format_migration_sql(&[], false);
6193        assert_eq!(empty, "-- No tables to create (empty database)\n");
6194    }
6195
6196    #[test]
6197    fn regenerate_sqlite_schema_applies_introspect_casing() {
6198        use crate::config::{Casing, IntrospectCasing};
6199        use drizzle_migrations::parser::SchemaParser;
6200        use drizzle_types::Dialect as BaseDialect;
6201
6202        let code = r#"
6203#[SQLiteTable]
6204pub struct AuditLogs {
6205    #[column(primary)]
6206    pub id: i64,
6207    pub user_name: String,
6208}
6209"#;
6210
6211        let parsed = SchemaParser::parse(code);
6212        let snapshot =
6213            Snapshot::from_parse_result(&parsed, BaseDialect::SQLite, Some(Casing::SnakeCase));
6214
6215        let mut camel = IntrospectResult {
6216            schema_code: String::new(),
6217            table_count: 0,
6218            index_count: 0,
6219            view_count: 0,
6220            warnings: Vec::new(),
6221            mysql_catalog_defaults: None,
6222            snapshot: snapshot.clone(),
6223            snapshot_path: std::path::PathBuf::new(),
6224        };
6225        regenerate_schema_from_snapshot(
6226            &mut camel,
6227            crate::config::Dialect::Sqlite,
6228            Some(IntrospectCasing::Camel),
6229        );
6230
6231        assert_eq!(
6232            camel.schema_code,
6233            "\
6234//! Auto-generated SQLite schema from introspection
6235//!
6236//! Schema introspected from filtered database objects
6237
6238use drizzle::sqlite::prelude::*;
6239
6240#[SQLiteTable]
6241pub struct AuditLogs {
6242    #[column(primary)]
6243    pub id: i64,
6244    pub userName: String,
6245}
6246
6247#[derive(SQLiteSchema)]
6248pub struct Schema {
6249    pub auditLogs: AuditLogs,
6250}
6251"
6252        );
6253
6254        let mut preserve = IntrospectResult {
6255            schema_code: String::new(),
6256            table_count: 0,
6257            index_count: 0,
6258            view_count: 0,
6259            warnings: Vec::new(),
6260            mysql_catalog_defaults: None,
6261            snapshot,
6262            snapshot_path: std::path::PathBuf::new(),
6263        };
6264        regenerate_schema_from_snapshot(
6265            &mut preserve,
6266            crate::config::Dialect::Sqlite,
6267            Some(IntrospectCasing::Preserve),
6268        );
6269
6270        assert_eq!(
6271            preserve.schema_code,
6272            "\
6273//! Auto-generated SQLite schema from introspection
6274//!
6275//! Schema introspected from filtered database objects
6276
6277use drizzle::sqlite::prelude::*;
6278
6279#[SQLiteTable]
6280pub struct AuditLogs {
6281    #[column(primary)]
6282    pub id: i64,
6283    pub user_name: String,
6284}
6285
6286#[derive(SQLiteSchema)]
6287pub struct Schema {
6288    pub audit_logs: AuditLogs,
6289}
6290"
6291        );
6292    }
6293
6294    #[test]
6295    fn regenerate_postgres_schema_applies_introspect_casing() {
6296        use crate::config::{Casing, IntrospectCasing};
6297        use drizzle_migrations::parser::SchemaParser;
6298        use drizzle_types::Dialect as BaseDialect;
6299
6300        let code = r#"
6301#[PostgresTable]
6302pub struct AuditLogs {
6303    #[column(primary)]
6304    pub id: i32,
6305    pub user_name: String,
6306}
6307"#;
6308
6309        let parsed = SchemaParser::parse(code);
6310        let snapshot =
6311            Snapshot::from_parse_result(&parsed, BaseDialect::PostgreSQL, Some(Casing::SnakeCase));
6312
6313        let mut camel = IntrospectResult {
6314            schema_code: String::new(),
6315            table_count: 0,
6316            index_count: 0,
6317            view_count: 0,
6318            warnings: Vec::new(),
6319            mysql_catalog_defaults: None,
6320            snapshot: snapshot.clone(),
6321            snapshot_path: std::path::PathBuf::new(),
6322        };
6323        regenerate_schema_from_snapshot(
6324            &mut camel,
6325            crate::config::Dialect::Postgresql,
6326            Some(IntrospectCasing::Camel),
6327        );
6328
6329        assert_eq!(
6330            camel.schema_code,
6331            "\
6332//! Auto-generated PostgreSQL schema from introspection
6333//!
6334//! Schema introspected from filtered database objects
6335
6336use drizzle::postgres::prelude::*;
6337
6338#[PostgresTable]
6339pub struct AuditLogs {
6340    #[column(primary)]
6341    pub id: i32,
6342    pub userName: String,
6343}
6344
6345#[derive(PostgresSchema)]
6346pub struct Schema {
6347    pub auditLogs: AuditLogs,
6348}
6349"
6350        );
6351
6352        let mut preserve = IntrospectResult {
6353            schema_code: String::new(),
6354            table_count: 0,
6355            index_count: 0,
6356            view_count: 0,
6357            warnings: Vec::new(),
6358            mysql_catalog_defaults: None,
6359            snapshot,
6360            snapshot_path: std::path::PathBuf::new(),
6361        };
6362        regenerate_schema_from_snapshot(
6363            &mut preserve,
6364            crate::config::Dialect::Postgresql,
6365            Some(IntrospectCasing::Preserve),
6366        );
6367
6368        assert_eq!(
6369            preserve.schema_code,
6370            "\
6371//! Auto-generated PostgreSQL schema from introspection
6372//!
6373//! Schema introspected from filtered database objects
6374
6375use drizzle::postgres::prelude::*;
6376
6377#[PostgresTable]
6378pub struct AuditLogs {
6379    #[column(primary)]
6380    pub id: i32,
6381    pub user_name: String,
6382}
6383
6384#[derive(PostgresSchema)]
6385pub struct Schema {
6386    pub audit_logs: AuditLogs,
6387}
6388"
6389        );
6390    }
6391
6392    #[cfg(feature = "postgres-sync")]
6393    #[test]
6394    fn postgres_sync_migrate_applies_concurrent_index_without_transaction() {
6395        use drizzle_migrations::{Migration, Migrations};
6396        use drizzle_types::Dialect;
6397
6398        let creds = test_postgres_creds();
6399        let mut setup_client = match connect_postgres_sync(&creds) {
6400            Ok(c) => c,
6401            Err(e) => {
6402                eprintln!(
6403                    "Skipping postgres_sync_migrate_applies_concurrent_index_without_transaction: {}",
6404                    e
6405                );
6406                return;
6407            }
6408        };
6409
6410        let table = unique_pg_name("cli_sync_users");
6411        let index = format!("{}_email_idx", table);
6412        let migration_schema = unique_pg_name("cli_sync_mig");
6413
6414        setup_client
6415            .batch_execute(&format!(
6416                "DROP TABLE IF EXISTS \"{table}\" CASCADE; \
6417                 CREATE TABLE \"{table}\" (id integer, email text NOT NULL); \
6418                 INSERT INTO \"{table}\" (id, email) VALUES (1, 'a@example.com');"
6419            ))
6420            .expect("setup table for concurrent index test");
6421        drop(setup_client);
6422
6423        let migration_tag = format!("20260212000000_{table}");
6424        let migration_sql =
6425            format!("CREATE INDEX CONCURRENTLY \"{index}\" ON \"{table}\" (\"email\");");
6426        let set = Migrations::with_tracking(
6427            vec![Migration::new(&migration_tag, &migration_sql)],
6428            Dialect::PostgreSQL,
6429            drizzle_migrations::Tracking::POSTGRES.schema(migration_schema.clone()),
6430        );
6431
6432        let result = run_postgres_sync_migrations(&set, &creds, false)
6433            .expect("sync migration with concurrent index should succeed");
6434        assert_eq!(result.applied_count, 1);
6435
6436        let mut verify_client = connect_postgres_sync(&creds).expect("reconnect for verification");
6437        let exists: i64 = verify_client
6438            .query_one(
6439                "SELECT COUNT(*)::bigint FROM pg_indexes \
6440                 WHERE schemaname = 'public' AND tablename = $1 AND indexname = $2",
6441                &[&table, &index],
6442            )
6443            .expect("query pg_indexes")
6444            .get(0);
6445        assert_eq!(exists, 1, "concurrent index was not created");
6446
6447        let _ = verify_client.batch_execute(&format!(
6448            "DROP TABLE IF EXISTS \"{table}\" CASCADE; \
6449             DROP SCHEMA IF EXISTS \"{migration_schema}\" CASCADE;"
6450        ));
6451    }
6452
6453    #[cfg(feature = "postgres-sync")]
6454    #[test]
6455    fn postgres_sync_migrate_upgrades_legacy_tracking_table_and_applies_pending() {
6456        use drizzle_migrations::{Migration, Migrations};
6457        use drizzle_types::Dialect;
6458
6459        let creds = test_postgres_creds();
6460        let mut client = connect_postgres_sync(&creds)
6461            .expect("connect postgres for legacy tracking upgrade test");
6462
6463        let applied_table = unique_pg_name("cli_sync_applied");
6464        let pending_table = unique_pg_name("cli_sync_pending");
6465        let migration_schema = unique_pg_name("cli_sync_tracking");
6466
6467        client
6468            .batch_execute(&format!(
6469                "DROP TABLE IF EXISTS \"{applied_table}\" CASCADE; \
6470                 DROP TABLE IF EXISTS \"{pending_table}\" CASCADE; \
6471                 DROP SCHEMA IF EXISTS \"{migration_schema}\" CASCADE; \
6472                 CREATE SCHEMA \"{migration_schema}\"; \
6473                 CREATE TABLE \"{migration_schema}\".\"__drizzle_migrations\" (id SERIAL PRIMARY KEY, hash TEXT NOT NULL, created_at BIGINT); \
6474                 CREATE TABLE \"{applied_table}\" (id integer primary key);"
6475            ))
6476            .expect("setup legacy postgres tracking metadata");
6477
6478        let first = Migration::new(
6479            &format!("20230331141203_{applied_table}"),
6480            &format!("CREATE TABLE \"{applied_table}\" (id integer primary key);"),
6481        );
6482        let second = Migration::new(
6483            &format!("20230331141204_{pending_table}"),
6484            &format!("CREATE TABLE \"{pending_table}\" (id integer primary key);"),
6485        );
6486        let set = Migrations::with_tracking(
6487            vec![first.clone(), second.clone()],
6488            Dialect::PostgreSQL,
6489            drizzle_migrations::Tracking::POSTGRES.schema(migration_schema.clone()),
6490        );
6491
6492        client
6493            .execute(
6494                &format!(
6495                    "INSERT INTO \"{migration_schema}\".\"__drizzle_migrations\" (hash, created_at) VALUES ($1, $2)"
6496                ),
6497                &[&first.hash(), &first.created_at()],
6498            )
6499            .expect("insert legacy applied migration row");
6500        drop(client);
6501
6502        let result = run_postgres_sync_migrations(&set, &creds, false)
6503            .expect("sync migration upgrade should succeed");
6504        assert_eq!(result.applied_count, 1);
6505        assert_eq!(result.applied_migrations, vec![second.hash().to_string()]);
6506
6507        let mut verify_client = connect_postgres_sync(&creds).expect("reconnect for verification");
6508        let columns: Vec<String> = verify_client
6509            .query(
6510                "SELECT column_name FROM information_schema.columns WHERE table_schema = $1 AND table_name = '__drizzle_migrations' ORDER BY ordinal_position",
6511                &[&migration_schema],
6512            )
6513            .expect("query upgraded tracking columns")
6514            .into_iter()
6515            .map(|row| row.get(0))
6516            .collect();
6517        assert_eq!(
6518            columns,
6519            vec!["id", "hash", "created_at", "name", "applied_at"]
6520        );
6521
6522        let rows: Vec<(String, i64, String, Option<String>)> = verify_client
6523            .query(
6524                &format!(
6525                    "SELECT hash, created_at, name, applied_at::text FROM \"{migration_schema}\".\"__drizzle_migrations\" ORDER BY id ASC"
6526                ),
6527                &[],
6528            )
6529            .expect("query upgraded metadata rows")
6530            .into_iter()
6531            .map(|row| (row.get(0), row.get(1), row.get(2), row.get(3)))
6532            .collect();
6533        assert_eq!(rows.len(), 2);
6534        assert_eq!(rows[0].0, first.hash());
6535        assert_eq!(rows[0].1, first.created_at());
6536        assert_eq!(rows[0].2, first.name());
6537        assert!(
6538            rows[0].3.is_some(),
6539            "legacy rows get applied_at backfilled so they cannot read as interrupted"
6540        );
6541        assert_eq!(rows[1].0, second.hash());
6542        assert_eq!(rows[1].1, second.created_at());
6543        assert_eq!(rows[1].2, second.name());
6544        assert!(rows[1].3.is_some());
6545
6546        let pending_exists: i64 = verify_client
6547            .query_one(
6548                "SELECT COUNT(*)::bigint FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1",
6549                &[&pending_table],
6550            )
6551            .expect("query pending table")
6552            .get(0);
6553        assert_eq!(pending_exists, 1);
6554
6555        let _ = verify_client.batch_execute(&format!(
6556            "DROP TABLE IF EXISTS \"{applied_table}\" CASCADE; \
6557             DROP TABLE IF EXISTS \"{pending_table}\" CASCADE; \
6558             DROP SCHEMA IF EXISTS \"{migration_schema}\" CASCADE;"
6559        ));
6560    }
6561
6562    /// An interrupted pre-transactional upgrade left `name` without
6563    /// `applied_at`; the next run finishes it instead of failing on the
6564    /// missing column. Statement errors also carry the server message.
6565    #[cfg(feature = "postgres-sync")]
6566    #[test]
6567    fn postgres_sync_migrate_finishes_half_upgraded_tracking_table() {
6568        use drizzle_migrations::{Migration, Migrations};
6569        use drizzle_types::Dialect;
6570
6571        let creds = test_postgres_creds();
6572        let mut client = connect_postgres_sync(&creds).expect("connect postgres");
6573        let migration_schema = unique_pg_name("cli_sync_half_upgraded");
6574        client
6575            .batch_execute(&format!(
6576                "DROP SCHEMA IF EXISTS \"{migration_schema}\" CASCADE; \
6577                 CREATE SCHEMA \"{migration_schema}\"; \
6578                 CREATE TABLE \"{migration_schema}\".\"__drizzle_migrations\" \
6579                     (id SERIAL PRIMARY KEY, hash TEXT NOT NULL, created_at BIGINT, name TEXT); \
6580                 INSERT INTO \"{migration_schema}\".\"__drizzle_migrations\" (hash, created_at) \
6581                     VALUES ('half_hash', 1680271923456);"
6582            ))
6583            .expect("reproduce the half-upgraded table");
6584        drop(client);
6585
6586        let applied = Migration::with_hash(
6587            "20230331141203_cli_half",
6588            "half_hash",
6589            1_680_271_923_000,
6590            vec![],
6591        );
6592        let broken = Migration::new(
6593            "20230401000000_cli_broken",
6594            &format!("INSERT INTO \"{migration_schema}\".missing_table VALUES (1);"),
6595        );
6596        let tracking = drizzle_migrations::Tracking::POSTGRES.schema(migration_schema.clone());
6597
6598        let set =
6599            Migrations::with_tracking(vec![applied.clone()], Dialect::PostgreSQL, tracking.clone());
6600        let result = run_postgres_sync_migrations(&set, &creds, false)
6601            .expect("a half-upgraded tracking table is completed");
6602        assert_eq!(result.applied_count, 0);
6603
6604        let set = Migrations::with_tracking(vec![applied, broken], Dialect::PostgreSQL, tracking);
6605        let error = run_postgres_sync_migrations(&set, &creds, false)
6606            .expect_err("the broken migration fails")
6607            .to_string();
6608        assert!(error.contains("20230401000000_cli_broken"), "{error}");
6609        assert!(
6610            error.contains("does not exist"),
6611            "server message is kept: {error}"
6612        );
6613
6614        let mut verify_client = connect_postgres_sync(&creds).expect("reconnect");
6615        let row = verify_client
6616            .query_one(
6617                &format!(
6618                    "SELECT name, applied_at IS NOT NULL FROM \"{migration_schema}\".\"__drizzle_migrations\""
6619                ),
6620                &[],
6621            )
6622            .expect("upgraded row");
6623        assert_eq!(row.get::<_, String>(0), "20230331141203_cli_half");
6624        assert!(row.get::<_, bool>(1));
6625        let _ = verify_client.batch_execute(&format!(
6626            "DROP SCHEMA IF EXISTS \"{migration_schema}\" CASCADE;"
6627        ));
6628    }
6629
6630    /// Concurrent first runs take the advisory lock before creating the
6631    /// tracking schema, so none of them races on `CREATE SCHEMA`.
6632    #[cfg(feature = "postgres-sync")]
6633    #[test]
6634    fn postgres_sync_concurrent_first_migrate_creates_tracking_schema_once() {
6635        use drizzle_migrations::{Migration, Migrations};
6636        use drizzle_types::Dialect;
6637
6638        let creds = test_postgres_creds();
6639        let migration_schema = unique_pg_name("cli_sync_concurrent_first");
6640        let table = unique_pg_name("cli_sync_concurrent_table");
6641        let set = Migrations::with_tracking(
6642            vec![Migration::new(
6643                "20240101000000_cli_concurrent",
6644                &format!("CREATE TABLE \"{table}\" (id integer);"),
6645            )],
6646            Dialect::PostgreSQL,
6647            drizzle_migrations::Tracking::POSTGRES.schema(migration_schema.clone()),
6648        );
6649
6650        let barrier = std::sync::Barrier::new(6);
6651        let applied: usize = std::thread::scope(|scope| {
6652            let handles = (0..6)
6653                .map(|_| {
6654                    scope.spawn(|| {
6655                        barrier.wait();
6656                        run_postgres_sync_migrations(&set, &creds, false)
6657                            .map(|result| result.applied_count)
6658                            .map_err(|error| error.to_string())
6659                    })
6660                })
6661                .collect::<Vec<_>>();
6662            handles
6663                .into_iter()
6664                .map(|handle| {
6665                    handle
6666                        .join()
6667                        .expect("thread")
6668                        .unwrap_or_else(|error| panic!("{error}"))
6669                })
6670                .sum()
6671        });
6672        assert_eq!(applied, 1);
6673
6674        let mut verify_client = connect_postgres_sync(&creds).expect("reconnect");
6675        let _ = verify_client.batch_execute(&format!(
6676            "DROP TABLE IF EXISTS \"{table}\" CASCADE; \
6677             DROP SCHEMA IF EXISTS \"{migration_schema}\" CASCADE;"
6678        ));
6679    }
6680
6681    #[cfg(feature = "postgres-sync")]
6682    #[test]
6683    fn postgres_sync_migrate_upgrade_rejects_unmatched_legacy_rows() {
6684        use drizzle_migrations::{Migration, Migrations};
6685        use drizzle_types::Dialect;
6686
6687        let creds = test_postgres_creds();
6688        let mut client =
6689            connect_postgres_sync(&creds).expect("connect postgres for unmatched legacy row test");
6690
6691        let migration_schema = unique_pg_name("cli_sync_tracking_unmatched");
6692        client
6693            .batch_execute(&format!(
6694                "DROP SCHEMA IF EXISTS \"{migration_schema}\" CASCADE; \
6695                 CREATE SCHEMA \"{migration_schema}\"; \
6696                 CREATE TABLE \"{migration_schema}\".\"__drizzle_migrations\" (id SERIAL PRIMARY KEY, hash TEXT NOT NULL, created_at BIGINT);"
6697            ))
6698            .expect("setup unmatched legacy metadata");
6699
6700        let migration = Migration::new(
6701            "20230331141203_cli_sync_first",
6702            "CREATE TABLE \"cli_sync_unmatched_target\" (id integer primary key);",
6703        );
6704        let set = Migrations::with_tracking(
6705            vec![migration],
6706            Dialect::PostgreSQL,
6707            drizzle_migrations::Tracking::POSTGRES.schema(migration_schema.clone()),
6708        );
6709
6710        client
6711            .execute(
6712                &format!(
6713                    "INSERT INTO \"{migration_schema}\".\"__drizzle_migrations\" (hash, created_at) VALUES ($1, $2)"
6714                ),
6715                &[&"unknown_hash", &1_680_271_924_000_i64],
6716            )
6717            .expect("insert unmatched legacy row");
6718        drop(client);
6719
6720        let err = run_postgres_sync_migrations(&set, &creds, false)
6721            .expect_err("unmatched legacy metadata should fail");
6722        assert!(err.to_string().contains("do not match local migrations"));
6723
6724        let mut verify_client = connect_postgres_sync(&creds).expect("reconnect for verification");
6725        let columns: Vec<String> = verify_client
6726            .query(
6727                "SELECT column_name FROM information_schema.columns WHERE table_schema = $1 AND table_name = '__drizzle_migrations' ORDER BY ordinal_position",
6728                &[&migration_schema],
6729            )
6730            .expect("query legacy tracking columns")
6731            .into_iter()
6732            .map(|row| row.get(0))
6733            .collect();
6734        assert_eq!(columns, vec!["id", "hash", "created_at"]);
6735
6736        let _ = verify_client.batch_execute(&format!(
6737            "DROP TABLE IF EXISTS \"cli_sync_unmatched_target\" CASCADE; \
6738             DROP SCHEMA IF EXISTS \"{migration_schema}\" CASCADE;"
6739        ));
6740    }
6741
6742    #[cfg(feature = "tokio-postgres")]
6743    #[test]
6744    fn tokio_postgres_migrate_applies_concurrent_index_without_transaction() {
6745        use drizzle_migrations::{Migration, Migrations};
6746        use drizzle_types::Dialect;
6747
6748        let creds = test_postgres_creds();
6749        let rt = tokio::runtime::Builder::new_current_thread()
6750            .enable_all()
6751            .build()
6752            .expect("create tokio runtime");
6753
6754        rt.block_on(async {
6755            let client = match connect_postgres_async(&creds).await {
6756                Ok(client) => client,
6757                Err(e) => {
6758                    eprintln!(
6759                        "Skipping tokio_postgres_migrate_applies_concurrent_index_without_transaction: {}",
6760                        e
6761                    );
6762                    return;
6763                }
6764            };
6765
6766            let table = unique_pg_name("cli_async_users");
6767            let index = format!("{}_email_idx", table);
6768            let migration_schema = unique_pg_name("cli_async_mig");
6769
6770            client
6771                .batch_execute(&format!(
6772                    "DROP TABLE IF EXISTS \"{table}\" CASCADE; \
6773                     CREATE TABLE \"{table}\" (id integer, email text NOT NULL); \
6774                     INSERT INTO \"{table}\" (id, email) VALUES (1, 'a@example.com');"
6775                ))
6776                .await
6777                .expect("setup table for async concurrent index test");
6778
6779            let migration_tag = format!("20260212000000_{table}");
6780            let migration_sql = format!(
6781                "CREATE INDEX CONCURRENTLY \"{index}\" ON \"{table}\" (\"email\");"
6782            );
6783            let set = Migrations::with_tracking(
6784                vec![Migration::new(&migration_tag, &migration_sql)],
6785                Dialect::PostgreSQL,
6786                drizzle_migrations::Tracking::POSTGRES.schema(migration_schema.clone()),
6787            );
6788
6789            let result = run_postgres_async_inner(&set, &creds, false)
6790                .await
6791                .expect("async migration with concurrent index should succeed");
6792            assert_eq!(result.applied_count, 1);
6793
6794            let exists: i64 = client
6795                .query_one(
6796                    "SELECT COUNT(*)::bigint FROM pg_indexes \
6797                     WHERE schemaname = 'public' AND tablename = $1 AND indexname = $2",
6798                    &[&table, &index],
6799                )
6800                .await
6801                .expect("query pg_indexes")
6802                .get(0);
6803            assert_eq!(exists, 1, "async concurrent index was not created");
6804
6805            let _ = client
6806                .batch_execute(&format!(
6807                    "DROP TABLE IF EXISTS \"{table}\" CASCADE; \
6808                     DROP SCHEMA IF EXISTS \"{migration_schema}\" CASCADE;"
6809                ))
6810                .await;
6811        });
6812    }
6813
6814    #[cfg(feature = "tokio-postgres")]
6815    #[test]
6816    fn tokio_postgres_migrate_upgrades_legacy_tracking_table_and_applies_pending() {
6817        use drizzle_migrations::{Migration, Migrations};
6818        use drizzle_types::Dialect;
6819
6820        let creds = test_postgres_creds();
6821        let rt = tokio::runtime::Builder::new_current_thread()
6822            .enable_all()
6823            .build()
6824            .expect("create tokio runtime");
6825
6826        rt.block_on(async {
6827            let client = connect_postgres_async(&creds)
6828                .await
6829                .expect("connect tokio-postgres for legacy tracking upgrade test");
6830
6831            let applied_table = unique_pg_name("cli_async_applied");
6832            let pending_table = unique_pg_name("cli_async_pending");
6833            let migration_schema = unique_pg_name("cli_async_tracking");
6834
6835            client
6836                .batch_execute(&format!(
6837                    "DROP TABLE IF EXISTS \"{applied_table}\" CASCADE; \
6838                     DROP TABLE IF EXISTS \"{pending_table}\" CASCADE; \
6839                     DROP SCHEMA IF EXISTS \"{migration_schema}\" CASCADE; \
6840                     CREATE SCHEMA \"{migration_schema}\"; \
6841                     CREATE TABLE \"{migration_schema}\".\"__drizzle_migrations\" (id SERIAL PRIMARY KEY, hash TEXT NOT NULL, created_at BIGINT); \
6842                     CREATE TABLE \"{applied_table}\" (id integer primary key);"
6843                ))
6844                .await
6845                .expect("setup legacy postgres tracking metadata");
6846
6847            let first = Migration::new(
6848                &format!("20230331141203_{applied_table}"),
6849                &format!("CREATE TABLE \"{applied_table}\" (id integer primary key);"),
6850            );
6851            let second = Migration::new(
6852                &format!("20230331141204_{pending_table}"),
6853                &format!("CREATE TABLE \"{pending_table}\" (id integer primary key);"),
6854            );
6855            let set = Migrations::with_tracking(
6856                vec![first.clone(), second.clone()],
6857                Dialect::PostgreSQL,
6858                drizzle_migrations::Tracking::POSTGRES.schema(migration_schema.clone()),
6859            );
6860
6861            client
6862                .execute(
6863                    &format!(
6864                        "INSERT INTO \"{migration_schema}\".\"__drizzle_migrations\" (hash, created_at) VALUES ($1, $2)"
6865                    ),
6866                    &[&first.hash(), &first.created_at()],
6867                )
6868                .await
6869                .expect("insert legacy applied migration row");
6870
6871            let result = run_postgres_async_inner(&set, &creds, false)
6872                .await
6873                .expect("async migration upgrade should succeed");
6874            assert_eq!(result.applied_count, 1);
6875            assert_eq!(result.applied_migrations, vec![second.hash().to_string()]);
6876
6877            let columns: Vec<String> = client
6878                .query(
6879                    "SELECT column_name FROM information_schema.columns WHERE table_schema = $1 AND table_name = '__drizzle_migrations' ORDER BY ordinal_position",
6880                    &[&migration_schema],
6881                )
6882                .await
6883                .expect("query upgraded tracking columns")
6884                .into_iter()
6885                .map(|row| row.get(0))
6886                .collect();
6887            assert_eq!(
6888                columns,
6889                vec!["id", "hash", "created_at", "name", "applied_at"]
6890            );
6891
6892            let rows: Vec<(String, i64, String, Option<String>)> = client
6893                .query(
6894                    &format!(
6895                        "SELECT hash, created_at, name, applied_at::text FROM \"{migration_schema}\".\"__drizzle_migrations\" ORDER BY id ASC"
6896                    ),
6897                    &[],
6898                )
6899                .await
6900                .expect("query upgraded metadata rows")
6901                .into_iter()
6902                .map(|row| (row.get(0), row.get(1), row.get(2), row.get(3)))
6903                .collect();
6904            assert_eq!(rows.len(), 2);
6905            assert_eq!(rows[0].0, first.hash());
6906            assert_eq!(rows[0].1, first.created_at());
6907            assert_eq!(rows[0].2, first.name());
6908            assert!(
6909                rows[0].3.is_some(),
6910                "legacy rows get applied_at backfilled so they cannot read as interrupted"
6911            );
6912            assert_eq!(rows[1].0, second.hash());
6913            assert_eq!(rows[1].1, second.created_at());
6914            assert_eq!(rows[1].2, second.name());
6915            assert!(rows[1].3.is_some());
6916
6917            let pending_exists: i64 = client
6918                .query_one(
6919                    "SELECT COUNT(*)::bigint FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1",
6920                    &[&pending_table],
6921                )
6922                .await
6923                .expect("query pending table")
6924                .get(0);
6925            assert_eq!(pending_exists, 1);
6926
6927            let _ = client
6928                .batch_execute(&format!(
6929                    "DROP TABLE IF EXISTS \"{applied_table}\" CASCADE; \
6930                     DROP TABLE IF EXISTS \"{pending_table}\" CASCADE; \
6931                     DROP SCHEMA IF EXISTS \"{migration_schema}\" CASCADE;"
6932                ))
6933                .await;
6934        });
6935    }
6936}