1use sqlx::{Error as SqlxError, PgPool, Row, postgres::PgPoolOptions, query, raw_sql};
2use thiserror::Error;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub struct DatabaseMigration {
7 pub version: &'static str,
9 pub name: &'static str,
11 pub up_sql: &'static str,
13 pub down_sql: &'static str,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum DatabaseMigrationCommand {
20 Up {
22 steps: Option<usize>,
24 },
25 Down {
27 steps: usize,
29 },
30 Status,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct DatabaseMigrationOptions {
37 database_url: String,
38 command: DatabaseMigrationCommand,
39}
40
41impl DatabaseMigrationOptions {
42 #[must_use]
44 pub const fn new(database_url: String, command: DatabaseMigrationCommand) -> Self {
45 Self {
46 database_url,
47 command,
48 }
49 }
50
51 #[must_use]
53 pub fn database_url(&self) -> &str {
54 &self.database_url
55 }
56
57 #[must_use]
59 pub const fn command(&self) -> &DatabaseMigrationCommand {
60 &self.command
61 }
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct DatabaseMigrationStatusEntry {
67 pub version: String,
69 pub name: String,
71 pub applied: bool,
73 pub applied_at_utc: Option<String>,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct DatabaseMigrationReport {
80 pub backend: String,
82 pub command: DatabaseMigrationCommand,
84 pub applied_count: u64,
86 pub reverted_count: u64,
88 pub applied_total_count: u64,
90 pub pending_count: u64,
92 pub migrations: Vec<DatabaseMigrationStatusEntry>,
94}
95
96#[derive(Debug, Error)]
98pub enum DatabaseMigrationError {
99 #[error("database URL must not be empty")]
101 EmptyDatabaseUrl,
102 #[error(transparent)]
104 Sqlx(#[from] SqlxError),
105 #[error("database contains an unknown shardline migration version: {0}")]
107 UnknownAppliedMigration(String),
108 #[error(
110 "bundled migration checksum mismatch for version {version}: expected {expected_checksum}, observed {observed_checksum}"
111 )]
112 ChecksumMismatch {
113 version: String,
115 expected_checksum: String,
117 observed_checksum: String,
119 },
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
123struct AppliedMigration {
124 version: String,
125 checksum: String,
126 applied_at_utc: String,
127}
128
129const MIGRATION_HISTORY_TABLE: &str = "shardline_schema_migrations";
130
131const SHARDLINE_MIGRATIONS: [DatabaseMigration; 7] = [
132 DatabaseMigration {
133 version: "20260417000000",
134 name: "metadata_store",
135 up_sql: include_str!("../migrations/20260417000000_metadata_store.up.sql"),
136 down_sql: include_str!("../migrations/20260417000000_metadata_store.down.sql"),
137 },
138 DatabaseMigration {
139 version: "20260417010000",
140 name: "retention_holds",
141 up_sql: include_str!("../migrations/20260417010000_retention_holds.up.sql"),
142 down_sql: include_str!("../migrations/20260417010000_retention_holds.down.sql"),
143 },
144 DatabaseMigration {
145 version: "20260418000000",
146 name: "dedupe_shards",
147 up_sql: include_str!("../migrations/20260418000000_dedupe_shards.up.sql"),
148 down_sql: include_str!("../migrations/20260418000000_dedupe_shards.down.sql"),
149 },
150 DatabaseMigration {
151 version: "20260418010000",
152 name: "webhook_deliveries",
153 up_sql: include_str!("../migrations/20260418010000_webhook_deliveries.up.sql"),
154 down_sql: include_str!("../migrations/20260418010000_webhook_deliveries.down.sql"),
155 },
156 DatabaseMigration {
157 version: "20260418020000",
158 name: "provider_repository_states",
159 up_sql: include_str!(
160 "../migrations/20260418020000_provider_repository_states.up.sql"
161 ),
162 down_sql: include_str!(
163 "../migrations/20260418020000_provider_repository_states.down.sql"
164 ),
165 },
166 DatabaseMigration {
167 version: "20260418110000",
168 name: "provider_repository_reconciliation",
169 up_sql: include_str!(
170 "../migrations/20260418110000_provider_repository_reconciliation.up.sql"
171 ),
172 down_sql: include_str!(
173 "../migrations/20260418110000_provider_repository_reconciliation.down.sql"
174 ),
175 },
176 DatabaseMigration {
177 version: "20260629000000",
178 name: "hub_api",
179 up_sql: include_str!("../migrations/20260629000000_hub_api.up.sql"),
180 down_sql: include_str!("../migrations/20260629000000_hub_api.down.sql"),
181 },
182];
183
184#[must_use]
186pub const fn bundled_database_migrations() -> &'static [DatabaseMigration] {
187 &SHARDLINE_MIGRATIONS
188}
189
190pub async fn apply_database_migrations(pool: &PgPool) -> Result<(), DatabaseMigrationError> {
197 ensure_migration_history_table(pool).await?;
198 verify_applied_migrations(pool).await?;
199
200 for migration in pending_migrations(pool).await? {
201 apply_one_migration(pool, migration).await?;
202 }
203
204 Ok(())
205}
206
207pub async fn run_database_migration(
214 options: &DatabaseMigrationOptions,
215) -> Result<DatabaseMigrationReport, DatabaseMigrationError> {
216 if options.database_url().trim().is_empty() {
217 return Err(DatabaseMigrationError::EmptyDatabaseUrl);
218 }
219
220 let pool = PgPoolOptions::new()
221 .max_connections(5)
222 .connect(options.database_url())
223 .await?;
224 ensure_migration_history_table(&pool).await?;
225 verify_applied_migrations(&pool).await?;
226
227 let (applied_count, reverted_count) = match options.command() {
228 DatabaseMigrationCommand::Up { steps } => {
229 let pending = pending_migrations(&pool).await?;
230 let mut applied_count = 0_u64;
231 for migration in pending.into_iter().take(steps.unwrap_or(usize::MAX)) {
232 apply_one_migration(&pool, migration).await?;
233 applied_count = applied_count.saturating_add(1);
234 }
235 (applied_count, 0)
236 }
237 DatabaseMigrationCommand::Down { steps } => {
238 let applied = applied_migrations_in_order(&pool).await?;
239 let mut reverted_count = 0_u64;
240 for migration in applied.into_iter().rev().take(*steps) {
241 revert_one_migration(&pool, migration).await?;
242 reverted_count = reverted_count.saturating_add(1);
243 }
244 (0, reverted_count)
245 }
246 DatabaseMigrationCommand::Status => (0, 0),
247 };
248
249 let migrations = migration_status_entries(&pool).await?;
250 let applied_total_count =
251 u64::try_from(migrations.iter().filter(|entry| entry.applied).count()).unwrap_or(u64::MAX);
252 let pending_count =
253 u64::try_from(migrations.iter().filter(|entry| !entry.applied).count()).unwrap_or(u64::MAX);
254
255 Ok(DatabaseMigrationReport {
256 backend: "postgres".to_owned(),
257 command: options.command().clone(),
258 applied_count,
259 reverted_count,
260 applied_total_count,
261 pending_count,
262 migrations,
263 })
264}
265
266async fn ensure_migration_history_table(pool: &PgPool) -> Result<(), SqlxError> {
267 raw_sql(&format!(
268 "CREATE TABLE IF NOT EXISTS {MIGRATION_HISTORY_TABLE} (
269 version TEXT PRIMARY KEY,
270 name TEXT NOT NULL,
271 checksum TEXT NOT NULL,
272 applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
273 )"
274 ))
275 .execute(pool)
276 .await?;
277
278 Ok(())
279}
280
281async fn verify_applied_migrations(pool: &PgPool) -> Result<(), DatabaseMigrationError> {
282 for applied in load_applied_migrations(pool).await? {
283 let Some(migration) = migration_by_version(&applied.version) else {
284 return Err(DatabaseMigrationError::UnknownAppliedMigration(
285 applied.version,
286 ));
287 };
288 let expected_checksum = migration_checksum(migration);
289 if expected_checksum != applied.checksum {
290 return Err(DatabaseMigrationError::ChecksumMismatch {
291 version: migration.version.to_owned(),
292 expected_checksum,
293 observed_checksum: applied.checksum,
294 });
295 }
296 }
297
298 Ok(())
299}
300
301async fn pending_migrations(
302 pool: &PgPool,
303) -> Result<Vec<&'static DatabaseMigration>, DatabaseMigrationError> {
304 let applied = load_applied_migrations(pool).await?;
305 let pending = SHARDLINE_MIGRATIONS
306 .iter()
307 .filter(|migration| {
308 applied
309 .iter()
310 .all(|entry| entry.version != migration.version)
311 })
312 .collect();
313 Ok(pending)
314}
315
316async fn applied_migrations_in_order(
317 pool: &PgPool,
318) -> Result<Vec<&'static DatabaseMigration>, DatabaseMigrationError> {
319 let applied = load_applied_migrations(pool).await?;
320 let mut migrations = Vec::with_capacity(applied.len());
321 for entry in applied {
322 let Some(migration) = migration_by_version(&entry.version) else {
323 return Err(DatabaseMigrationError::UnknownAppliedMigration(
324 entry.version,
325 ));
326 };
327 migrations.push(migration);
328 }
329 migrations.sort_by_key(|migration| migration.version);
330 Ok(migrations)
331}
332
333async fn apply_one_migration(
334 pool: &PgPool,
335 migration: &'static DatabaseMigration,
336) -> Result<(), DatabaseMigrationError> {
337 let mut transaction = pool.begin().await?;
338 raw_sql(migration.up_sql).execute(&mut *transaction).await?;
339 query(&format!(
340 "INSERT INTO {MIGRATION_HISTORY_TABLE} (version, name, checksum)
341 VALUES ($1, $2, $3)
342 ON CONFLICT (version) DO NOTHING"
343 ))
344 .bind(migration.version)
345 .bind(migration.name)
346 .bind(migration_checksum(migration))
347 .execute(&mut *transaction)
348 .await?;
349 transaction.commit().await?;
350 Ok(())
351}
352
353async fn revert_one_migration(
354 pool: &PgPool,
355 migration: &'static DatabaseMigration,
356) -> Result<(), DatabaseMigrationError> {
357 let mut transaction = pool.begin().await?;
358 raw_sql(migration.down_sql)
359 .execute(&mut *transaction)
360 .await?;
361 query(&format!(
362 "DELETE FROM {MIGRATION_HISTORY_TABLE} WHERE version = $1"
363 ))
364 .bind(migration.version)
365 .execute(&mut *transaction)
366 .await?;
367 transaction.commit().await?;
368 Ok(())
369}
370
371async fn load_applied_migrations(pool: &PgPool) -> Result<Vec<AppliedMigration>, SqlxError> {
372 let rows = query(&format!(
373 "SELECT version, checksum,
374 to_char(applied_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"')
375 AS applied_at_utc
376 FROM {MIGRATION_HISTORY_TABLE}
377 ORDER BY version"
378 ))
379 .fetch_all(pool)
380 .await?;
381
382 let mut migrations = Vec::with_capacity(rows.len());
383 for row in rows {
384 migrations.push(AppliedMigration {
385 version: row.try_get::<String, _>("version")?,
386 checksum: row.try_get::<String, _>("checksum")?,
387 applied_at_utc: row.try_get::<String, _>("applied_at_utc")?,
388 });
389 }
390
391 Ok(migrations)
392}
393
394async fn migration_status_entries(
395 pool: &PgPool,
396) -> Result<Vec<DatabaseMigrationStatusEntry>, DatabaseMigrationError> {
397 let applied = load_applied_migrations(pool).await?;
398 let mut statuses = Vec::with_capacity(SHARDLINE_MIGRATIONS.len());
399 for migration in SHARDLINE_MIGRATIONS {
400 let applied_entry = applied
401 .iter()
402 .find(|entry| entry.version == migration.version);
403 statuses.push(DatabaseMigrationStatusEntry {
404 version: migration.version.to_owned(),
405 name: migration.name.to_owned(),
406 applied: applied_entry.is_some(),
407 applied_at_utc: applied_entry.map(|entry| entry.applied_at_utc.clone()),
408 });
409 }
410
411 Ok(statuses)
412}
413
414fn migration_by_version(version: &str) -> Option<&'static DatabaseMigration> {
415 SHARDLINE_MIGRATIONS
416 .iter()
417 .find(|migration| migration.version == version)
418}
419
420fn migration_checksum(migration: &DatabaseMigration) -> String {
421 blake3::hash(migration.up_sql.as_bytes())
422 .to_hex()
423 .to_string()
424}
425
426#[cfg(test)]
427mod tests {
428 use std::{env::var as env_var, error::Error, process::id as process_id};
429
430 use sqlx::{PgPool, postgres::PgPoolOptions, query};
431 use url::Url;
432
433 use super::{
434 DatabaseMigrationCommand, DatabaseMigrationOptions, apply_database_migrations,
435 bundled_database_migrations, run_database_migration,
436 };
437
438 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
439 async fn database_migration_up_status_and_down_cover_full_lifecycle() {
440 let result = exercise_database_migration_up_status_and_down_cover_full_lifecycle().await;
441 let error = result.as_ref().err().map(ToString::to_string);
442 assert!(
443 result.is_ok(),
444 "database migration lifecycle failed: {error:?}"
445 );
446 }
447
448 #[test]
449 fn bundled_database_migrations_are_monotonic() {
450 let migrations = bundled_database_migrations();
451 assert!(!migrations.is_empty());
452 assert!(migrations.windows(2).all(|window| {
453 let Some(first) = window.first() else {
454 return false;
455 };
456 let Some(second) = window.get(1) else {
457 return false;
458 };
459 first.version < second.version
460 }));
461 }
462
463 async fn exercise_database_migration_up_status_and_down_cover_full_lifecycle()
464 -> Result<(), Box<dyn Error>> {
465 let Some(base_url) = env_var("DATABASE_URL").ok() else {
466 return Ok(());
467 };
468
469 let database_name = format!("shardline_db_migrate_{}", process_id());
470 let admin_url = database_url_for(&base_url, "postgres")?;
471 let admin_pool = PgPoolOptions::new()
472 .max_connections(1)
473 .connect(&admin_url)
474 .await?;
475 recreate_database(&admin_pool, &database_name).await?;
476
477 let database_url = database_url_for(&base_url, &database_name)?;
478 let status_before = run_database_migration(&DatabaseMigrationOptions::new(
479 database_url.clone(),
480 DatabaseMigrationCommand::Status,
481 ))
482 .await?;
483 assert_eq!(status_before.applied_count, 0);
484 assert_eq!(status_before.reverted_count, 0);
485 assert_eq!(status_before.applied_total_count, 0);
486 assert_eq!(
487 status_before.pending_count,
488 u64::try_from(bundled_database_migrations().len())?
489 );
490
491 let up = run_database_migration(&DatabaseMigrationOptions::new(
492 database_url.clone(),
493 DatabaseMigrationCommand::Up { steps: Some(2) },
494 ))
495 .await?;
496 assert_eq!(up.applied_count, 2);
497 assert_eq!(up.reverted_count, 0);
498 assert_eq!(up.applied_total_count, 2);
499
500 let up_rest = run_database_migration(&DatabaseMigrationOptions::new(
501 database_url.clone(),
502 DatabaseMigrationCommand::Up { steps: None },
503 ))
504 .await?;
505 assert_eq!(
506 up_rest.applied_count,
507 u64::try_from(bundled_database_migrations().len().saturating_sub(2))?
508 );
509 assert_eq!(up_rest.pending_count, 0);
510
511 let down = run_database_migration(&DatabaseMigrationOptions::new(
512 database_url.clone(),
513 DatabaseMigrationCommand::Down { steps: 1 },
514 ))
515 .await?;
516 assert_eq!(down.reverted_count, 1);
517 assert_eq!(down.pending_count, 1);
518
519 let pool = PgPoolOptions::new()
520 .max_connections(5)
521 .connect(&database_url)
522 .await?;
523 apply_database_migrations(&pool).await?;
524
525 let final_status = run_database_migration(&DatabaseMigrationOptions::new(
526 database_url,
527 DatabaseMigrationCommand::Status,
528 ))
529 .await?;
530 assert_eq!(final_status.pending_count, 0);
531 assert_eq!(
532 final_status.applied_total_count,
533 u64::try_from(bundled_database_migrations().len())?
534 );
535
536 Ok(())
537 }
538
539 async fn recreate_database(pool: &PgPool, database_name: &str) -> Result<(), Box<dyn Error>> {
540 query(&format!("DROP DATABASE IF EXISTS {database_name}"))
541 .execute(pool)
542 .await?;
543 query(&format!("CREATE DATABASE {database_name}"))
544 .execute(pool)
545 .await?;
546 Ok(())
547 }
548
549 fn database_url_for(base_url: &str, database_name: &str) -> Result<String, Box<dyn Error>> {
550 let mut url = Url::parse(base_url)?;
551 url.set_path(database_name);
552 Ok(url.to_string())
553 }
554}