Skip to main content

coven_database/
migration.rs

1//! Numbered app migrations and their transaction-scoped SQL access.
2
3use crate::migration_names::MigrationNames;
4use crate::sqlite::DatabaseConnection;
5use crate::{DbError, MigrationError};
6use rusqlite::{Params, Row};
7use std::cell::RefCell;
8use std::sync::Arc;
9
10/// How one migration changed the schema and synced rows (§18.1).
11#[derive(Clone, Debug, Eq, PartialEq)]
12pub enum MigrationChange {
13    /// No synced definition or row changed and no table disappeared. Local-only and
14    /// view changes must not be mistaken for a synced schema addition (§18.1).
15    NoChange,
16    /// Only synced tables or their columns were added.
17    Addition,
18    /// A table disappeared, a synced definition changed beyond an addition,
19    /// or statements changed rows of synced tables.
20    Breaking,
21}
22
23/// The classification of a migration committed by this open.
24#[derive(Clone, Debug, Eq, PartialEq)]
25pub struct MigrationOutcome {
26    /// Its position in the app's migration sequence.
27    pub version: u32,
28    /// The app's name for this migration.
29    pub name: &'static str,
30    /// Its schema comparison and row changes, before subsequent migrations.
31    pub change: MigrationChange,
32}
33
34pub(crate) type MigrationOperation =
35    dyn Fn(u32) -> Result<crate::NewOperation, DbError> + Send + Sync;
36
37type WriteConversion = dyn Fn(&mut crate::RowChange) -> Result<(), DbError> + Send + Sync;
38
39type MigrationBody = dyn Fn(&MigrationContext<'_>) -> Result<(), DbError> + Send + Sync;
40
41/// What a breaking migration does with received late old-version writes.
42#[derive(Clone)]
43pub enum OldWrites {
44    /// Convert eligible changes without rewriting their original upload records.
45    Convert(Arc<WriteConversion>),
46    /// Keep each original change as a frozen schema loss; never discard its values.
47    Drop,
48}
49
50#[derive(Clone)]
51pub(crate) enum MigrationKind {
52    Addition,
53    Breaking(OldWrites),
54}
55
56/// One numbered change to the app's database, with its declared kind.
57/// Clones retain the same callbacks across bootstrap and database opening.
58#[derive(Clone)]
59pub struct Migration {
60    pub(crate) version: u32,
61    pub(crate) name: &'static str,
62    body: Arc<MigrationBody>,
63    pub(crate) kind: MigrationKind,
64}
65
66impl Migration {
67    /// SQL that adds tables or columns, or changes only local objects.
68    /// A runtime breaking classification refuses the whole migration batch.
69    pub fn addition(version: u32, name: &'static str, sql: &'static str) -> Self {
70        Self::addition_with(version, name, move |context| {
71            context.execute_batch(sql)?;
72            Ok(())
73        })
74    }
75
76    /// Code that adds tables or columns, or changes only local objects.
77    pub fn addition_with<F>(version: u32, name: &'static str, f: F) -> Self
78    where
79        F: Fn(&MigrationContext<'_>) -> Result<(), DbError> + Send + Sync + 'static,
80    {
81        Self { version, name, body: Arc::new(f), kind: MigrationKind::Addition }
82    }
83
84    /// Breaking SQL with an explicit choice for late old-version writes.
85    /// An addition or local-only classification refuses the whole batch.
86    ///
87    /// A breaking migration cannot omit the old-write choice:
88    /// ```compile_fail
89    /// use coven_database::Migration;
90    /// Migration::breaking(2, "rename", "ALTER TABLE notes RENAME COLUMN title TO name");
91    /// ```
92    pub fn breaking(
93        version: u32,
94        name: &'static str,
95        sql: &'static str,
96        old_writes: OldWrites,
97    ) -> Self {
98        Self::breaking_with(
99            version,
100            name,
101            move |context| {
102                context.execute_batch(sql)?;
103                Ok(())
104            },
105            old_writes,
106        )
107    }
108
109    /// Breaking code, including backfills and rebuilds, with its old-write choice.
110    ///
111    /// ```compile_fail
112    /// use coven_database::Migration;
113    /// Migration::breaking_with(2, "backfill", |context| {
114    ///     context.execute_batch("UPDATE notes SET title=upper(title)")?;
115    ///     Ok(())
116    /// });
117    /// ```
118    pub fn breaking_with<F>(version: u32, name: &'static str, f: F, old_writes: OldWrites) -> Self
119    where
120        F: Fn(&MigrationContext<'_>) -> Result<(), DbError> + Send + Sync + 'static,
121    {
122        Self { version, name, body: Arc::new(f), kind: MigrationKind::Breaking(old_writes) }
123    }
124
125    pub(crate) fn check_kind(&self, breaking: bool) -> Result<(), DbError> {
126        if matches!(self.kind, MigrationKind::Breaking(_)) != breaking {
127            return Err(DbError::MigrationKindMismatch { version: self.version });
128        }
129        Ok(())
130    }
131
132    pub(crate) fn apply(&self, context: &MigrationContext<'_>) -> Result<(), DbError> {
133        (self.body)(context)
134    }
135}
136
137pub(crate) fn validate_versions(migrations: &[Migration]) -> Result<u32, MigrationError> {
138    let mut supported = 0u32;
139    for (position, migration) in migrations.iter().enumerate() {
140        let expected = supported.checked_add(1).expect("migration list fits in u32");
141        if migration.version != expected {
142            return Err(MigrationError::NotContiguous {
143                position,
144                found: migration.version,
145                expected,
146            });
147        }
148        supported = expected;
149    }
150    Ok(supported)
151}
152
153/// App SQL inside the transaction owned by the migration run.
154/// The context borrows its owner and cannot change the transaction or connection.
155pub struct MigrationContext<'connection> {
156    database: &'connection DatabaseConnection,
157    names: RefCell<MigrationNames>,
158    /// The schema before this step, which the step hands back when it ends.
159    before: crate::schema::Schema,
160    /// The run's capture, which each step takes from the last.
161    capture: RefCell<crate::migration_snapshot::MigrationCapture<'connection>>,
162}
163
164impl<'connection> MigrationContext<'connection> {
165    pub(crate) fn new(
166        database: &'connection DatabaseConnection,
167        before: crate::schema::Schema,
168        capture: crate::migration_snapshot::MigrationCapture<'connection>,
169    ) -> Self {
170        Self {
171            database,
172            names: RefCell::new(MigrationNames::new(&before)),
173            before,
174            capture: RefCell::new(capture),
175        }
176    }
177
178    /// The step's effects on the schema, the schema it started from, and the
179    /// run's capture for the next step.
180    pub(crate) fn finish(
181        self,
182        after: &crate::schema::Schema,
183    ) -> (
184        crate::migration_names::MigrationEffects,
185        crate::schema::Schema,
186        crate::migration_snapshot::MigrationCapture<'connection>,
187    ) {
188        (
189            self.names.into_inner().finish(&self.before, after),
190            self.before,
191            self.capture.into_inner(),
192        )
193    }
194
195    fn statement<T>(
196        &self,
197        sql: &str,
198        run: impl FnOnce() -> rusqlite::Result<T>,
199    ) -> rusqlite::Result<T> {
200        self.before_statement(sql)?;
201        let before = self.database.schema_cookie()?;
202        let result = run();
203        if self.database.schema_cookie()? != before {
204            self.record(sql)?;
205        }
206        result
207    }
208
209    pub(crate) fn before_statement(&self, sql: &str) -> rusqlite::Result<()> {
210        self.capture.borrow_mut().before(&self.before, sql)
211    }
212
213    pub(crate) fn record(&self, sql: &str) -> rusqlite::Result<()> {
214        self.names.borrow_mut().record(sql)
215    }
216
217    /// Execute one app statement with parameters.
218    pub fn execute<P: Params>(&self, sql: &str, params: P) -> rusqlite::Result<usize> {
219        self.statement(sql, || self.database.app_execute(sql, params))
220    }
221
222    /// Execute app statements without escaping the migration transaction.
223    pub fn execute_batch(&self, sql: &str) -> rusqlite::Result<()> {
224        self.database.app_batch_tracked(sql, Some(self))
225    }
226
227    /// Map one row from an app query.
228    pub fn query_row<T, P, F>(&self, sql: &str, params: P, map: F) -> rusqlite::Result<T>
229    where
230        P: Params,
231        F: FnOnce(&Row<'_>) -> rusqlite::Result<T>,
232    {
233        self.statement(sql, || self.database.app_query_row(sql, params, map))
234    }
235
236    /// Map every result of an app query.
237    pub fn query<T, P, F>(&self, sql: &str, params: P, map: F) -> rusqlite::Result<Vec<T>>
238    where
239        P: Params,
240        F: FnMut(&Row<'_>) -> rusqlite::Result<T>,
241    {
242        self.statement(sql, || self.database.app_query(sql, params, map))
243    }
244}
245
246#[cfg(test)]
247#[path = "migration_tests.rs"]
248pub(crate) mod tests;