Skip to main content

dovecote_sqlx_postgres/
migration.rs

1//! Versioned PostgreSQL migration metadata.
2
3use sqlx::FromRow;
4
5/// Schema version adapters compare before using these migration artifacts.
6pub const SCHEMA_VERSION: u32 = 2;
7
8/// Numeric crate version used to evaluate migration compatibility without
9/// parsing free-form requirement strings.
10#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
11pub struct CrateVersion {
12    major: u16,
13    minor: u16,
14    patch: u16,
15}
16
17impl CrateVersion {
18    /// Creates a comparable semantic version from numeric components.
19    pub const fn new(major: u16, minor: u16, patch: u16) -> Self {
20        Self {
21            major,
22            minor,
23            patch,
24        }
25    }
26
27    /// Returns the major component.
28    pub const fn major(self) -> u16 {
29        self.major
30    }
31
32    /// Returns the minor component.
33    pub const fn minor(self) -> u16 {
34        self.minor
35    }
36
37    /// Returns the patch component.
38    pub const fn patch(self) -> u16 {
39        self.patch
40    }
41
42    const fn is_less_than(self, other: Self) -> bool {
43        self.major < other.major
44            || (self.major == other.major
45                && (self.minor < other.minor
46                    || (self.minor == other.minor && self.patch < other.patch)))
47    }
48}
49
50pub(crate) fn current_crate_version() -> CrateVersion {
51    CrateVersion::new(
52        env!("CARGO_PKG_VERSION_MAJOR")
53            .parse()
54            .expect("Cargo supplies a numeric major version"),
55        env!("CARGO_PKG_VERSION_MINOR")
56            .parse()
57            .expect("Cargo supplies a numeric minor version"),
58        env!("CARGO_PKG_VERSION_PATCH")
59            .parse()
60            .expect("Cargo supplies a numeric patch version"),
61    )
62}
63
64#[derive(Debug, FromRow)]
65pub(crate) struct SchemaMarker {
66    pub(crate) schema_version: i32,
67    pub(crate) minimum_crate_major: i16,
68    pub(crate) minimum_crate_minor: i16,
69    pub(crate) minimum_crate_patch: i16,
70    pub(crate) rolling_compatible: bool,
71}
72
73/// Returned when a migration's maximum supported release precedes its minimum.
74#[derive(Clone, Copy, Debug, Eq, PartialEq)]
75pub struct MigrationCompatibilityError;
76
77impl std::fmt::Display for MigrationCompatibilityError {
78    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        formatter.write_str("migration compatibility maximum precedes minimum")
80    }
81}
82
83impl std::error::Error for MigrationCompatibilityError {}
84
85/// A checked crate-version range for a migration artifact.
86///
87/// Use [`Self::contains`] when deciding whether the running adapter may apply it.
88#[derive(Clone, Copy, Debug, Eq, PartialEq)]
89pub struct MigrationCompatibility {
90    minimum: CrateVersion,
91    maximum: Option<CrateVersion>,
92}
93
94impl MigrationCompatibility {
95    const fn new(minimum: CrateVersion, maximum: Option<CrateVersion>) -> Self {
96        Self { minimum, maximum }
97    }
98
99    /// Constructs a compatibility range, rejecting a maximum below its minimum.
100    pub const fn try_new(
101        minimum: CrateVersion,
102        maximum: Option<CrateVersion>,
103    ) -> Result<Self, MigrationCompatibilityError> {
104        if let Some(maximum) = maximum
105            && maximum.is_less_than(minimum)
106        {
107            return Err(MigrationCompatibilityError);
108        }
109        Ok(Self::new(minimum, maximum))
110    }
111
112    /// Returns the minimum supported crate version.
113    pub const fn minimum(self) -> CrateVersion {
114        self.minimum
115    }
116
117    /// Returns the optional maximum supported crate version.
118    pub const fn maximum(self) -> Option<CrateVersion> {
119        self.maximum
120    }
121
122    /// Returns whether `version` falls within this inclusive range.
123    pub const fn contains(self, version: CrateVersion) -> bool {
124        !version.is_less_than(self.minimum)
125            && match self.maximum {
126                Some(maximum) => !maximum.is_less_than(version),
127                None => true,
128            }
129    }
130}
131
132/// An immutable SQL artifact whose compatibility metadata cannot be fabricated
133/// by callers of the adapter crate.
134#[derive(Clone, Copy, Debug, Eq, PartialEq)]
135pub struct Migration {
136    version: u32,
137    sql: &'static str,
138    compatibility: MigrationCompatibility,
139    rolling_compatible: bool,
140}
141
142impl Migration {
143    const fn new(
144        version: u32,
145        sql: &'static str,
146        compatibility: MigrationCompatibility,
147        rolling_compatible: bool,
148    ) -> Self {
149        Self {
150            version,
151            sql,
152            compatibility,
153            rolling_compatible,
154        }
155    }
156
157    /// Returns the schema version represented by this migration.
158    pub const fn version(self) -> u32 {
159        self.version
160    }
161
162    /// Returns the immutable SQL text shipped for this migration.
163    pub const fn sql(self) -> &'static str {
164        self.sql
165    }
166
167    /// Returns the crate-version compatibility range for this migration.
168    pub const fn compatibility(self) -> MigrationCompatibility {
169        self.compatibility
170    }
171
172    /// Returns whether this migration supports rolling deployment compatibility.
173    pub const fn rolling_compatible(self) -> bool {
174        self.rolling_compatible
175    }
176}
177
178/// The clean-install migration sequence shipped with this adapter.
179///
180/// The version 1 artifact remains available as [`LEGACY_MIGRATION`] for the
181/// explicit prepare/backfill/activate upgrade route. It is intentionally not
182/// rewritten or silently upgraded in place.
183pub const MIGRATIONS: &[Migration] = &[Migration::new(
184    2,
185    include_str!("../migrations/0002_dovecote_tenant_baseline.sql"),
186    MigrationCompatibility::new(CrateVersion::new(0, 2, 0), None),
187    false,
188)];
189
190/// The immutable schema version 1 artifact used by pre-tenant deployments.
191pub const LEGACY_MIGRATION: Migration = Migration::new(
192    1,
193    include_str!("../migrations/0001_dovecote.sql"),
194    MigrationCompatibility::new(CrateVersion::new(0, 1, 0), None),
195    false,
196);
197
198/// SQL that adds nullable tenant columns to a version 1 deployment.
199pub const V1_TENANT_PREPARE_SQL: &str =
200    include_str!("../migrations/0002_dovecote_tenant_prepare.sql");
201
202/// SQL that validates an operator-owned backfill and activates version 2.
203pub const V1_TENANT_ACTIVATE_SQL: &str =
204    include_str!("../migrations/0002_dovecote_tenant_activate.sql");
205
206pub(crate) fn current_migration() -> Result<Migration, String> {
207    MIGRATIONS
208        .iter()
209        .find(|migration| migration.version() == SCHEMA_VERSION)
210        .copied()
211        .ok_or_else(|| format!("adapter does not ship schema version {SCHEMA_VERSION}"))
212}
213
214pub(crate) fn marker_compatibility(
215    marker: &SchemaMarker,
216) -> Result<MigrationCompatibility, String> {
217    let minimum = CrateVersion::new(
218        u16::try_from(marker.minimum_crate_major)
219            .map_err(|_| "schema marker minimum major version is negative".to_owned())?,
220        u16::try_from(marker.minimum_crate_minor)
221            .map_err(|_| "schema marker minimum minor version is negative".to_owned())?,
222        u16::try_from(marker.minimum_crate_patch)
223            .map_err(|_| "schema marker minimum patch version is negative".to_owned())?,
224    );
225    MigrationCompatibility::try_new(minimum, None)
226        .map_err(|error| format!("schema marker compatibility is invalid: {error}"))
227}
228
229pub(crate) fn marker_matches_migration(
230    marker: &SchemaMarker,
231    migration: Migration,
232) -> Result<(), String> {
233    let marker_version = u32::try_from(marker.schema_version)
234        .map_err(|_| "schema marker version is negative".to_owned())?;
235    let compatibility = marker_compatibility(marker)?;
236    if marker_version != migration.version() {
237        return Err(format!(
238            "schema marker version {} does not match installed version {}",
239            marker_version,
240            migration.version()
241        ));
242    }
243
244    if compatibility != migration.compatibility() {
245        return Err("schema marker compatibility range is incompatible".to_owned());
246    }
247
248    if marker.rolling_compatible != migration.rolling_compatible() {
249        return Err("schema marker rolling compatibility is incompatible".to_owned());
250    }
251
252    if !migration.compatibility().contains(current_crate_version()) {
253        return Err("current crate is outside the migration compatibility range".to_owned());
254    }
255    Ok(())
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn migrations_are_ordered_and_typed() {
264        assert_eq!(MIGRATIONS[0].version(), SCHEMA_VERSION);
265        assert!(!MIGRATIONS[0].sql().is_empty());
266        assert_eq!(
267            MIGRATIONS[0].compatibility().minimum(),
268            CrateVersion::new(0, 2, 0)
269        );
270        assert!(!MIGRATIONS[0].rolling_compatible());
271        assert_eq!(LEGACY_MIGRATION.version(), 1);
272        assert!(!LEGACY_MIGRATION.sql().is_empty());
273        assert!(
274            MIGRATIONS[0]
275                .compatibility()
276                .contains(CrateVersion::new(0, 9, 0))
277        );
278        assert!(
279            MigrationCompatibility::try_new(
280                CrateVersion::new(1, 0, 0),
281                Some(CrateVersion::new(0, 9, 0))
282            )
283            .is_err()
284        );
285    }
286}