Skip to main content

drizzle_cli/commands/
upgrade.rs

1//! `drizzle up`: upgrades migration folders and snapshots to the current format.
2//!
3//! This command matches drizzle-kit's `up` command and handles two layouts:
4//!
5//! * **Legacy stable-kit layout** (flat `NNNN_name.sql` files plus
6//!   `meta/_journal.json` and `meta/NNNN_snapshot.json`): the whole
7//!   directory is converted to the v1-beta layout (`{folder}/migration.sql`
8//!   and `{folder}/snapshot.json`) with every snapshot structurally upgraded
9//!   to the current entity-array format. SQL files are moved verbatim. Folders
10//!   are named like drizzle-kit names them: the UTC `YYYYMMDDHHMMSS` of the
11//!   journal entry's `when`, then the tag without its index
12//!   (`0000_flimsy_shard` becomes `20231114221320_flimsy_shard`).
13//! * **v1-beta layout** with old snapshot versions: each `snapshot.json` is
14//!   upgraded in place.
15
16use crate::config::{Config, Dialect as CliDialect};
17use crate::error::CliError;
18use crate::output;
19use drizzle_migrations::naming::legacy_migration_folder_name;
20use drizzle_migrations::upgrade::upgrade_to_latest;
21use drizzle_migrations::version::{is_supported_version, snapshot_version};
22use drizzle_types::Dialect;
23use std::fs;
24use std::path::{Path, PathBuf};
25
26#[derive(clap::Args, Debug, Clone, Default)]
27pub struct UpgradeOptions {
28    /// Override dialect from config
29    #[arg(long)]
30    pub dialect: Option<CliDialect>,
31
32    /// Override output directory
33    #[arg(long)]
34    pub out: Option<PathBuf>,
35}
36
37/// Runs `drizzle up` on the migrations folder (see the [module docs](self)).
38///
39/// A missing folder only prints a note. Does not connect to the database.
40///
41/// # Errors
42///
43/// Returns [`CliError`] if `db_name` does not match the config, the folder
44/// or a file in it cannot be read, the legacy journal is malformed, a
45/// snapshot does not convert to the current format, or writing the upgraded
46/// files fails.
47pub fn run(config: &Config, db_name: Option<&str>, opts: &UpgradeOptions) -> Result<(), CliError> {
48    let db = config.database(db_name)?;
49
50    // CLI flags override config
51    let dialect = opts.dialect.unwrap_or(db.dialect).to_base();
52    let out_dir = opts.out.as_deref().unwrap_or_else(|| db.migrations_dir());
53
54    println!(
55        "{}",
56        output::heading(&format!(
57            "Checking for snapshots to upgrade in {}",
58            out_dir.display()
59        ))
60    );
61
62    if !out_dir.exists() {
63        println!(
64            "{}",
65            output::warning(&format!(
66                "No migrations folder found at {}",
67                out_dir.display()
68            ))
69        );
70        return Ok(());
71    }
72
73    // Legacy stable-kit layout: flat SQL files with a meta/ journal. Convert
74    // the whole directory to the v1-beta folder layout.
75    if out_dir.join("meta").join("_journal.json").exists() {
76        let converted = convert_legacy_layout(out_dir, dialect)?;
77        println!(
78            "{}",
79            output::success(&format!(
80                "Converted {} migration(s) to the folder layout (snapshots at version {})",
81                converted,
82                snapshot_version(dialect)
83            ))
84        );
85        return Ok(());
86    }
87
88    let upgraded = upgrade_snapshots(out_dir, dialect)?;
89
90    if upgraded == 0 {
91        println!(
92            "{}",
93            output::success(&format!(
94                "All snapshots are already at the latest version ({})",
95                snapshot_version(dialect)
96            ))
97        );
98    } else {
99        println!(
100            "{}",
101            output::success(&format!(
102                "Upgraded {} snapshot(s) to version {}",
103                upgraded,
104                snapshot_version(dialect)
105            ))
106        );
107    }
108
109    Ok(())
110}
111
112// =============================================================================
113// Legacy layout conversion (flat NNNN_name.sql + meta/)
114// =============================================================================
115
116/// A fully converted legacy journal entry, held in memory until every entry
117/// has converted cleanly.
118struct ConvertedEntry {
119    tag: String,
120    /// `<UTC YYYYMMDDHHMMSS of when>_<tag without its index>`.
121    folder: String,
122    sql_path: PathBuf,
123    sql: String,
124    snapshot_json: String,
125}
126
127/// Convert a legacy drizzle-kit migrations directory to the v1-beta layout.
128///
129/// For each journal entry, the flat `{tag}.sql` file becomes
130/// `{folder}/migration.sql` (content moved verbatim) and the matching
131/// `meta/{idx}_snapshot.json` becomes `{folder}/snapshot.json`, structurally
132/// upgraded to the current entity-array format. `{folder}` is
133/// [`legacy_migration_folder_name`] of the tag and the entry's `when`, the
134/// name drizzle-kit's `up` gives it and drizzle-orm records once applied.
135///
136/// The conversion is all-or-nothing: every entry is read, upgraded, and
137/// validated in memory first; the new folders are then written; the legacy
138/// flat files and the `meta/` directory are removed only after every write
139/// succeeded. Any failure before that point leaves the directory untouched
140/// (or with extra folders next to the intact legacy files, which a rerun
141/// simply overwrites).
142pub(crate) fn convert_legacy_layout(out_dir: &Path, dialect: Dialect) -> Result<usize, CliError> {
143    let meta_dir = out_dir.join("meta");
144    let journal_path = meta_dir.join("_journal.json");
145
146    println!(
147        "{}",
148        output::info(
149            "Detected legacy drizzle-kit migration folders (meta/_journal.json) — converting to the folder layout"
150        )
151    );
152
153    let journal_contents =
154        fs::read_to_string(&journal_path).map_err(|e| CliError::IoError(e.to_string()))?;
155    let journal: serde_json::Value = serde_json::from_str(&journal_contents).map_err(|e| {
156        CliError::Other(format!("Invalid JSON in {}: {}", journal_path.display(), e))
157    })?;
158
159    let entries = journal
160        .get("entries")
161        .and_then(serde_json::Value::as_array)
162        .ok_or_else(|| {
163            CliError::Other(format!("{} has no 'entries' array", journal_path.display()))
164        })?;
165
166    // Phase 1: read + upgrade + validate everything in memory. Nothing on
167    // disk is touched until every entry converts cleanly.
168    let mut converted: Vec<ConvertedEntry> = Vec::with_capacity(entries.len());
169    for entry in entries {
170        let tag = entry
171            .get("tag")
172            .and_then(serde_json::Value::as_str)
173            .ok_or_else(|| {
174                CliError::Other(format!(
175                    "Journal entry without a 'tag' in {}",
176                    journal_path.display()
177                ))
178            })?;
179        let idx = entry
180            .get("idx")
181            .and_then(serde_json::Value::as_u64)
182            .ok_or_else(|| {
183                CliError::Other(format!(
184                    "Journal entry '{tag}' without an 'idx' in {}",
185                    journal_path.display()
186                ))
187            })?;
188        let when = entry
189            .get("when")
190            .and_then(serde_json::Value::as_i64)
191            .ok_or_else(|| {
192                CliError::Other(format!(
193                    "Journal entry '{tag}' without a 'when' timestamp in {}",
194                    journal_path.display()
195                ))
196            })?;
197        // drizzle-kit's `up` names the folder after `when`, not the tag, and
198        // drizzle-orm records that name; any other name re-runs everything.
199        let folder = legacy_migration_folder_name(tag, when);
200        if let Some(clash) = converted.iter().find(|done| done.folder == folder) {
201            return Err(CliError::Other(format!(
202                "Journal entries '{}' and '{tag}' both convert to folder '{folder}'",
203                clash.tag
204            )));
205        }
206
207        let sql_path = out_dir.join(format!("{tag}.sql"));
208        let sql = fs::read_to_string(&sql_path).map_err(|e| {
209            CliError::Other(format!(
210                "Cannot read migration SQL {}: {}",
211                sql_path.display(),
212                e
213            ))
214        })?;
215
216        let snapshot_path = meta_dir.join(format!("{idx:04}_snapshot.json"));
217        let snapshot_contents = fs::read_to_string(&snapshot_path).map_err(|e| {
218            CliError::Other(format!(
219                "Cannot read snapshot {}: {}",
220                snapshot_path.display(),
221                e
222            ))
223        })?;
224        let snapshot: serde_json::Value =
225            serde_json::from_str(&snapshot_contents).map_err(|e| {
226                CliError::Other(format!(
227                    "Invalid JSON in {}: {}",
228                    snapshot_path.display(),
229                    e
230                ))
231            })?;
232
233        let upgraded = upgrade_to_latest(snapshot, dialect);
234        let snapshot_json = validated_pretty_snapshot(&upgraded, dialect).map_err(|e| {
235            CliError::Other(format!(
236                "Snapshot {} did not convert cleanly to the current format: {}",
237                snapshot_path.display(),
238                e
239            ))
240        })?;
241
242        converted.push(ConvertedEntry {
243            tag: tag.to_string(),
244            folder,
245            sql_path,
246            sql,
247            snapshot_json,
248        });
249    }
250
251    // Phase 2: write the new folders; the legacy files stay untouched until
252    // every write has succeeded.
253    for entry in &converted {
254        let folder = out_dir.join(&entry.folder);
255        fs::create_dir_all(&folder).map_err(|e| CliError::IoError(e.to_string()))?;
256        fs::write(folder.join("migration.sql"), &entry.sql)
257            .map_err(|e| CliError::IoError(e.to_string()))?;
258        fs::write(folder.join("snapshot.json"), &entry.snapshot_json)
259            .map_err(|e| CliError::IoError(e.to_string()))?;
260        println!(
261            "{}",
262            output::info(&format!(
263                "  {} -> {}/migration.sql + snapshot.json",
264                entry.tag, entry.folder
265            ))
266        );
267    }
268
269    // Phase 3: remove the legacy flat files and the meta/ directory.
270    for entry in &converted {
271        fs::remove_file(&entry.sql_path).map_err(|e| CliError::IoError(e.to_string()))?;
272    }
273    fs::remove_dir_all(&meta_dir).map_err(|e| CliError::IoError(e.to_string()))?;
274
275    Ok(converted.len())
276}
277
278/// Validate that an upgraded snapshot deserializes as the current typed
279/// snapshot format, then pretty-print the *upgraded document itself* (it may
280/// carry explicit `null`s that keep optional fields loadable — a typed
281/// re-serialization would drop them).
282fn validated_pretty_snapshot(
283    upgraded: &serde_json::Value,
284    dialect: Dialect,
285) -> Result<String, CliError> {
286    match dialect {
287        Dialect::SQLite => {
288            serde_json::from_value::<drizzle_migrations::sqlite::SQLiteSnapshot>(upgraded.clone())
289                .map_err(|e| CliError::Other(e.to_string()))?;
290        }
291        Dialect::PostgreSQL => {
292            serde_json::from_value::<drizzle_migrations::postgres::PostgresSnapshot>(
293                upgraded.clone(),
294            )
295            .map_err(|e| CliError::Other(e.to_string()))?;
296        }
297        Dialect::MySQL => {
298            serde_json::from_value::<drizzle_migrations::mysql::MySQLSnapshot>(upgraded.clone())
299                .map_err(|e| CliError::Other(e.to_string()))?;
300        }
301    }
302
303    serde_json::to_string_pretty(upgraded).map_err(|e| CliError::Other(e.to_string()))
304}
305
306// =============================================================================
307// In-place upgrades (v1-beta layout with old snapshot versions)
308// =============================================================================
309
310/// Upgrade all snapshots in a migrations folder
311fn upgrade_snapshots(out_dir: &Path, dialect: Dialect) -> Result<usize, CliError> {
312    let mut upgraded_count = 0;
313
314    // Check for V3 folder-based migrations (each folder has snapshot.json)
315    let v3_snapshots = find_v3_snapshots(out_dir)?;
316
317    for snapshot_path in v3_snapshots {
318        if upgrade_snapshot_file(&snapshot_path, dialect)? {
319            upgraded_count += 1;
320        }
321    }
322
323    // Also check for orphaned meta/ folder snapshots (no _journal.json, so
324    // the full layout conversion doesn't apply)
325    let meta_folder = out_dir.join("meta");
326    if meta_folder.exists() {
327        let legacy_snapshots = find_legacy_snapshots(&meta_folder)?;
328        for snapshot_path in legacy_snapshots {
329            if upgrade_snapshot_file(&snapshot_path, dialect)? {
330                upgraded_count += 1;
331            }
332        }
333    }
334
335    Ok(upgraded_count)
336}
337
338/// Find V3 format snapshots (folder/snapshot.json)
339fn find_v3_snapshots(out_dir: &Path) -> Result<Vec<std::path::PathBuf>, CliError> {
340    let mut snapshots = Vec::new();
341
342    if !out_dir.exists() {
343        return Ok(snapshots);
344    }
345
346    for entry in fs::read_dir(out_dir).map_err(|e| CliError::IoError(e.to_string()))? {
347        let entry = entry.map_err(|e| CliError::IoError(e.to_string()))?;
348        let path = entry.path();
349
350        if path.is_dir() {
351            let snapshot_path = path.join("snapshot.json");
352            if snapshot_path.exists() {
353                snapshots.push(snapshot_path);
354            }
355        }
356    }
357
358    Ok(snapshots)
359}
360
361/// Find legacy format snapshots (meta/*_snapshot.json)
362fn find_legacy_snapshots(meta_folder: &Path) -> Result<Vec<std::path::PathBuf>, CliError> {
363    let mut snapshots = Vec::new();
364
365    if !meta_folder.exists() {
366        return Ok(snapshots);
367    }
368
369    for entry in fs::read_dir(meta_folder).map_err(|e| CliError::IoError(e.to_string()))? {
370        let entry = entry.map_err(|e| CliError::IoError(e.to_string()))?;
371        let path = entry.path();
372
373        if path.is_file()
374            && let Some(name) = path.file_name().and_then(|n| n.to_str())
375            && name.ends_with("_snapshot.json")
376        {
377            snapshots.push(path);
378        }
379    }
380
381    Ok(snapshots)
382}
383
384/// True if the snapshot document already has the current entity-array
385/// structure (a `ddl` array) *and* the latest version stamp. Version alone is
386/// not trusted: historic upgrades stamped the latest version onto documents
387/// that still had the legacy object shape.
388fn is_current_format(json: &serde_json::Value, dialect: Dialect) -> bool {
389    let version = json
390        .get("version")
391        .and_then(serde_json::Value::as_str)
392        .unwrap_or("unknown");
393
394    version == snapshot_version(dialect) && json.get("ddl").is_some_and(serde_json::Value::is_array)
395}
396
397/// Upgrade a single snapshot file if needed
398/// Returns true if the file was upgraded, false if already at latest version
399fn upgrade_snapshot_file(path: &Path, dialect: Dialect) -> Result<bool, CliError> {
400    let contents = fs::read_to_string(path).map_err(|e| CliError::IoError(e.to_string()))?;
401
402    let json: serde_json::Value = serde_json::from_str(&contents)
403        .map_err(|e| CliError::Other(format!("Invalid JSON in {}: {}", path.display(), e)))?;
404
405    if is_current_format(&json, dialect) {
406        return Ok(false);
407    }
408
409    // Get current version
410    let version = json
411        .get("version")
412        .and_then(|v| v.as_str())
413        .unwrap_or("unknown");
414
415    let latest_version = snapshot_version(dialect);
416
417    // Check if version is supported for upgrade
418    let version_num: u32 = version.parse().unwrap_or(0);
419    if !is_supported_version(dialect, version) && version_num > 0 {
420        println!(
421            "{}",
422            output::warning(&format!(
423                "Skipping {}: version {} is not supported for upgrade",
424                path.display(),
425                version
426            ))
427        );
428        return Ok(false);
429    }
430
431    println!(
432        "{}",
433        output::info(&format!(
434            "Upgrading {} from version {} to {}",
435            path.display(),
436            version,
437            latest_version
438        ))
439    );
440
441    // Upgrade the snapshot and refuse to write anything that doesn't parse
442    // as the current typed format.
443    let upgraded = upgrade_to_latest(json, dialect);
444    let upgraded_json = validated_pretty_snapshot(&upgraded, dialect).map_err(|e| {
445        CliError::Other(format!(
446            "Snapshot {} did not convert cleanly to the current format: {}",
447            path.display(),
448            e
449        ))
450    })?;
451
452    fs::write(path, upgraded_json).map_err(|e| CliError::IoError(e.to_string()))?;
453
454    Ok(true)
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use tempfile::TempDir;
461
462    #[test]
463    fn test_find_v3_snapshots() {
464        let temp_dir = TempDir::new().unwrap();
465
466        // Create a V3 migration folder
467        let migration_folder = temp_dir.path().join("20231220_initial");
468        fs::create_dir_all(&migration_folder).unwrap();
469        fs::write(migration_folder.join("snapshot.json"), "{}").unwrap();
470        fs::write(migration_folder.join("migration.sql"), "").unwrap();
471
472        let snapshots = find_v3_snapshots(temp_dir.path()).unwrap();
473        assert_eq!(snapshots.len(), 1);
474    }
475
476    #[test]
477    fn test_find_legacy_snapshots() {
478        let temp_dir = TempDir::new().unwrap();
479
480        // Create a legacy meta folder
481        let meta_folder = temp_dir.path().join("meta");
482        fs::create_dir_all(&meta_folder).unwrap();
483        fs::write(meta_folder.join("0000_initial_snapshot.json"), "{}").unwrap();
484        fs::write(meta_folder.join("0001_add_users_snapshot.json"), "{}").unwrap();
485        fs::write(meta_folder.join("_journal.json"), "{}").unwrap(); // Should not be included
486
487        let snapshots = find_legacy_snapshots(&meta_folder).unwrap();
488        assert_eq!(snapshots.len(), 2);
489    }
490
491    #[test]
492    fn test_is_current_format_requires_ddl_array() {
493        // A mis-stamped legacy doc (latest version string, object shape)
494        // must not be treated as current.
495        let mis_stamped = serde_json::json!({
496            "version": snapshot_version(Dialect::SQLite),
497            "dialect": "sqlite",
498            "tables": {}
499        });
500        assert!(!is_current_format(&mis_stamped, Dialect::SQLite));
501
502        let current = serde_json::json!({
503            "version": snapshot_version(Dialect::SQLite),
504            "dialect": "sqlite",
505            "ddl": []
506        });
507        assert!(is_current_format(&current, Dialect::SQLite));
508    }
509
510    #[test]
511    fn mysql_latest_snapshot_is_validated_like_other_dialects() {
512        let current = serde_json::json!({
513            "version": snapshot_version(Dialect::MySQL),
514            "dialect": "mysql",
515            "id": "00000000-0000-0000-0000-000000000001",
516            "prevIds": ["00000000-0000-0000-0000-000000000000"],
517            "ddl": []
518        });
519        let rendered =
520            validated_pretty_snapshot(&current, Dialect::MySQL).expect("current MySQL snapshot");
521        assert!(rendered.contains("\"dialect\": \"mysql\""));
522    }
523}