Skip to main content

drizzle_migrations/
writer.rs

1//! Low-level migration file writer for V3 folder layouts.
2//!
3//! Prefer [`crate::build::run`] for normal `build.rs` workflows. This module is the
4//! lower-level writer used for custom generation flows.
5//!
6//! V3 format (matches drizzle-kit):
7//! - Each migration is in its own folder: `out/{tag}/`
8//! - SQL file: `out/{tag}/migration.sql`
9//! - Snapshot: `out/{tag}/snapshot.json`
10//! - Tag format: `YYYYMMDDHHMMSS_adjective_hero` (or custom name)
11//!
12//! No journal file is used - migrations are discovered by scanning folders.
13
14use crate::naming::{PrefixMode, generate_migration_tag, validate_migration_name};
15use crate::sqlite::statements::Generator as SqliteGenerator;
16use crate::sqlite::{SQLiteSnapshot, SchemaDiff as SqliteSchemaDiff};
17use crate::version::ORIGIN_UUID;
18use drizzle_types::Dialect;
19
20use std::fs;
21use std::io;
22use std::path::{Path, PathBuf};
23
24/// Publish a complete migration directory without exposing partially written files.
25///
26/// The callback writes into a unique sibling staging directory. The staging
27/// directory is renamed to `tag` only after the callback succeeds.
28///
29/// # Errors
30///
31/// Returns a configuration error for an invalid or existing tag, and an I/O
32/// error when staging, writing, or publishing fails.
33#[doc(hidden)]
34pub fn publish_migration_directory(
35    out: &Path,
36    tag: &str,
37    write: impl FnOnce(&Path) -> Result<(), MigrationError>,
38) -> Result<PathBuf, MigrationError> {
39    validate_migration_name(tag).map_err(|error| MigrationError::ConfigError(error.to_string()))?;
40    fs::create_dir_all(out).map_err(|error| MigrationError::IoError(error.to_string()))?;
41
42    let destination = out.join(tag);
43    if destination.exists() {
44        return Err(MigrationError::ConfigError(format!(
45            "migration `{tag}` already exists"
46        )));
47    }
48
49    let staging = out.join(format!(".{tag}.{}.tmp", uuid::Uuid::new_v4()));
50    fs::create_dir(&staging).map_err(|error| MigrationError::IoError(error.to_string()))?;
51
52    if let Err(error) = write(&staging) {
53        let _ = fs::remove_dir_all(&staging);
54        return Err(error);
55    }
56
57    if destination.exists() {
58        let _ = fs::remove_dir_all(&staging);
59        return Err(MigrationError::ConfigError(format!(
60            "migration `{tag}` already exists"
61        )));
62    }
63
64    match fs::rename(&staging, &destination) {
65        Ok(()) => Ok(destination),
66        Err(error) => {
67            let _ = fs::remove_dir_all(&staging);
68            Err(MigrationError::IoError(error.to_string()))
69        }
70    }
71}
72
73// =============================================================================
74// Migration Writer V3 (folder-based, matches drizzle-kit)
75// =============================================================================
76
77/// Low-level writer for creating migration files in V3 folder structure.
78///
79/// V3 format creates a folder per migration:
80/// ```rust
81/// # let _ = r####"
82/// out/
83///   20231220143052_initial_schema/
84///     migration.sql
85///     snapshot.json
86///   20231221093015_add_users/
87///     migration.sql
88///     snapshot.json
89/// # "####;
90/// ```
91pub struct Writer {
92    /// Output directory for migrations
93    out: PathBuf,
94    /// Database dialect
95    dialect: Dialect,
96    /// Enable SQL statement breakpoints
97    breakpoints: bool,
98    /// Prefix mode for migration tags
99    prefix_mode: PrefixMode,
100    /// Optional custom name for migrations
101    custom_name: Option<String>,
102}
103
104impl Writer {
105    /// Create a new migration writer with the given settings
106    pub fn new(out: impl Into<PathBuf>, dialect: Dialect) -> Self {
107        Self {
108            out: out.into(),
109            dialect,
110            breakpoints: true,
111            prefix_mode: PrefixMode::Timestamp, // V3 default
112            custom_name: None,
113        }
114    }
115
116    /// Set whether to use breakpoints in generated SQL
117    #[must_use]
118    pub const fn with_breakpoints(mut self, enabled: bool) -> Self {
119        self.breakpoints = enabled;
120        self
121    }
122
123    /// Set the prefix mode for migration tags
124    #[must_use]
125    pub const fn with_prefix_mode(mut self, mode: PrefixMode) -> Self {
126        self.prefix_mode = mode;
127        self
128    }
129
130    /// Set a custom name for the next migration
131    #[must_use]
132    pub fn with_custom_name(mut self, name: impl Into<String>) -> Self {
133        self.custom_name = Some(name.into());
134        self
135    }
136
137    /// Get the migrations directory path
138    #[must_use]
139    pub fn migrations_dir(&self) -> &Path {
140        &self.out
141    }
142
143    /// Get the dialect
144    #[must_use]
145    pub const fn dialect(&self) -> Dialect {
146        self.dialect
147    }
148
149    /// Ensure the migration directory exists.
150    ///
151    /// # Errors
152    ///
153    /// Returns an error if the migrations directory cannot be created (e.g.
154    /// insufficient permissions or a conflicting non-directory file exists).
155    pub fn ensure_dirs(&self) -> io::Result<()> {
156        fs::create_dir_all(self.migrations_dir())?;
157        Ok(())
158    }
159
160    /// Get the path to a migration folder
161    #[must_use]
162    pub fn migration_folder_path(&self, tag: &str) -> PathBuf {
163        self.out.join(tag)
164    }
165
166    /// Get the path to a migration SQL file (V3 format: folder/migration.sql)
167    #[must_use]
168    pub fn migration_sql_path(&self, tag: &str) -> PathBuf {
169        self.migration_folder_path(tag).join("migration.sql")
170    }
171
172    /// Get the path to a snapshot file (V3 format: folder/snapshot.json)
173    #[must_use]
174    pub fn snapshot_path(&self, tag: &str) -> PathBuf {
175        self.migration_folder_path(tag).join("snapshot.json")
176    }
177
178    /// Discover all existing migration folders, sorted by name.
179    ///
180    /// # Errors
181    ///
182    /// Returns an error if the migrations directory cannot be read.
183    pub fn discover_migrations(&self) -> io::Result<Vec<String>> {
184        if !self.out.exists() {
185            return Ok(Vec::new());
186        }
187
188        let mut folders: Vec<String> = fs::read_dir(&self.out)?
189            .filter_map(std::result::Result::ok)
190            .filter(|entry| entry.file_type().is_ok_and(|t| t.is_dir()))
191            .filter_map(|entry| {
192                let name = entry.file_name().to_string_lossy().to_string();
193                // migration.sql marks a migration folder; custom migrations
194                // have no snapshot.json but still occupy an index slot.
195                if entry.path().join("migration.sql").exists() {
196                    Some(name)
197                } else {
198                    None
199                }
200            })
201            .collect();
202
203        folders.sort();
204        Ok(folders)
205    }
206
207    /// Load the previous snapshot by scanning existing migration folders.
208    ///
209    /// # Errors
210    ///
211    /// Returns an error if the migrations directory cannot be read or the
212    /// found snapshot cannot be parsed.
213    pub fn load_previous_snapshot(&self) -> io::Result<SQLiteSnapshot> {
214        let migrations = self.discover_migrations()?;
215
216        // The newest folder with a snapshot is the baseline; snapshot-less
217        // custom migrations in between must not reset it to empty.
218        for tag in migrations.iter().rev() {
219            let snapshot_path = self.snapshot_path(tag);
220            if snapshot_path.exists() {
221                return SQLiteSnapshot::load(&snapshot_path);
222            }
223        }
224
225        Ok(SQLiteSnapshot::new())
226    }
227
228    /// Write a `SQLite` migration in V3 folder format.
229    ///
230    /// # Errors
231    ///
232    /// Returns [`MigrationError::NoChanges`] if the diff produces no
233    /// statements, or [`MigrationError::IoError`] if any filesystem
234    /// operation fails during migration emission.
235    pub fn write_sqlite_migration(
236        &self,
237        diff: &SqliteSchemaDiff,
238        current_snapshot: &SQLiteSnapshot,
239    ) -> Result<String, MigrationError> {
240        // Ensure base directory exists
241        self.ensure_dirs()
242            .map_err(|e| MigrationError::IoError(e.to_string()))?;
243
244        // Discover existing migrations for indexing
245        let existing = self
246            .discover_migrations()
247            .map_err(|e| MigrationError::IoError(e.to_string()))?;
248        let idx = u32::try_from(existing.len()).unwrap_or(u32::MAX);
249
250        // Generate tag
251        let tag = match self.prefix_mode {
252            PrefixMode::Timestamp => generate_migration_tag(self.custom_name.as_deref()),
253            _ => crate::naming::generate_migration_tag_with_mode(
254                self.prefix_mode,
255                idx,
256                self.custom_name.as_deref(),
257            ),
258        };
259
260        // Generate SQL
261        let generator = SqliteGenerator::new().with_breakpoints(self.breakpoints);
262        let statements = generator.generate_migration(diff);
263
264        if statements.is_empty() {
265            return Err(MigrationError::NoChanges);
266        }
267
268        let sql = generator.statements_to_sql(&statements);
269
270        // Create snapshot with proper chain
271        let mut snapshot = current_snapshot.clone();
272        let prev_ids = if existing.is_empty() {
273            vec![ORIGIN_UUID.to_string()]
274        } else {
275            // Load previous snapshot to get its ID
276            let prev_snapshot = self
277                .load_previous_snapshot()
278                .map_err(|e| MigrationError::IoError(e.to_string()))?;
279            vec![prev_snapshot.id]
280        };
281        snapshot.prev_ids = prev_ids;
282        snapshot.id = uuid::Uuid::new_v4().to_string();
283
284        publish_migration_directory(&self.out, &tag, |folder| {
285            fs::write(folder.join("migration.sql"), &sql)
286                .map_err(|error| MigrationError::IoError(error.to_string()))?;
287            snapshot
288                .save(&folder.join("snapshot.json"))
289                .map_err(|error| MigrationError::SnapshotError(error.to_string()))
290        })?;
291
292        Ok(tag)
293    }
294
295    /// Generate migration from comparing two snapshots.
296    ///
297    /// # Errors
298    ///
299    /// Returns [`MigrationError::NoChanges`] if the snapshots diff is empty,
300    /// or any error produced by [`Self::write_sqlite_migration`].
301    pub fn generate_migration_from_snapshots(
302        &self,
303        prev: &SQLiteSnapshot,
304        cur: &SQLiteSnapshot,
305    ) -> Result<String, MigrationError> {
306        let diff = crate::sqlite::diff_snapshots(prev, cur);
307
308        if diff.is_empty() {
309            return Err(MigrationError::NoChanges);
310        }
311
312        self.write_sqlite_migration(&diff, cur)
313    }
314
315    /// Write a custom (empty) migration for user SQL.
316    ///
317    /// # Errors
318    ///
319    /// Returns [`MigrationError::IoError`] if directory creation or file
320    /// writes fail while emitting the placeholder migration folder.
321    pub fn write_custom_migration(&self) -> Result<String, MigrationError> {
322        // Ensure base directory exists
323        self.ensure_dirs()
324            .map_err(|e| MigrationError::IoError(e.to_string()))?;
325
326        // Discover existing migrations for indexing
327        let existing = self
328            .discover_migrations()
329            .map_err(|e| MigrationError::IoError(e.to_string()))?;
330        let idx = u32::try_from(existing.len()).unwrap_or(u32::MAX);
331
332        // Generate tag
333        let tag = match self.prefix_mode {
334            PrefixMode::Timestamp => generate_migration_tag(self.custom_name.as_deref()),
335            _ => crate::naming::generate_migration_tag_with_mode(
336                self.prefix_mode,
337                idx,
338                self.custom_name.as_deref(),
339            ),
340        };
341
342        // Create a minimal snapshot
343        let prev_snapshot = self
344            .load_previous_snapshot()
345            .map_err(|e| MigrationError::IoError(e.to_string()))?;
346
347        let mut snapshot = prev_snapshot.clone();
348        snapshot.prev_ids = if existing.is_empty() {
349            vec![ORIGIN_UUID.to_string()]
350        } else {
351            vec![prev_snapshot.id]
352        };
353        snapshot.id = uuid::Uuid::new_v4().to_string();
354
355        publish_migration_directory(&self.out, &tag, |folder| {
356            let sql = "-- Custom SQL migration file, put your code below! --\n";
357            fs::write(folder.join("migration.sql"), sql)
358                .map_err(|error| MigrationError::IoError(error.to_string()))?;
359            snapshot
360                .save(&folder.join("snapshot.json"))
361                .map_err(|error| MigrationError::SnapshotError(error.to_string()))
362        })?;
363
364        Ok(tag)
365    }
366}
367
368// =============================================================================
369// Migration Errors
370// =============================================================================
371
372/// Migration errors
373#[derive(Debug, thiserror::Error)]
374pub enum MigrationError {
375    #[error("Configuration error: {0}")]
376    ConfigError(String),
377
378    #[error("IO error: {0}")]
379    IoError(String),
380
381    #[error("No schema changes detected")]
382    NoChanges,
383
384    #[error("Snapshot error: {0}")]
385    SnapshotError(String),
386
387    #[error("Dialect mismatch: cannot diff snapshots from different dialects")]
388    DialectMismatch,
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    #[test]
396    fn publish_directory_is_complete_and_refuses_collisions() {
397        let temp = tempfile::tempdir().expect("create temp directory");
398        let destination = publish_migration_directory(temp.path(), "0001_initial", |folder| {
399            fs::write(folder.join("migration.sql"), "SELECT 1;")
400                .map_err(|error| MigrationError::IoError(error.to_string()))?;
401            fs::write(folder.join("snapshot.json"), "{}")
402                .map_err(|error| MigrationError::IoError(error.to_string()))
403        })
404        .expect("publish migration");
405
406        assert!(destination.join("migration.sql").is_file());
407        assert!(destination.join("snapshot.json").is_file());
408
409        let error = publish_migration_directory(temp.path(), "0001_initial", |_| Ok(()))
410            .expect_err("collision must fail");
411        assert!(matches!(error, MigrationError::ConfigError(_)));
412        assert_eq!(
413            fs::read_to_string(destination.join("migration.sql")).expect("read original"),
414            "SELECT 1;"
415        );
416    }
417
418    #[test]
419    fn publish_directory_cleans_staging_after_write_failure() {
420        let temp = tempfile::tempdir().expect("create temp directory");
421        let error = publish_migration_directory(temp.path(), "0002_broken", |folder| {
422            fs::write(folder.join("migration.sql"), "SELECT 1;")
423                .map_err(|error| MigrationError::IoError(error.to_string()))?;
424            Err(MigrationError::SnapshotError("injected failure".into()))
425        })
426        .expect_err("write failure must propagate");
427
428        assert!(matches!(error, MigrationError::SnapshotError(_)));
429        assert!(!temp.path().join("0002_broken").exists());
430        assert_eq!(fs::read_dir(temp.path()).expect("read output").count(), 0);
431    }
432
433    #[test]
434    fn publish_directory_rejects_unsafe_tag_before_writing() {
435        let temp = tempfile::tempdir().expect("create temp directory");
436        let mut called = false;
437        let error = publish_migration_directory(temp.path(), "../escape", |_| {
438            called = true;
439            Ok(())
440        })
441        .expect_err("unsafe tag must fail");
442
443        assert!(!called);
444        assert!(matches!(error, MigrationError::ConfigError(_)));
445        assert!(
446            !temp
447                .path()
448                .parent()
449                .expect("parent")
450                .join("escape")
451                .exists()
452        );
453    }
454}