1use 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 #[arg(long)]
30 pub dialect: Option<CliDialect>,
31
32 #[arg(long)]
34 pub out: Option<PathBuf>,
35}
36
37pub fn run(config: &Config, db_name: Option<&str>, opts: &UpgradeOptions) -> Result<(), CliError> {
48 let db = config.database(db_name)?;
49
50 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 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
112struct ConvertedEntry {
119 tag: String,
120 folder: String,
122 sql_path: PathBuf,
123 sql: String,
124 snapshot_json: String,
125}
126
127pub(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 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 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 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 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
278fn 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
306fn upgrade_snapshots(out_dir: &Path, dialect: Dialect) -> Result<usize, CliError> {
312 let mut upgraded_count = 0;
313
314 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 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
338fn 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
361fn 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
384fn 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
397fn 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 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 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 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 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 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(); 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 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(¤t, 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(¤t, Dialect::MySQL).expect("current MySQL snapshot");
521 assert!(rendered.contains("\"dialect\": \"mysql\""));
522 }
523}