Skip to main content

drizzle_cli/db/
mod.rs

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