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