Skip to main content

drizzle_cli/commands/
upgrade.rs

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