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            return Err(CliError::Other(
270                "MySQL snapshot upgrades are not supported".to_string(),
271            ));
272        }
273    }
274
275    serde_json::to_string_pretty(upgraded).map_err(|e| CliError::Other(e.to_string()))
276}
277
278// =============================================================================
279// In-place upgrades (v1-beta layout with old snapshot versions)
280// =============================================================================
281
282/// Upgrade all snapshots in a migrations folder
283fn upgrade_snapshots(out_dir: &Path, dialect: Dialect) -> Result<usize, CliError> {
284    let mut upgraded_count = 0;
285
286    // Check for V3 folder-based migrations (each folder has snapshot.json)
287    let v3_snapshots = find_v3_snapshots(out_dir)?;
288
289    for snapshot_path in v3_snapshots {
290        if upgrade_snapshot_file(&snapshot_path, dialect)? {
291            upgraded_count += 1;
292        }
293    }
294
295    // Also check for orphaned meta/ folder snapshots (no _journal.json, so
296    // the full layout conversion doesn't apply)
297    let meta_folder = out_dir.join("meta");
298    if meta_folder.exists() {
299        let legacy_snapshots = find_legacy_snapshots(&meta_folder)?;
300        for snapshot_path in legacy_snapshots {
301            if upgrade_snapshot_file(&snapshot_path, dialect)? {
302                upgraded_count += 1;
303            }
304        }
305    }
306
307    Ok(upgraded_count)
308}
309
310/// Find V3 format snapshots (folder/snapshot.json)
311fn find_v3_snapshots(out_dir: &Path) -> Result<Vec<std::path::PathBuf>, CliError> {
312    let mut snapshots = Vec::new();
313
314    if !out_dir.exists() {
315        return Ok(snapshots);
316    }
317
318    for entry in fs::read_dir(out_dir).map_err(|e| CliError::IoError(e.to_string()))? {
319        let entry = entry.map_err(|e| CliError::IoError(e.to_string()))?;
320        let path = entry.path();
321
322        if path.is_dir() {
323            let snapshot_path = path.join("snapshot.json");
324            if snapshot_path.exists() {
325                snapshots.push(snapshot_path);
326            }
327        }
328    }
329
330    Ok(snapshots)
331}
332
333/// Find legacy format snapshots (meta/*_snapshot.json)
334fn find_legacy_snapshots(meta_folder: &Path) -> Result<Vec<std::path::PathBuf>, CliError> {
335    let mut snapshots = Vec::new();
336
337    if !meta_folder.exists() {
338        return Ok(snapshots);
339    }
340
341    for entry in fs::read_dir(meta_folder).map_err(|e| CliError::IoError(e.to_string()))? {
342        let entry = entry.map_err(|e| CliError::IoError(e.to_string()))?;
343        let path = entry.path();
344
345        if path.is_file()
346            && let Some(name) = path.file_name().and_then(|n| n.to_str())
347            && name.ends_with("_snapshot.json")
348        {
349            snapshots.push(path);
350        }
351    }
352
353    Ok(snapshots)
354}
355
356/// True if the snapshot document already has the current entity-array
357/// structure (a `ddl` array) *and* the latest version stamp. Version alone is
358/// not trusted: historic upgrades stamped the latest version onto documents
359/// that still had the legacy object shape.
360fn is_current_format(json: &serde_json::Value, dialect: Dialect) -> bool {
361    let version = json
362        .get("version")
363        .and_then(serde_json::Value::as_str)
364        .unwrap_or("unknown");
365
366    version == snapshot_version(dialect) && json.get("ddl").is_some_and(serde_json::Value::is_array)
367}
368
369/// Upgrade a single snapshot file if needed
370/// Returns true if the file was upgraded, false if already at latest version
371fn upgrade_snapshot_file(path: &Path, dialect: Dialect) -> Result<bool, CliError> {
372    let contents = fs::read_to_string(path).map_err(|e| CliError::IoError(e.to_string()))?;
373
374    let json: serde_json::Value = serde_json::from_str(&contents)
375        .map_err(|e| CliError::Other(format!("Invalid JSON in {}: {}", path.display(), e)))?;
376
377    if is_current_format(&json, dialect) {
378        return Ok(false);
379    }
380
381    // Get current version
382    let version = json
383        .get("version")
384        .and_then(|v| v.as_str())
385        .unwrap_or("unknown");
386
387    let latest_version = snapshot_version(dialect);
388
389    // Check if version is supported for upgrade
390    let version_num: u32 = version.parse().unwrap_or(0);
391    if !is_supported_version(dialect, version) && version_num > 0 {
392        println!(
393            "{}",
394            output::warning(&format!(
395                "Skipping {}: version {} is not supported for upgrade",
396                path.display(),
397                version
398            ))
399        );
400        return Ok(false);
401    }
402
403    println!(
404        "{}",
405        output::info(&format!(
406            "Upgrading {} from version {} to {}",
407            path.display(),
408            version,
409            latest_version
410        ))
411    );
412
413    // Upgrade the snapshot and refuse to write anything that doesn't parse
414    // as the current typed format.
415    let upgraded = upgrade_to_latest(json, dialect);
416    let upgraded_json = validated_pretty_snapshot(&upgraded, dialect).map_err(|e| {
417        CliError::Other(format!(
418            "Snapshot {} did not convert cleanly to the current format: {}",
419            path.display(),
420            e
421        ))
422    })?;
423
424    fs::write(path, upgraded_json).map_err(|e| CliError::IoError(e.to_string()))?;
425
426    Ok(true)
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432    use tempfile::TempDir;
433
434    #[test]
435    fn test_find_v3_snapshots() {
436        let temp_dir = TempDir::new().unwrap();
437
438        // Create a V3 migration folder
439        let migration_folder = temp_dir.path().join("20231220_initial");
440        fs::create_dir_all(&migration_folder).unwrap();
441        fs::write(migration_folder.join("snapshot.json"), "{}").unwrap();
442        fs::write(migration_folder.join("migration.sql"), "").unwrap();
443
444        let snapshots = find_v3_snapshots(temp_dir.path()).unwrap();
445        assert_eq!(snapshots.len(), 1);
446    }
447
448    #[test]
449    fn test_find_legacy_snapshots() {
450        let temp_dir = TempDir::new().unwrap();
451
452        // Create a legacy meta folder
453        let meta_folder = temp_dir.path().join("meta");
454        fs::create_dir_all(&meta_folder).unwrap();
455        fs::write(meta_folder.join("0000_initial_snapshot.json"), "{}").unwrap();
456        fs::write(meta_folder.join("0001_add_users_snapshot.json"), "{}").unwrap();
457        fs::write(meta_folder.join("_journal.json"), "{}").unwrap(); // Should not be included
458
459        let snapshots = find_legacy_snapshots(&meta_folder).unwrap();
460        assert_eq!(snapshots.len(), 2);
461    }
462
463    #[test]
464    fn test_is_current_format_requires_ddl_array() {
465        // A mis-stamped legacy doc (latest version string, object shape)
466        // must not be treated as current.
467        let mis_stamped = serde_json::json!({
468            "version": snapshot_version(Dialect::SQLite),
469            "dialect": "sqlite",
470            "tables": {}
471        });
472        assert!(!is_current_format(&mis_stamped, Dialect::SQLite));
473
474        let current = serde_json::json!({
475            "version": snapshot_version(Dialect::SQLite),
476            "dialect": "sqlite",
477            "ddl": []
478        });
479        assert!(is_current_format(&current, Dialect::SQLite));
480    }
481}