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