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 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
278fn upgrade_snapshots(out_dir: &Path, dialect: Dialect) -> Result<usize, CliError> {
284 let mut upgraded_count = 0;
285
286 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 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
310fn 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
333fn 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
356fn 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
369fn 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 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 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 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 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 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(); 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 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(¤t, Dialect::SQLite));
480 }
481}