Skip to main content

drizzle_cli/commands/
import.rs

1//! `drizzle import`: ports a TypeScript drizzle-orm project by writing the
2//! Rust schema from its drizzle-kit migration snapshots.
3//!
4//! drizzle-kit already serializes the TypeScript schema into each migration's
5//! snapshot, so no TypeScript is read. The newest snapshot is upgraded in
6//! memory to the current format (the same chain `drizzle up` runs) and
7//! written as Rust with the codegen behind `drizzle introspect`.
8//!
9//! Accepted inputs:
10//!
11//! * a drizzle-kit `out` folder in the folder layout
12//!   (`<tag>/migration.sql` + `<tag>/snapshot.json`): the last folder wins;
13//! * a folder in the older journal layout (`NNNN_tag.sql` +
14//!   `meta/_journal.json` + `meta/NNNN_snapshot.json`): the last journal
15//!   entry's snapshot wins;
16//! * a single `snapshot.json` file.
17//!
18//! The migration files are left where they are unless `--upgrade` is given.
19
20use crate::codegen::{SchemaCode, SchemaCodeOptions, schema_code};
21use crate::config::{CONFIG_FILE, Config, Dialect, IntrospectCasing, Schema};
22use crate::error::CliError;
23use crate::output;
24use drizzle_migrations::schema::Snapshot;
25use drizzle_migrations::upgrade::upgrade_to_latest;
26use drizzle_migrations::version::{is_supported_version, needs_upgrade, snapshot_version};
27use serde_json::Value;
28use std::fmt::Display;
29use std::fs;
30use std::path::{Path, PathBuf};
31
32/// Schema path used when neither `--out` nor a config names one.
33const DEFAULT_SCHEMA_PATH: &str = "src/schema.rs";
34
35#[derive(clap::Args, Debug, Clone)]
36pub struct ImportOptions {
37    /// drizzle-kit migrations folder (either layout) or a snapshot.json file
38    #[arg(value_name = "PATH")]
39    pub path: PathBuf,
40
41    /// File to write the schema to, or `-` for stdout [default: the config's
42    /// `schema`, else src/schema.rs]
43    #[arg(long, value_name = "FILE")]
44    pub out: Option<PathBuf>,
45
46    /// Overwrite the output file if it exists
47    #[arg(long)]
48    pub force: bool,
49
50    /// Dialect of the snapshot (default: its `dialect` field)
51    #[arg(long)]
52    pub dialect: Option<Dialect>,
53
54    /// Casing for Rust field names (camel or preserve) [default: the config's
55    /// `introspect.casing` when set, else snake_case]
56    #[arg(long)]
57    pub casing: Option<IntrospectCasing>,
58
59    /// Name of the generated schema struct
60    #[arg(long, value_name = "NAME", default_value = "Schema")]
61    pub schema_name: String,
62
63    /// Convert a journal-layout folder to the folder layout in place, as
64    /// `drizzle up` does
65    #[arg(long)]
66    pub upgrade: bool,
67
68    /// Write a starter drizzle.config.toml for the imported project when
69    /// none exists
70    #[arg(long)]
71    pub init_config: bool,
72}
73
74/// How the migrations next to the imported snapshot are laid out.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76enum Layout {
77    /// `<tag>/migration.sql` + `<tag>/snapshot.json` (drizzle-kit 1.x and
78    /// drizzle-rs).
79    Folders,
80    /// `NNNN_tag.sql` + `meta/_journal.json` (drizzle-kit 0.x).
81    Journal,
82}
83
84/// The snapshot to import and the migrations folder it belongs to.
85#[derive(Debug)]
86struct Source {
87    snapshot_path: PathBuf,
88    /// The migrations folder and its layout, when the snapshot sits in one.
89    migrations: Option<(PathBuf, Layout)>,
90}
91
92/// Where human-readable messages go: stdout, or stderr when the schema
93/// itself is printed to stdout.
94#[derive(Clone, Copy)]
95struct Messages {
96    to_stderr: bool,
97}
98
99impl Messages {
100    fn line(self, text: impl Display) {
101        if self.to_stderr {
102            eprintln!("{text}");
103        } else {
104            println!("{text}");
105        }
106    }
107}
108
109/// Runs `drizzle import` (see the [module docs](self)).
110///
111/// `config` is the loaded `drizzle.config.toml`, if there is one; it only
112/// supplies the default output path and casing.
113///
114/// # Errors
115///
116/// Returns [`CliError`] if `db_name` does not match the config, no snapshot
117/// is found at the path, the snapshot is not valid JSON, its dialect is
118/// unknown or unsupported, its version is too old or too new, it does not
119/// convert to the current format, the output file exists without
120/// `--force`, or a file cannot be written.
121pub fn run(
122    config: Option<&Config>,
123    db_name: Option<&str>,
124    opts: &ImportOptions,
125) -> Result<(), CliError> {
126    let db = config.map(|c| c.database(db_name)).transpose()?;
127    let to_stdout = opts.out.as_deref() == Some(Path::new("-"));
128    let say = Messages {
129        to_stderr: to_stdout,
130    };
131    if to_stdout && opts.upgrade {
132        return Err(CliError::Other(
133            "--upgrade cannot be combined with --out - (it prints to stdout)".into(),
134        ));
135    }
136
137    let source = find_snapshot(&opts.path)?;
138    let contents = fs::read_to_string(&source.snapshot_path)
139        .map_err(|e| CliError::IoError(format!("{}: {e}", source.snapshot_path.display())))?;
140    let json: Value = serde_json::from_str(&contents).map_err(|e| {
141        CliError::Other(format!(
142            "Invalid JSON in {}: {e}",
143            source.snapshot_path.display()
144        ))
145    })?;
146
147    let detected = snapshot_dialect(&json, &source.snapshot_path);
148    let dialect = match (opts.dialect, detected) {
149        // `--dialect turso` on a `sqlite` snapshot, or a snapshot without a
150        // usable `dialect` field.
151        (Some(wanted), Ok(found)) if wanted.to_base() != found.to_base() => {
152            return Err(CliError::Other(format!(
153                "{} is a {} snapshot; --dialect {} does not match it",
154                source.snapshot_path.display(),
155                found.as_str(),
156                wanted.as_str()
157            )));
158        }
159        (Some(wanted), _) => wanted,
160        (None, detected) => detected?,
161    };
162    let snapshot = load_snapshot(json, dialect, &source.snapshot_path)?;
163
164    let casing = opts.casing.or_else(|| {
165        db.and_then(|db| db.introspect.as_ref())
166            .map(|introspect| introspect.casing)
167    });
168    let module_doc = format!(
169        "Imported from the drizzle-kit snapshot {}",
170        forward_slashes(&source.snapshot_path)
171    );
172    let generated = schema_code(
173        &snapshot,
174        &SchemaCodeOptions {
175            casing,
176            module_doc: &module_doc,
177            schema_name: &opts.schema_name,
178        },
179    )?;
180
181    let out_path = if to_stdout {
182        None
183    } else {
184        Some(resolve_out_path(opts.out.as_deref(), db)?)
185    };
186
187    say.line(output::heading("Importing drizzle-kit snapshot..."));
188    say.line("");
189    say.line(format!(
190        "  {}: {}",
191        output::label("Snapshot"),
192        source.snapshot_path.display()
193    ));
194    say.line(format!(
195        "  {}: {}",
196        output::label("Dialect"),
197        dialect.as_str()
198    ));
199    say.line(format!(
200        "  {}: {}",
201        output::label("Output"),
202        out_path
203            .as_deref()
204            .map_or_else(|| "stdout".to_string(), |p| p.display().to_string())
205    ));
206
207    match &out_path {
208        None => print!("{}", generated.code),
209        Some(path) => write_schema(path, &generated.code, opts.force)?,
210    }
211
212    print_summary(say, &generated);
213
214    if opts.upgrade {
215        match &source.migrations {
216            Some((dir, Layout::Journal)) => {
217                say.line("");
218                let converted =
219                    crate::commands::upgrade::convert_legacy_layout(dir, dialect.to_base())?;
220                say.line(output::success(&format!(
221                    "Converted {converted} migration(s) in {} to the folder layout",
222                    dir.display()
223                )));
224            }
225            _ => say.line(output::warning(
226                "--upgrade: the snapshot is not in a journal-layout folder; nothing to convert",
227            )),
228        }
229    }
230
231    let migrations = source.migrations.as_ref().map(|(dir, layout)| {
232        let layout = if opts.upgrade {
233            Layout::Folders
234        } else {
235            *layout
236        };
237        (dir.as_path(), layout)
238    });
239
240    let wrote_config = if opts.init_config {
241        init_config(say, config, dialect, out_path.as_deref(), migrations)?
242    } else {
243        false
244    };
245
246    print_next_steps(
247        say,
248        &NextSteps {
249            config,
250            db_out: db.map(crate::config::DatabaseConfig::migrations_dir),
251            wrote_config,
252            dialect,
253            schema_path: out_path.as_deref(),
254            migrations,
255        },
256    );
257    print_manual_port_notes(say);
258
259    Ok(())
260}
261
262// =============================================================================
263// Finding the snapshot
264// =============================================================================
265
266fn find_snapshot(path: &Path) -> Result<Source, CliError> {
267    if path.is_file() {
268        return Ok(Source {
269            snapshot_path: path.to_path_buf(),
270            migrations: migrations_around_file(path),
271        });
272    }
273    if !path.is_dir() {
274        return Err(CliError::Other(format!(
275            "{} does not exist. Pass a drizzle-kit migrations folder (the `out` of drizzle.config.ts) or a snapshot.json file.",
276            path.display()
277        )));
278    }
279
280    let journal_path = path.join("meta").join("_journal.json");
281    if journal_path.is_file() {
282        return Ok(Source {
283            snapshot_path: last_journal_snapshot(path, &journal_path)?,
284            migrations: Some((path.to_path_buf(), Layout::Journal)),
285        });
286    }
287
288    let mut folders = Vec::new();
289    for entry in fs::read_dir(path).map_err(|e| CliError::IoError(e.to_string()))? {
290        let entry = entry.map_err(|e| CliError::IoError(e.to_string()))?;
291        let candidate = entry.path().join("snapshot.json");
292        if candidate.is_file() {
293            folders.push(entry.file_name());
294        }
295    }
296    // Folder names start with their creation timestamp, so the last name
297    // is the newest migration (the order drizzle-kit and `migrate` use).
298    folders.sort();
299    let Some(newest) = folders.last() else {
300        return Err(CliError::Other(format!(
301            "No drizzle-kit snapshots found in {}: expected <tag>/snapshot.json folders or meta/_journal.json. Pass the `out` folder of drizzle.config.ts, or a snapshot.json file.",
302            path.display()
303        )));
304    };
305    Ok(Source {
306        snapshot_path: path.join(newest).join("snapshot.json"),
307        migrations: Some((path.to_path_buf(), Layout::Folders)),
308    })
309}
310
311/// The snapshot of the last entry in a drizzle-kit journal.
312fn last_journal_snapshot(dir: &Path, journal_path: &Path) -> Result<PathBuf, CliError> {
313    let text = fs::read_to_string(journal_path).map_err(|e| CliError::IoError(e.to_string()))?;
314    let journal: Value = serde_json::from_str(&text)
315        .map_err(|e| CliError::Other(format!("Invalid JSON in {}: {e}", journal_path.display())))?;
316    let last = journal
317        .get("entries")
318        .and_then(Value::as_array)
319        .and_then(|entries| entries.last())
320        .ok_or_else(|| {
321            CliError::Other(format!(
322                "{} lists no migrations, so there is no snapshot to import",
323                journal_path.display()
324            ))
325        })?;
326    let idx = last.get("idx").and_then(Value::as_u64).ok_or_else(|| {
327        CliError::Other(format!(
328            "The last entry of {} has no 'idx'",
329            journal_path.display()
330        ))
331    })?;
332    let snapshot_path = dir.join("meta").join(format!("{idx:04}_snapshot.json"));
333    if !snapshot_path.is_file() {
334        return Err(CliError::Other(format!(
335            "{} is missing (named by the last entry of {})",
336            snapshot_path.display(),
337            journal_path.display()
338        )));
339    }
340    Ok(snapshot_path)
341}
342
343/// The migrations folder a snapshot file sits in, if any:
344/// `<out>/<tag>/snapshot.json` or `<out>/meta/NNNN_snapshot.json`.
345fn migrations_around_file(file: &Path) -> Option<(PathBuf, Layout)> {
346    let parent = file.parent()?;
347    let grandparent = parent.parent()?;
348    if parent.file_name().is_some_and(|name| name == "meta")
349        && parent.join("_journal.json").is_file()
350    {
351        return Some((grandparent.to_path_buf(), Layout::Journal));
352    }
353    if file.file_name().is_some_and(|name| name == "snapshot.json")
354        && parent.join("migration.sql").is_file()
355    {
356        return Some((grandparent.to_path_buf(), Layout::Folders));
357    }
358    None
359}
360
361// =============================================================================
362// Reading the snapshot
363// =============================================================================
364
365/// The dialect named by a snapshot's `dialect` field.
366fn snapshot_dialect(json: &Value, path: &Path) -> Result<Dialect, CliError> {
367    let Some(name) = json.get("dialect").and_then(Value::as_str) else {
368        return Err(CliError::Other(format!(
369            "{} has no \"dialect\" field; pass --dialect",
370            path.display()
371        )));
372    };
373    match name {
374        "sqlite" => Ok(Dialect::Sqlite),
375        "turso" => Ok(Dialect::Turso),
376        "postgresql" | "postgres" | "pg" => Ok(Dialect::Postgresql),
377        "mysql" => Ok(Dialect::Mysql),
378        other => Err(CliError::Other(format!(
379            "{} is a \"{other}\" snapshot; drizzle-rs supports sqlite, turso, postgresql and mysql",
380            path.display()
381        ))),
382    }
383}
384
385/// Checks the version, upgrades the document to the current format, and
386/// loads it.
387fn load_snapshot(json: Value, dialect: Dialect, path: &Path) -> Result<Snapshot, CliError> {
388    let base = dialect.to_base();
389    let version = match json.get("version") {
390        Some(Value::String(version)) => version.clone(),
391        Some(Value::Number(version)) => version.to_string(),
392        _ => {
393            return Err(CliError::Other(format!(
394                "{} has no snapshot \"version\"; is it a drizzle-kit snapshot?",
395                path.display()
396            )));
397        }
398    };
399    if !is_supported_version(base, &version) {
400        use drizzle_migrations::version::{
401            MYSQL_MIN_SUPPORTED_VERSION, POSTGRES_MIN_SUPPORTED_VERSION,
402            SQLITE_MIN_SUPPORTED_VERSION,
403        };
404        let min = match base {
405            drizzle_types::Dialect::SQLite => SQLITE_MIN_SUPPORTED_VERSION,
406            drizzle_types::Dialect::PostgreSQL => POSTGRES_MIN_SUPPORTED_VERSION,
407            drizzle_types::Dialect::MySQL => MYSQL_MIN_SUPPORTED_VERSION,
408        };
409        let latest = snapshot_version(base);
410        let hint = if needs_upgrade(base, &version) {
411            "Run `npx drizzle-kit up` in the TypeScript project to upgrade its snapshots, then import again."
412        } else {
413            "It is newer than this drizzle-cli understands; update drizzle-cli."
414        };
415        return Err(CliError::Other(format!(
416            "{} is a {} snapshot at version {version}; drizzle import reads versions {min} to {latest}. {hint}",
417            path.display(),
418            dialect.as_str(),
419        )));
420    }
421
422    let upgraded = upgrade_to_latest(json, base);
423    let invalid = |e: &dyn Display| {
424        CliError::Other(format!(
425            "{} did not convert to a {} snapshot: {e}",
426            path.display(),
427            dialect.as_str()
428        ))
429    };
430    let snapshot = match base {
431        drizzle_types::Dialect::SQLite => {
432            Snapshot::Sqlite(serde_json::from_value(upgraded).map_err(|e| invalid(&e))?)
433        }
434        drizzle_types::Dialect::PostgreSQL => {
435            Snapshot::Postgres(serde_json::from_value(upgraded).map_err(|e| invalid(&e))?)
436        }
437        drizzle_types::Dialect::MySQL => {
438            let snapshot: drizzle_migrations::mysql::MySQLSnapshot =
439                serde_json::from_value(upgraded).map_err(|e| invalid(&e))?;
440            drizzle_migrations::mysql::MySQLDDL::try_from_entities(snapshot.ddl.clone())
441                .map_err(|e| invalid(&e))?;
442            Snapshot::MySQL(snapshot)
443        }
444    };
445    Ok(snapshot)
446}
447
448// =============================================================================
449// Writing the schema
450// =============================================================================
451
452/// `--out`, else the config's single `schema` file, else `src/schema.rs`.
453fn resolve_out_path(
454    out: Option<&Path>,
455    db: Option<&crate::config::DatabaseConfig>,
456) -> Result<PathBuf, CliError> {
457    if let Some(out) = out {
458        return Ok(out.to_path_buf());
459    }
460    let Some(db) = db else {
461        return Ok(PathBuf::from(DEFAULT_SCHEMA_PATH));
462    };
463    match &db.schema {
464        Schema::One(path) if !path.contains(['*', '?', '[', '{']) => Ok(PathBuf::from(path)),
465        _ => Err(CliError::Other(format!(
466            "The config's schema ({}) is not a single file; pass --out <FILE>",
467            db.schema_display()
468        ))),
469    }
470}
471
472fn write_schema(path: &Path, code: &str, force: bool) -> Result<(), CliError> {
473    if path.exists() && !force {
474        return Err(CliError::Other(format!(
475            "{} already exists. Pass --force to overwrite it, or --out <FILE> to write elsewhere.",
476            path.display()
477        )));
478    }
479    if let Some(parent) = path.parent()
480        && !parent.as_os_str().is_empty()
481    {
482        fs::create_dir_all(parent).map_err(|e| {
483            CliError::Other(format!(
484                "Failed to create output directory '{}': {e}",
485                parent.display()
486            ))
487        })?;
488    }
489    fs::write(path, code).map_err(|e| {
490        CliError::Other(format!(
491            "Failed to write schema file '{}': {e}",
492            path.display()
493        ))
494    })
495}
496
497fn print_summary(say: Messages, generated: &SchemaCode) {
498    say.line("");
499    for line in crate::commands::introspect::summary_lines(
500        generated.table_count,
501        generated.index_count,
502        generated.view_count,
503        &generated.warnings,
504    ) {
505        say.line(line);
506    }
507}
508
509// =============================================================================
510// Config and next steps
511// =============================================================================
512
513/// Writes a starter config for the imported project. Returns whether it
514/// wrote one.
515fn init_config(
516    say: Messages,
517    config: Option<&Config>,
518    dialect: Dialect,
519    schema_path: Option<&Path>,
520    migrations: Option<(&Path, Layout)>,
521) -> Result<bool, CliError> {
522    let config_path = Path::new(CONFIG_FILE);
523    if config.is_some() || config_path.exists() {
524        say.line(output::warning(&format!(
525            "--init-config: a config already exists, so {CONFIG_FILE} was not written"
526        )));
527        return Ok(false);
528    }
529    let schema = schema_path.map_or_else(|| DEFAULT_SCHEMA_PATH.to_string(), forward_slashes);
530    let out = migrations.map_or_else(
531        || crate::commands::init::DEFAULT_OUT.to_string(),
532        |(dir, _)| forward_slashes(dir),
533    );
534    let contents = crate::commands::init::config_contents(dialect.as_str(), None, &schema, &out)?;
535    fs::write(config_path, contents).map_err(|e| CliError::IoError(e.to_string()))?;
536    say.line("");
537    say.line(output::success(&format!(
538        "Created {CONFIG_FILE} (schema = \"{schema}\", out = \"{out}\")"
539    )));
540    Ok(true)
541}
542
543/// `path` with forward slashes (relative paths kept), for the config file
544/// and the generated module doc.
545fn forward_slashes(path: &Path) -> String {
546    path.to_string_lossy().replace('\\', "/")
547}
548
549struct NextSteps<'a> {
550    config: Option<&'a Config>,
551    db_out: Option<&'a Path>,
552    wrote_config: bool,
553    dialect: Dialect,
554    schema_path: Option<&'a Path>,
555    migrations: Option<(&'a Path, Layout)>,
556}
557
558fn print_next_steps(say: Messages, steps: &NextSteps<'_>) {
559    say.line("");
560    say.line(output::heading("Next steps:"));
561    let mut n = 0;
562    let mut step = |text: String| {
563        n += 1;
564        say.line(format!("  {n}. {text}"));
565    };
566
567    let out_display = steps
568        .migrations
569        .map_or_else(|| "./drizzle".to_string(), |(dir, _)| forward_slashes(dir));
570
571    if steps.wrote_config {
572        step(format!(
573            "Fill in [dbCredentials] in {CONFIG_FILE} (it points `out` at {out_display})."
574        ));
575    } else if steps.config.is_none() {
576        let schema = steps
577            .schema_path
578            .map_or_else(|| DEFAULT_SCHEMA_PATH.to_string(), forward_slashes);
579        step(format!(
580            "Create {CONFIG_FILE} with the existing migrations folder as `out` (or rerun with --init-config):"
581        ));
582        say.line(output::muted(&format!(
583            "       dialect = \"{}\"",
584            steps.dialect.as_str()
585        )));
586        say.line(output::muted(&format!("       schema = \"{schema}\"")));
587        say.line(output::muted(&format!("       out = \"{out_display}\"")));
588    } else if let Some((dir, _)) = steps.migrations
589        && steps.db_out.is_none_or(|out| !same_dir(out, dir))
590    {
591        step(format!(
592            "Set `out = \"{out_display}\"` in {CONFIG_FILE} so drizzle-rs uses the existing migrations."
593        ));
594    }
595
596    if let Some((_, Layout::Journal)) = steps.migrations {
597        step(format!(
598            "Convert the migrations folder to the folder layout drizzle-rs reads (moves the files in place; or rerun with --upgrade):\n       {}",
599            output::muted(&format!("drizzle up --out {out_display}"))
600        ));
601    }
602
603    step(format!(
604        "{} sees the migrations drizzle-orm already applied in its tracking table (`__drizzle_migrations`; schema `drizzle` on PostgreSQL) and runs only new ones. If drizzle.config.ts set `migrations.table` or `migrations.schema`, set the same under [migrations].",
605        output::heading("drizzle migrate")
606    ));
607    step(format!(
608        "Run {}: for an unchanged schema it reports no changes.",
609        output::heading("drizzle generate")
610    ));
611}
612
613fn same_dir(a: &Path, b: &Path) -> bool {
614    match (fs::canonicalize(a), fs::canonicalize(b)) {
615        (Ok(a), Ok(b)) => a == b,
616        _ => a == b,
617    }
618}
619
620fn print_manual_port_notes(say: Messages) {
621    say.line("");
622    say.line(output::heading(
623        "Snapshots do not record these; port them by hand:",
624    ));
625    for note in [
626        "relations(): declare foreign keys with #[column(references = Table::column)]; relation accessors come from those",
627        "$type<T>() and column modes ({ mode: 'json' | 'timestamp' | 'bigint' }): fields get the column's storage type; switch to your Rust type (e.g. #[column(json)] with a serde type, or a #[derive(...Enum)])",
628        "$default / $defaultFn / $onUpdate / $onUpdateFn: values computed in JS; use #[column(default_fn = path)] or set them in code",
629        "customType(): the column keeps its SQL type; map it to a Rust type yourself",
630    ] {
631        say.line(format!("  {} {note}", output::warning("-")));
632    }
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638    use serde_json::json;
639
640    #[test]
641    fn dialect_spellings() {
642        let path = Path::new("snapshot.json");
643        for (name, dialect) in [
644            ("sqlite", Dialect::Sqlite),
645            ("turso", Dialect::Turso),
646            ("postgresql", Dialect::Postgresql),
647            ("postgres", Dialect::Postgresql),
648            ("mysql", Dialect::Mysql),
649        ] {
650            assert_eq!(
651                snapshot_dialect(&json!({ "dialect": name }), path).unwrap(),
652                dialect
653            );
654        }
655        assert!(snapshot_dialect(&json!({ "dialect": "singlestore" }), path).is_err());
656        assert!(snapshot_dialect(&json!({}), path).is_err());
657    }
658
659    #[test]
660    fn old_and_future_versions_are_refused() {
661        let path = Path::new("snapshot.json");
662        let old = load_snapshot(
663            json!({ "version": "4", "dialect": "sqlite" }),
664            Dialect::Sqlite,
665            path,
666        )
667        .unwrap_err()
668        .to_string();
669        assert!(old.contains("version 4"), "{old}");
670        assert!(old.contains("drizzle-kit up"), "{old}");
671
672        let future = load_snapshot(
673            json!({ "version": "99", "dialect": "postgresql" }),
674            Dialect::Postgresql,
675            path,
676        )
677        .unwrap_err()
678        .to_string();
679        assert!(future.contains("update drizzle-cli"), "{future}");
680    }
681}