1pub const SCHEMA_VERSION: u32 = 2;
5
6#[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 #[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 #[must_use]
26 pub const fn major(self) -> u16 {
27 self.major
28 }
29 #[must_use]
31 pub const fn minor(self) -> u16 {
32 self.minor
33 }
34 #[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#[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#[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 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 #[must_use]
84 pub const fn minimum(self) -> CrateVersion {
85 self.minimum
86 }
87 #[must_use]
89 pub const fn maximum(self) -> Option<CrateVersion> {
90 self.maximum
91 }
92 #[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#[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 #[must_use]
127 pub const fn version(self) -> u32 {
128 self.version
129 }
130 #[must_use]
132 pub const fn sql(self) -> &'static str {
133 self.sql
134 }
135 #[must_use]
137 pub const fn compatibility(self) -> MigrationCompatibility {
138 self.compatibility
139 }
140 #[must_use]
142 pub const fn rolling_compatible(self) -> bool {
143 self.rolling_compatible
144 }
145}
146
147pub 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
155pub 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
163pub const V1_TENANT_PREPARE_SQL: &str =
165 include_str!("../migrations/0002_dovecote_tenant_prepare.sql");
166
167pub 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}