Skip to main content

dovecote_sqlx_sqlite/
migration.rs

1//! Versioned `SQLite` migration metadata.
2
3/// Schema version implemented by this adapter.
4pub const SCHEMA_VERSION: u32 = 2;
5
6/// Numeric crate version used to evaluate migration compatibility.
7#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
8pub struct CrateVersion {
9    major: u16,
10    minor: u16,
11    patch: u16,
12}
13
14impl CrateVersion {
15    /// Creates a numeric crate version.
16    #[must_use]
17    pub const fn new(major: u16, minor: u16, patch: u16) -> Self {
18        Self {
19            major,
20            minor,
21            patch,
22        }
23    }
24    /// Returns the major component.
25    #[must_use]
26    pub const fn major(self) -> u16 {
27        self.major
28    }
29    /// Returns the minor component.
30    #[must_use]
31    pub const fn minor(self) -> u16 {
32        self.minor
33    }
34    /// Returns the patch component.
35    #[must_use]
36    pub const fn patch(self) -> u16 {
37        self.patch
38    }
39    const fn is_less_than(self, other: Self) -> bool {
40        self.major < other.major
41            || (self.major == other.major
42                && (self.minor < other.minor
43                    || (self.minor == other.minor && self.patch < other.patch)))
44    }
45}
46
47/// Error returned when a migration compatibility range is inverted.
48#[derive(Clone, Copy, Debug, Eq, PartialEq)]
49pub struct MigrationCompatibilityError;
50impl std::fmt::Display for MigrationCompatibilityError {
51    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        formatter.write_str("migration compatibility maximum precedes minimum")
53    }
54}
55impl std::error::Error for MigrationCompatibilityError {}
56
57/// A checked crate-version range for a migration artifact.
58#[derive(Clone, Copy, Debug, Eq, PartialEq)]
59pub struct MigrationCompatibility {
60    minimum: CrateVersion,
61    maximum: Option<CrateVersion>,
62}
63impl MigrationCompatibility {
64    const fn new(minimum: CrateVersion, maximum: Option<CrateVersion>) -> Self {
65        Self { minimum, maximum }
66    }
67    /// Creates a compatibility range, rejecting an upper bound below its lower bound.
68    ///
69    /// # Errors
70    /// Returns an error when the maximum compatible version precedes the minimum.
71    pub const fn try_new(
72        minimum: CrateVersion,
73        maximum: Option<CrateVersion>,
74    ) -> Result<Self, MigrationCompatibilityError> {
75        if let Some(maximum) = maximum
76            && maximum.is_less_than(minimum)
77        {
78            return Err(MigrationCompatibilityError);
79        }
80        Ok(Self::new(minimum, maximum))
81    }
82    /// Returns the minimum supported crate version.
83    #[must_use]
84    pub const fn minimum(self) -> CrateVersion {
85        self.minimum
86    }
87    /// Returns the maximum supported crate version, when bounded.
88    #[must_use]
89    pub const fn maximum(self) -> Option<CrateVersion> {
90        self.maximum
91    }
92    /// Returns whether a crate version is inside this compatibility range.
93    #[must_use]
94    pub const fn contains(self, version: CrateVersion) -> bool {
95        !version.is_less_than(self.minimum)
96            && match self.maximum {
97                Some(maximum) => !maximum.is_less_than(version),
98                None => true,
99            }
100    }
101}
102
103/// One immutable, versioned `SQLite` migration artifact.
104#[derive(Clone, Copy, Debug, Eq, PartialEq)]
105pub struct Migration {
106    version: u32,
107    sql: &'static str,
108    compatibility: MigrationCompatibility,
109    rolling_compatible: bool,
110}
111impl Migration {
112    const fn new(
113        version: u32,
114        sql: &'static str,
115        compatibility: MigrationCompatibility,
116        rolling_compatible: bool,
117    ) -> Self {
118        Self {
119            version,
120            sql,
121            compatibility,
122            rolling_compatible,
123        }
124    }
125    /// Returns the schema version introduced by this migration.
126    #[must_use]
127    pub const fn version(self) -> u32 {
128        self.version
129    }
130    /// Returns the migration SQL exactly as shipped.
131    #[must_use]
132    pub const fn sql(self) -> &'static str {
133        self.sql
134    }
135    /// Returns the crate-version compatibility range.
136    #[must_use]
137    pub const fn compatibility(self) -> MigrationCompatibility {
138        self.compatibility
139    }
140    /// Returns whether this migration supports rolling deployment.
141    #[must_use]
142    pub const fn rolling_compatible(self) -> bool {
143        self.rolling_compatible
144    }
145}
146
147/// All migration artifacts shipped by this adapter, in version order.
148pub const MIGRATIONS: &[Migration] = &[Migration::new(
149    2,
150    include_str!("../migrations/0002_dovecote_tenant_baseline.sql"),
151    MigrationCompatibility::new(CrateVersion::new(0, 2, 0), None),
152    false,
153)];
154
155/// The immutable schema version 1 artifact for pre-tenant deployments.
156pub const LEGACY_MIGRATION: Migration = Migration::new(
157    1,
158    include_str!("../migrations/0001_dovecote.sql"),
159    MigrationCompatibility::new(CrateVersion::new(0, 1, 0), None),
160    false,
161);
162
163/// SQL that adds nullable tenant columns to a version 1 deployment.
164pub const V1_TENANT_PREPARE_SQL: &str =
165    include_str!("../migrations/0002_dovecote_tenant_prepare.sql");
166
167/// SQL that validates backfill and activates tenant constraints.
168pub const V1_TENANT_ACTIVATE_SQL: &str =
169    include_str!("../migrations/0002_dovecote_tenant_activate.sql");
170
171pub(crate) fn current_crate_version() -> CrateVersion {
172    CrateVersion::new(
173        env!("CARGO_PKG_VERSION_MAJOR")
174            .parse()
175            .expect("Cargo version is numeric"),
176        env!("CARGO_PKG_VERSION_MINOR")
177            .parse()
178            .expect("Cargo version is numeric"),
179        env!("CARGO_PKG_VERSION_PATCH")
180            .parse()
181            .expect("Cargo version is numeric"),
182    )
183}
184pub(crate) fn current_migration() -> Result<Migration, String> {
185    MIGRATIONS
186        .iter()
187        .find(|migration| migration.version() == SCHEMA_VERSION)
188        .copied()
189        .ok_or_else(|| format!("adapter does not ship schema version {SCHEMA_VERSION}"))
190}
191pub(crate) fn migration_is_usable(migration: Migration) -> Result<(), String> {
192    if !migration.compatibility().contains(current_crate_version()) {
193        return Err("current crate is outside migration compatibility range".to_owned());
194    }
195    Ok(())
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    #[test]
202    fn migrations_are_ordered_and_typed() {
203        assert_eq!(MIGRATIONS[0].version(), SCHEMA_VERSION);
204        assert!(!MIGRATIONS[0].sql().is_empty());
205        assert_eq!(
206            MIGRATIONS[0].compatibility().minimum(),
207            CrateVersion::new(0, 2, 0)
208        );
209        assert!(MIGRATIONS[0].sql().contains("dovecote_schema"));
210        assert!(!MIGRATIONS[0].rolling_compatible());
211        assert!(
212            MIGRATIONS[0]
213                .compatibility()
214                .contains(CrateVersion::new(0, 9, 0))
215        );
216        assert!(
217            MigrationCompatibility::try_new(
218                CrateVersion::new(1, 0, 0),
219                Some(CrateVersion::new(0, 9, 0))
220            )
221            .is_err()
222        );
223    }
224
225    #[test]
226    fn tenant_baseline_retains_v1_durable_bounds() {
227        let sql = MIGRATIONS[0].sql();
228        for bound in [
229            "stream AS BLOB)) <= 255",
230            "event_id AS BLOB)) <= 1024",
231            "source AS BLOB)) <= 2048",
232            "event_type AS BLOB)) <= 1024",
233            "subject AS BLOB)) <= 2048",
234            "datacontenttype AS BLOB)) <= 255",
235            "dataschema AS BLOB)) <= 2048",
236            "partitionkey AS BLOB)) <= 255",
237            "claimed_by AS BLOB)) <= 255",
238            "last_failure_code AS BLOB)) <= 128",
239            "last_failure_detail AS BLOB)) <= 2048",
240            "quarantine_reason AS BLOB)) <= 2048",
241        ] {
242            assert!(sql.contains(bound), "missing durable bound: {bound}");
243        }
244        assert!(sql.contains("source AS BLOB)) + length(CAST(event_id AS BLOB)) <= 2048"));
245    }
246
247    #[test]
248    fn tenant_activation_is_a_transactional_rebuild() {
249        assert!(V1_TENANT_ACTIVATE_SQL.contains("BEGIN IMMEDIATE"));
250        assert!(V1_TENANT_ACTIVATE_SQL.contains("CREATE TEMP TABLE"));
251        assert!(V1_TENANT_ACTIVATE_SQL.contains("CREATE TABLE dovecote_events_v2"));
252        assert!(V1_TENANT_ACTIVATE_SQL.contains("ALTER TABLE dovecote_events_v2 RENAME"));
253        assert!(V1_TENANT_ACTIVATE_SQL.contains("LEFT JOIN dovecote_events AS e"));
254        assert!(V1_TENANT_ACTIVATE_SQL.contains("e.row_id IS NULL OR d.tenant_id <> e.tenant_id"));
255        assert!(V1_TENANT_ACTIVATE_SQL.contains("COMMIT"));
256        assert!(V1_TENANT_ACTIVATE_SQL.contains("INSERT INTO dovecote_deliveries_v2"));
257    }
258}