Skip to main content

drizzle_cli/commands/
generate.rs

1//! `drizzle generate`: writes a migration folder from schema changes.
2
3use std::fmt::Write;
4use std::path::{Path, PathBuf};
5
6use crate::commands::overrides;
7use crate::config::{Casing, Config, Dialect, Driver, MigrationPrefix};
8use crate::error::CliError;
9use crate::output;
10use drizzle_migrations::schema::Snapshot;
11
12#[derive(clap::Args, Debug, Clone)]
13pub struct GenerateOptions {
14    /// Migration name (optional, auto-generated if not provided)
15    #[arg(short, long)]
16    pub name: Option<String>,
17
18    /// Create a custom (empty) migration file for manual SQL
19    #[arg(long)]
20    pub custom: bool,
21
22    /// Casing for generated identifiers (`camelCase` or `snake_case`)
23    #[arg(long)]
24    pub casing: Option<Casing>,
25
26    /// Override dialect from config
27    #[arg(long)]
28    pub dialect: Option<Dialect>,
29
30    /// Override driver from config
31    #[arg(long)]
32    pub driver: Option<Driver>,
33
34    /// Override schema path(s)
35    #[arg(long, value_delimiter = ',')]
36    pub schema: Option<Vec<String>>,
37
38    /// Override output directory
39    #[arg(long)]
40    pub out: Option<std::path::PathBuf>,
41
42    /// Override breakpoints setting
43    #[arg(long)]
44    pub breakpoints: Option<bool>,
45
46    /// When merged migration branches change the same objects, diff against
47    /// the newest snapshot instead of failing
48    #[arg(long)]
49    pub ignore_conflicts: bool,
50
51    /// When merged migration branches change the same objects, delete the
52    /// migrations that conflict (on both branches, with every migration
53    /// after them), then generate one migration in their place. Use it only
54    /// when no shared database ran them
55    #[arg(long, conflicts_with_all = ["ignore_conflicts", "custom"])]
56    pub fix_conflicts: bool,
57
58    #[command(flatten)]
59    pub hints: crate::commands::renames::HintArgs,
60}
61
62/// Runs `drizzle generate`: diffs the schema files against the newest
63/// `snapshot.json` in the migrations folder and writes
64/// `<out>/<tag>/migration.sql` plus `snapshot.json`.
65///
66/// After a git merge leaves several open heads in the snapshot history, the
67/// diff runs against the merged state of every branch instead (see
68/// [`drizzle_migrations::history::load_merge_base`]) and the new snapshot's
69/// `prevIds` lists every head, as drizzle-kit does.
70///
71/// Prints a note and writes nothing when the schema has no tables or indexes
72/// or nothing changed. With `--custom`, writes a `migration.sql` holding only
73/// a comment and no snapshot, so the next diff ignores it. Also rewrites
74/// `migrations.js` when bundling is enabled. Does not connect to the
75/// database.
76///
77/// Ambiguous renames are asked about on a terminal and otherwise answered
78/// from `--hints` / `--hints-file`; renames are never inferred (see
79/// [`renames`](crate::commands::renames)).
80///
81/// # Errors
82///
83/// Returns [`CliError`] if `db_name` does not match the config, the folder
84/// still uses the legacy `meta/_journal.json` layout (run `drizzle up`), no
85/// schema files are found or they have parse errors, merged migration
86/// branches conflict (unless `--ignore-conflicts` or `--fix-conflicts`), a
87/// conflicting migration folder cannot be deleted, the hints are invalid
88/// ([`CliError::InvalidHints`]) or leave a rename question unanswered without
89/// a terminal ([`CliError::MissingHints`]), a prompt is aborted, the diff
90/// fails, or the migration folder cannot be written (including when its tag
91/// already exists).
92pub fn run(config: &Config, db_name: Option<&str>, opts: GenerateOptions) -> Result<(), CliError> {
93    use drizzle_migrations::naming::{PrefixMode, generate_migration_tag_with_mode};
94
95    let db = config.database(db_name)?;
96
97    // CLI flag overrides config
98    let effective_casing = opts.casing.or(db.casing);
99    let effective_dialect = overrides::resolve_dialect(db, opts.dialect);
100    let effective_driver = overrides::resolve_driver(db, effective_dialect, opts.driver)?;
101    let effective_breakpoints = opts.breakpoints.unwrap_or(db.breakpoints);
102    let out_dir = opts
103        .out
104        .clone()
105        .unwrap_or_else(|| db.migrations_dir().to_path_buf());
106
107    crate::commands::harness::print_db_header(config, db_name);
108
109    println!("{}", output::heading("Generating migration..."));
110
111    println!(
112        "  {}: {}",
113        output::label("Dialect"),
114        effective_dialect.as_str()
115    );
116    if let Some(driver) = effective_driver {
117        println!("  {}: {}", output::label("Driver"), driver);
118    }
119
120    // Create output directories if they don't exist
121    std::fs::create_dir_all(&out_dir).map_err(|e| CliError::IoError(e.to_string()))?;
122
123    let legacy_journal_path = out_dir.join("meta").join("_journal.json");
124    if legacy_journal_path.exists() {
125        return Err(CliError::Other(
126            "Detected old drizzle-kit migration folders. Upgrade them before generating new migrations."
127                .to_string(),
128        ));
129    }
130
131    // Handle custom migration (empty migration file for manual SQL)
132    if opts.custom {
133        let bundle = db.bundle_enabled();
134        return generate_custom_migration(
135            &out_dir,
136            effective_breakpoints,
137            db.migrations.as_ref().and_then(|m| m.prefix),
138            opts.name,
139            bundle,
140        );
141    }
142
143    // Parse schema files
144    let parse_result = parse_schema_files(db, opts.schema.as_deref())?;
145
146    if parse_result.tables.is_empty() && parse_result.indexes.is_empty() {
147        println!(
148            "{}",
149            output::warning("No tables or indexes found in schema files.")
150        );
151        return Ok(());
152    }
153
154    println!(
155        "  {} {} table(s), {} index(es)",
156        output::label("Found"),
157        parse_result.tables.len(),
158        parse_result.indexes.len()
159    );
160
161    // Get dialect from config
162    let dialect = effective_dialect.to_base();
163
164    // Build current snapshot from parsed schema (use config dialect, not parser-detected)
165    let current_snapshot = Snapshot::from_parse_result(&parse_result, dialect, effective_casing);
166
167    // `--fix-conflicts`: set the conflicting migrations aside, and delete
168    // them once the new migration is written. An aborted prompt or a failed
169    // diff puts them back.
170    let mut set_aside = None;
171    if opts.fix_conflicts {
172        let folders =
173            drizzle_migrations::history::conflict_fix(&out_dir, dialect).map_err(history_error)?;
174        if !folders.is_empty() {
175            println!(
176                "{}",
177                output::warning(
178                    "Deleting conflicting migrations (reset any database that ran them):"
179                )
180            );
181            for folder in &folders {
182                println!("  {folder}");
183            }
184            set_aside = Some(SetAside::new(&out_dir, folders)?);
185        }
186    }
187
188    // The schema the existing migrations produce: the newest snapshot, or
189    // every branch's changes merged after a git merge.
190    let merge_base =
191        drizzle_migrations::history::load_merge_base(&out_dir, dialect, opts.ignore_conflicts)
192            .map_err(history_error)?;
193
194    // Ask (or read hints) about ambiguous renames, then diff with the answers.
195    let options = crate::commands::renames::resolve_for_command(
196        &merge_base.snapshot,
197        &current_snapshot,
198        drizzle_migrations::DiffOptions::new(),
199        &opts.hints,
200    )?;
201    let mut generated = generate_diff(&merge_base.snapshot, &current_snapshot, &options)?;
202    if let Some(prev_ids) = merge_base.prev_ids {
203        println!(
204            "  {} {} migration branches",
205            output::label("Merging"),
206            prev_ids.len()
207        );
208        generated.snapshot.set_prev_ids(prev_ids);
209    }
210
211    if generated.is_empty() {
212        println!("{}", output::warning("No schema changes detected 😴"));
213        if let Some(set_aside) = set_aside {
214            set_aside.delete()?;
215            if db.bundle_enabled() {
216                write_migrations_js(&out_dir)?;
217            }
218        }
219        return Ok(());
220    }
221
222    println!(
223        "  {} {} SQL statement(s)",
224        output::label("Generated"),
225        generated.statements.len()
226    );
227
228    // Data-loss and review notes from the diff, as push prints them.
229    if !generated.warnings.is_empty() {
230        println!("{}", output::warning("Warnings:"));
231        for warning in &generated.warnings {
232            println!("  {} {}", output::warning("-"), warning);
233        }
234        println!();
235    }
236
237    let prefix_mode = db
238        .migrations
239        .as_ref()
240        .and_then(|m| m.prefix)
241        .map_or(PrefixMode::Timestamp, map_prefix_mode);
242
243    let next_idx = next_migration_index(&out_dir)?;
244    let migration_tag =
245        generate_migration_tag_with_mode(prefix_mode, next_idx, opts.name.as_deref());
246
247    let migration_dir =
248        write_migration_files(&out_dir, &migration_tag, &generated, effective_breakpoints)?;
249    if let Some(set_aside) = set_aside {
250        set_aside.delete()?;
251    }
252
253    // Regenerate {out_dir}/migrations.js bundle index when enabled.
254    // Auto-enabled for driver = durable-sqlite (see `DatabaseConfig::bundle_enabled`).
255    if db.bundle_enabled() {
256        write_migrations_js(&out_dir)?;
257    }
258
259    println!(
260        "{}",
261        output::success(&format!("Migration generated: {migration_tag}"))
262    );
263    println!("   {}", migration_dir.display());
264
265    Ok(())
266}
267
268/// Resolve and parse schema files.
269fn parse_schema_files(
270    db: &crate::config::DatabaseConfig,
271    schema_override: Option<&[String]>,
272) -> Result<drizzle_migrations::parser::ParseResult, CliError> {
273    use drizzle_migrations::parser::SchemaParser;
274
275    let schema_files = overrides::resolve_schema_files(db, schema_override)?;
276    if schema_files.is_empty() {
277        return Err(CliError::NoSchemaFiles(overrides::resolve_schema_display(
278            db,
279            schema_override,
280        )));
281    }
282
283    println!(
284        "  {} {} schema file(s)",
285        output::label("Parsing"),
286        schema_files.len()
287    );
288
289    let mut combined_code = String::new();
290    for path in &schema_files {
291        let code = std::fs::read_to_string(path)
292            .map_err(|e| CliError::IoError(format!("Failed to read {}: {}", path.display(), e)))?;
293        combined_code.push_str(&code);
294        combined_code.push('\n');
295    }
296
297    let parse_result = SchemaParser::parse(&combined_code);
298    crate::snapshot::surface_parse_diagnostics(&parse_result)?;
299    Ok(parse_result)
300}
301
302/// Write migration.sql and snapshot.json to `{out_dir}/{tag}/`.
303fn write_migration_files(
304    out_dir: &Path,
305    migration_tag: &str,
306    generated: &drizzle_migrations::Plan,
307    breakpoints: bool,
308) -> Result<std::path::PathBuf, CliError> {
309    let sql_content = if breakpoints {
310        generated.statements.join("\n--> statement-breakpoint\n")
311    } else {
312        generated.statements.join("\n\n")
313    };
314
315    drizzle_migrations::writer::publish_migration_directory(out_dir, migration_tag, |folder| {
316        std::fs::write(folder.join("migration.sql"), &sql_content)
317            .map_err(|error| drizzle_migrations::MigrationError::IoError(error.to_string()))?;
318        generated
319            .snapshot
320            .save(&folder.join("snapshot.json"))
321            .map_err(|error| drizzle_migrations::MigrationError::SnapshotError(error.to_string()))
322    })
323    .map_err(map_migration_error)
324}
325
326fn history_error(error: drizzle_migrations::history::HistoryError) -> CliError {
327    match error {
328        drizzle_migrations::history::HistoryError::Io(error) => {
329            CliError::IoError(error.to_string())
330        }
331        conflicts => CliError::MigrationError(conflicts.to_string()),
332    }
333}
334
335/// Migration folders `--fix-conflicts` moved out of the migrations folder,
336/// into a sibling folder. Dropping it moves them back; [`delete`](Self::delete)
337/// removes them for good.
338struct SetAside {
339    out_dir: PathBuf,
340    stash: PathBuf,
341    folders: Vec<String>,
342}
343
344impl SetAside {
345    fn new(out_dir: &Path, folders: Vec<String>) -> Result<Self, CliError> {
346        let io = |e: std::io::Error| CliError::IoError(e.to_string());
347        let out_dir = std::fs::canonicalize(out_dir).map_err(io)?;
348        let name = out_dir
349            .file_name()
350            .map_or_else(|| "migrations".into(), |name| name.to_string_lossy());
351        let stash = out_dir.with_file_name(format!(".{name}.fix-conflicts"));
352        if stash.exists() {
353            return Err(CliError::IoError(format!(
354                "{} is left from an earlier run; move its folders back into {} or delete it",
355                stash.display(),
356                out_dir.display()
357            )));
358        }
359        std::fs::create_dir(&stash).map_err(io)?;
360        let mut set_aside = Self {
361            out_dir,
362            stash,
363            folders: Vec::with_capacity(folders.len()),
364        };
365        for folder in folders {
366            std::fs::rename(
367                set_aside.out_dir.join(&folder),
368                set_aside.stash.join(&folder),
369            )
370            .map_err(io)?;
371            set_aside.folders.push(folder);
372        }
373        Ok(set_aside)
374    }
375
376    fn delete(mut self) -> Result<(), CliError> {
377        self.folders.clear();
378        std::fs::remove_dir_all(&self.stash).map_err(|e| CliError::IoError(e.to_string()))
379    }
380}
381
382impl Drop for SetAside {
383    fn drop(&mut self) {
384        if self.folders.is_empty() {
385            return;
386        }
387        for folder in &self.folders {
388            if let Err(error) = std::fs::rename(self.stash.join(folder), self.out_dir.join(folder))
389            {
390                eprintln!(
391                    "{}",
392                    output::warning(&format!(
393                        "Could not move {folder} back from {}: {error}",
394                        self.stash.display()
395                    ))
396                );
397                return;
398            }
399        }
400        let _ = std::fs::remove_dir(&self.stash);
401    }
402}
403
404/// Generate an empty custom migration for manual SQL
405fn generate_custom_migration(
406    out_dir: &Path,
407    _breakpoints: bool,
408    prefix: Option<MigrationPrefix>,
409    name: Option<String>,
410    bundle: bool,
411) -> Result<(), CliError> {
412    use drizzle_migrations::naming::{PrefixMode, generate_migration_tag_with_mode};
413
414    let custom_name = name.unwrap_or_else(|| "custom".to_string());
415
416    let prefix_mode = prefix.map_or(PrefixMode::Timestamp, map_prefix_mode);
417
418    let migration_tag = generate_migration_tag_with_mode(
419        prefix_mode,
420        next_migration_index(out_dir)?,
421        Some(&custom_name),
422    );
423
424    let sql_content = "-- Custom SQL migration file, put your code below! --\n\n";
425    let migration_dir = drizzle_migrations::writer::publish_migration_directory(
426        out_dir,
427        &migration_tag,
428        |folder| {
429            std::fs::write(folder.join("migration.sql"), sql_content)
430                .map_err(|error| drizzle_migrations::MigrationError::IoError(error.to_string()))
431        },
432    )
433    .map_err(map_migration_error)?;
434
435    // Regenerate {out_dir}/migrations.js bundle index when enabled.
436    if bundle {
437        write_migrations_js(out_dir)?;
438    }
439
440    println!(
441        "{}",
442        output::success(&format!("Custom migration created: {migration_tag}"))
443    );
444    println!("   {}", migration_dir.display());
445    println!(
446        "{}",
447        output::label("   Edit the migration file to add your SQL statements.")
448    );
449
450    Ok(())
451}
452
453const fn map_prefix_mode(p: MigrationPrefix) -> drizzle_migrations::PrefixMode {
454    match p {
455        MigrationPrefix::Index => drizzle_migrations::PrefixMode::Index,
456        MigrationPrefix::Timestamp => drizzle_migrations::PrefixMode::Timestamp,
457        MigrationPrefix::Supabase => drizzle_migrations::PrefixMode::Supabase,
458        MigrationPrefix::Unix => drizzle_migrations::PrefixMode::Unix,
459        MigrationPrefix::None => drizzle_migrations::PrefixMode::None,
460    }
461}
462
463fn next_migration_index(out_dir: &Path) -> Result<u32, CliError> {
464    let entries = collect_v3_migration_tags(out_dir)?;
465    let mut max_index: Option<u32> = None;
466
467    for tag in &entries {
468        let Some(prefix) = tag.split('_').next() else {
469            continue;
470        };
471
472        // Index prefixes are short (`0000`); longer digit runs are timestamp
473        // (14), unix (10), or millisecond (13) prefixes, not indexes.
474        if prefix.len() > 5 || !prefix.chars().all(|c| c.is_ascii_digit()) {
475            continue;
476        }
477
478        if let Ok(idx) = prefix.parse::<u32>() {
479            max_index = Some(max_index.map_or(idx, |curr| curr.max(idx)));
480        }
481    }
482
483    Ok(max_index.map_or_else(
484        || u32::try_from(entries.len()).unwrap_or(u32::MAX),
485        |idx| idx.saturating_add(1),
486    ))
487}
488
489fn collect_v3_migration_tags(out_dir: &Path) -> Result<Vec<String>, CliError> {
490    if !out_dir.exists() {
491        return Ok(Vec::new());
492    }
493
494    let mut tags = Vec::new();
495    for entry in std::fs::read_dir(out_dir).map_err(|e| CliError::IoError(e.to_string()))? {
496        let entry = entry.map_err(|e| CliError::IoError(e.to_string()))?;
497        if !entry
498            .file_type()
499            .map_err(|e| CliError::IoError(e.to_string()))?
500            .is_dir()
501        {
502            continue;
503        }
504
505        let tag = entry.file_name().to_string_lossy().to_string();
506        if tag == "meta" {
507            continue;
508        }
509
510        if entry.path().join("migration.sql").exists() {
511            tags.push(tag);
512        }
513    }
514
515    tags.sort();
516    Ok(tags)
517}
518
519/// Write a `migrations.js` bundle index at the root of the migrations output
520/// folder.
521///
522/// Mirrors `drizzle-kit`'s `bundle: true` output. JS bundlers (Metro for
523/// Expo/React Native, Cloudflare Workers' bundler for Durable Objects `SQLite`)
524/// require static `import` statements to embed SQL text into the final JS
525/// bundle; this file is the entry point.
526///
527/// Rust-only consumers can ignore it — our [`drizzle_migrations::MigrationDir`]
528/// loader reads the `migration.sql` files directly.
529///
530/// # Errors
531///
532/// Returns [`CliError`] if the migrations directory cannot be enumerated or if
533/// writing the `migrations.js` file fails.
534pub fn write_migrations_js(out_dir: &Path) -> Result<(), CliError> {
535    let tags = collect_v3_migration_tags(out_dir)?;
536
537    let mut content = String::new();
538    for (idx, tag) in tags.iter().enumerate() {
539        let import_name = format!("m{idx:04}");
540        // Forward slashes work in JS import specifiers on every platform,
541        // including Windows — they are URL-style paths, not filesystem paths.
542        let _ = writeln!(
543            content,
544            "import {import_name} from './{tag}/migration.sql';"
545        );
546    }
547
548    content.push_str("\nexport default {\n  migrations: {\n");
549    for (idx, tag) in tags.iter().enumerate() {
550        let _ = writeln!(content, "    \"{tag}\": m{idx:04},");
551    }
552    content.push_str("  }\n};\n");
553
554    let migrations_js_path = out_dir.join("migrations.js");
555    std::fs::write(&migrations_js_path, content).map_err(|e| CliError::IoError(e.to_string()))?;
556
557    Ok(())
558}
559
560/// Map migration infrastructure errors to CLI errors.
561fn map_migration_error(error: drizzle_migrations::MigrationError) -> CliError {
562    match error {
563        drizzle_migrations::MigrationError::DialectMismatch => CliError::DialectMismatch,
564        drizzle_migrations::MigrationError::NoChanges => {
565            CliError::Other("No schema changes detected".to_string())
566        }
567        drizzle_migrations::MigrationError::UnansweredRenames(questions) => {
568            CliError::MissingHints(super::renames::missing_hints_report(&questions))
569        }
570        drizzle_migrations::MigrationError::ConfigError(_)
571        | drizzle_migrations::MigrationError::IoError(_)
572        | drizzle_migrations::MigrationError::SnapshotError(_) => {
573            CliError::MigrationError(error.to_string())
574        }
575    }
576}
577
578/// Generate diff between two snapshots
579fn generate_diff(
580    prev: &drizzle_migrations::schema::Snapshot,
581    current: &drizzle_migrations::schema::Snapshot,
582    options: &drizzle_migrations::DiffOptions,
583) -> Result<drizzle_migrations::Plan, CliError> {
584    drizzle_migrations::diff_with(prev, current, options).map_err(map_migration_error)
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590    use tempfile::tempdir;
591
592    fn touch_migration(out_dir: &Path, tag: &str) {
593        let dir = out_dir.join(tag);
594        std::fs::create_dir_all(&dir).expect("mkdir migration folder");
595        std::fs::write(dir.join("migration.sql"), "-- stub\n").expect("write migration.sql");
596    }
597
598    #[test]
599    fn custom_migration_rejects_unsafe_name_before_writing() {
600        let tmp = tempdir().expect("tempdir");
601        let error = generate_custom_migration(
602            tmp.path(),
603            false,
604            Some(MigrationPrefix::None),
605            Some("../escape".to_string()),
606            false,
607        )
608        .expect_err("unsafe name must fail");
609
610        assert!(matches!(error, CliError::MigrationError(_)));
611        assert_eq!(
612            std::fs::read_dir(tmp.path()).expect("read output").count(),
613            0
614        );
615    }
616
617    #[test]
618    fn custom_migration_is_published_as_complete_directory() {
619        let tmp = tempdir().expect("tempdir");
620        generate_custom_migration(
621            tmp.path(),
622            false,
623            Some(MigrationPrefix::None),
624            Some("manual_change".to_string()),
625            false,
626        )
627        .expect("generate custom migration");
628
629        let migration = tmp.path().join("manual_change");
630        assert!(migration.join("migration.sql").is_file());
631        assert_eq!(
632            std::fs::read_dir(tmp.path()).expect("read output").count(),
633            1
634        );
635    }
636
637    #[test]
638    fn migrations_js_contains_import_and_export_map_in_tag_order() {
639        let tmp = tempdir().expect("tempdir");
640        let out_dir = tmp.path();
641
642        touch_migration(out_dir, "20230331141203_first");
643        touch_migration(out_dir, "20230401091530_second");
644        touch_migration(out_dir, "20230501111111_third");
645
646        write_migrations_js(out_dir).expect("write migrations.js");
647
648        let contents =
649            std::fs::read_to_string(out_dir.join("migrations.js")).expect("read migrations.js");
650
651        assert!(
652            contents.contains("import m0000 from './20230331141203_first/migration.sql';"),
653            "first import present"
654        );
655        assert!(
656            contents.contains("import m0001 from './20230401091530_second/migration.sql';"),
657            "second import present"
658        );
659        assert!(
660            contents.contains("import m0002 from './20230501111111_third/migration.sql';"),
661            "third import present"
662        );
663        assert!(
664            contents.contains("\"20230331141203_first\": m0000,"),
665            "first map entry"
666        );
667        assert!(
668            contents.contains("\"20230401091530_second\": m0001,"),
669            "second map entry"
670        );
671        assert!(
672            contents.contains("\"20230501111111_third\": m0002,"),
673            "third map entry"
674        );
675        assert!(
676            contents.contains("export default {"),
677            "export default present"
678        );
679    }
680
681    #[test]
682    fn migrations_js_is_empty_shell_when_no_migrations_exist() {
683        let tmp = tempdir().expect("tempdir");
684        let out_dir = tmp.path();
685
686        write_migrations_js(out_dir).expect("write migrations.js");
687
688        let contents =
689            std::fs::read_to_string(out_dir.join("migrations.js")).expect("read migrations.js");
690
691        assert!(!contents.contains("import "), "no imports when empty");
692        assert!(
693            contents.contains("export default {"),
694            "export default still present"
695        );
696        assert!(
697            contents.contains("migrations: {"),
698            "migrations map still present"
699        );
700    }
701
702    #[test]
703    fn migrations_js_uses_forward_slashes_in_import_paths() {
704        // JS import specifiers use URL-style paths (always forward slashes),
705        // regardless of host filesystem separator. This guards against a
706        // Windows-specific regression that upstream shipped in beta.22.
707        let tmp = tempdir().expect("tempdir");
708        let out_dir = tmp.path();
709
710        touch_migration(out_dir, "20230331141203_first");
711
712        write_migrations_js(out_dir).expect("write migrations.js");
713
714        let contents =
715            std::fs::read_to_string(out_dir.join("migrations.js")).expect("read migrations.js");
716
717        assert!(
718            !contents.contains('\\'),
719            "import paths must use forward slashes even on Windows"
720        );
721        assert!(contents.contains("'./20230331141203_first/migration.sql'"));
722    }
723}