1use 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 #[arg(long)]
26 pub dialect: Option<CliDialect>,
27
28 #[arg(long)]
30 pub out: Option<PathBuf>,
31}
32
33pub fn run(config: &Config, db_name: Option<&str>, opts: &UpgradeOptions) -> Result<(), CliError> {
42 let db = config.database(db_name)?;
43
44 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 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
106struct ConvertedEntry {
113 tag: String,
114 sql_path: PathBuf,
115 sql: String,
116 snapshot_json: String,
117}
118
119fn 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 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 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 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
249fn 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
277fn upgrade_snapshots(out_dir: &Path, dialect: Dialect) -> Result<usize, CliError> {
283 let mut upgraded_count = 0;
284
285 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 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
309fn 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
332fn 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
355fn 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
368fn 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 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 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 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 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 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(); 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 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(¤t, 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(¤t, Dialect::MySQL).expect("current MySQL snapshot");
492 assert!(rendered.contains("\"dialect\": \"mysql\""));
493 }
494}