1#![forbid(unsafe_code)]
3
4use minco_db::{
5 AppliedMigration, DatabaseBackend, MigrationSet, SeedPlan, SeedTransaction, SeedVerification,
6 TargetState, resolve_seed_source, validate_seed_plan as validate_seed_model_plan,
7};
8use serde::{Deserialize, Serialize};
9pub use sqlx::PgPool;
10use sqlx::postgres::PgPoolOptions;
11use std::{
12 path::{Path, PathBuf},
13 time::Duration,
14};
15use thiserror::Error;
16
17pub mod plugin_adapters;
18
19const MINCO_PLAN_LOCK_ID: i64 = 0x4d49_4e43_4f5f_504c;
20
21#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct PostgresPoolConfig {
23 pub url: String,
24 pub max_connections: u32,
25 pub acquire_timeout_seconds: u64,
26 pub idle_timeout_seconds: u64,
27}
28
29impl std::fmt::Debug for PostgresPoolConfig {
30 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31 formatter
32 .debug_struct("PostgresPoolConfig")
33 .field("url", &"[REDACTED DATABASE URL]")
34 .field("max_connections", &self.max_connections)
35 .field("acquire_timeout_seconds", &self.acquire_timeout_seconds)
36 .field("idle_timeout_seconds", &self.idle_timeout_seconds)
37 .finish()
38 }
39}
40
41impl PostgresPoolConfig {
42 pub fn serverless(url: impl Into<String>) -> Self {
43 Self {
44 url: url.into(),
45 max_connections: 2,
46 acquire_timeout_seconds: 5,
47 idle_timeout_seconds: 60,
48 }
49 }
50 pub fn validate(&self) -> Result<(), PostgresError> {
51 if self.url.trim().is_empty() {
52 return Err(PostgresError::InvalidConfig("database URL is empty".into()));
53 }
54 if self.max_connections == 0 {
55 return Err(PostgresError::InvalidConfig(
56 "max_connections must be at least 1".into(),
57 ));
58 }
59 Ok(())
60 }
61}
62
63pub async fn connect(config: &PostgresPoolConfig) -> Result<PgPool, PostgresError> {
64 config.validate()?;
65 Ok(PgPoolOptions::new()
66 .min_connections(0)
67 .max_connections(config.max_connections)
68 .acquire_timeout(Duration::from_secs(config.acquire_timeout_seconds))
69 .idle_timeout(Some(Duration::from_secs(config.idle_timeout_seconds)))
70 .connect(&config.url)
71 .await?)
72}
73
74pub fn connect_lazy(config: &PostgresPoolConfig) -> Result<PgPool, PostgresError> {
75 config.validate()?;
76 Ok(PgPoolOptions::new()
77 .min_connections(0)
78 .max_connections(config.max_connections)
79 .acquire_timeout(Duration::from_secs(config.acquire_timeout_seconds))
80 .idle_timeout(Some(Duration::from_secs(config.idle_timeout_seconds)))
81 .connect_lazy(&config.url)?)
82}
83
84pub async fn migrate(pool: &PgPool, path: impl AsRef<Path>) -> Result<(), PostgresError> {
85 let migrator = sqlx::migrate::Migrator::new(path.as_ref()).await?;
86 migrator.run(pool).await?;
87 Ok(())
88}
89
90pub async fn migrate_with_history_table(
91 pool: &PgPool,
92 path: impl AsRef<Path>,
93 history_table: &'static str,
94) -> Result<(), PostgresError> {
95 validate_identifier(history_table, "migration history table")?;
96 let mut migrator = sqlx::migrate::Migrator::new(path.as_ref()).await?;
97 migrator.dangerous_set_table_name(history_table);
98 migrator.run(pool).await?;
99 Ok(())
100}
101
102pub async fn migration_target_state(
103 pool: &PgPool,
104 set: &MigrationSet,
105) -> Result<TargetState, PostgresError> {
106 validate_set(set)?;
107 if !table_exists(pool, &set.history_table).await? {
108 return Ok(TargetState::default());
109 }
110 let dirty_query = format!(
111 "SELECT version FROM {} WHERE success = false ORDER BY version LIMIT 1",
112 set.history_table
113 );
114 let dirty_version = sqlx::query_scalar::<_, i64>(sqlx::AssertSqlSafe(dirty_query))
116 .fetch_optional(pool)
117 .await?;
118 let applied_query = format!(
119 "SELECT version, checksum FROM {} WHERE success = true ORDER BY version",
120 set.history_table
121 );
122 let applied = sqlx::query_as::<_, (i64, Vec<u8>)>(sqlx::AssertSqlSafe(applied_query))
124 .fetch_all(pool)
125 .await?
126 .into_iter()
127 .map(|(version, checksum)| AppliedMigration {
128 version,
129 sqlx_checksum_sha384: hex(&checksum),
130 })
131 .collect();
132 Ok(TargetState {
133 dirty_version,
134 applied,
135 })
136}
137
138pub async fn verify_migration_tables(
139 pool: &PgPool,
140 set: &MigrationSet,
141) -> Result<Vec<String>, PostgresError> {
142 validate_set(set)?;
143 let mut missing = Vec::new();
144 for table in &set.verify_tables {
145 if !table_exists(pool, table).await? {
146 missing.push(table.clone());
147 }
148 }
149 Ok(missing)
150}
151
152pub async fn apply_migration_set(
153 pool: &PgPool,
154 project_root: &Path,
155 set: &MigrationSet,
156) -> Result<(), PostgresError> {
157 apply_migration_plan(pool, project_root, std::slice::from_ref(set)).await
158}
159
160pub async fn apply_migration_plan(
161 pool: &PgPool,
162 project_root: &Path,
163 sets: &[MigrationSet],
164) -> Result<(), PostgresError> {
165 if sets.is_empty() {
166 return Err(PostgresError::InvalidConfig(
167 "migration plan contains no sets".into(),
168 ));
169 }
170 let mut migrators = Vec::with_capacity(sets.len());
171 for set in sets {
172 validate_set(set)?;
173 let root = migration_root(project_root, set)?;
174 let mut migrator = sqlx::migrate::Migrator::new(root).await?;
175 verify_resolved_migrations(&migrator, set)?;
176 migrator.dangerous_set_table_name(set.history_table.clone());
177 migrators.push(migrator);
178 }
179
180 let mut connection = pool.acquire().await?;
181 connection.close_on_drop();
185 sqlx::query("SELECT pg_advisory_lock($1)")
186 .bind(MINCO_PLAN_LOCK_ID)
187 .execute(&mut *connection)
188 .await?;
189 for migrator in migrators {
190 migrator.run_direct(None, &mut *connection, false).await?;
191 }
192 let unlocked = sqlx::query_scalar::<_, bool>("SELECT pg_advisory_unlock($1)")
193 .bind(MINCO_PLAN_LOCK_ID)
194 .fetch_one(&mut *connection)
195 .await?;
196 if !unlocked {
197 return Err(PostgresError::PlanLockLost);
198 }
199 Ok(())
200}
201
202pub async fn apply_seed_plan(
203 pool: &PgPool,
204 project_root: &Path,
205 plan: &SeedPlan,
206) -> Result<(), PostgresError> {
207 validate_seed_plan(plan)?;
208 let sources = plan
209 .seeds
210 .iter()
211 .map(|seed| resolve_seed_source(project_root, seed))
212 .collect::<Result<Vec<_>, _>>()
213 .map_err(|error| PostgresError::SeedSource(error.to_string()))?;
214 match plan.seeds[0].transaction {
215 SeedTransaction::Required => {
216 let mut transaction = pool.begin().await?;
217 for source in sources {
218 sqlx::raw_sql(sqlx::AssertSqlSafe(source.apply_sql))
219 .execute(&mut *transaction)
220 .await?;
221 }
222 transaction.commit().await?;
223 }
224 SeedTransaction::Autocommit => {
225 for source in sources {
226 sqlx::raw_sql(sqlx::AssertSqlSafe(source.apply_sql))
227 .execute(pool)
228 .await?;
229 }
230 }
231 }
232 Ok(())
233}
234
235pub async fn verify_seed_plan(
236 pool: &PgPool,
237 project_root: &Path,
238 plan: &SeedPlan,
239) -> Result<Vec<SeedVerification>, PostgresError> {
240 validate_seed_plan(plan)?;
241 let mut transaction = pool.begin().await?;
242 sqlx::query("SET TRANSACTION READ ONLY")
243 .execute(&mut *transaction)
244 .await?;
245 let mut verification = Vec::with_capacity(plan.seeds.len());
246 for seed in &plan.seeds {
247 let source = resolve_seed_source(project_root, seed)
248 .map_err(|error| PostgresError::SeedSource(error.to_string()))?;
249 let rows = sqlx::query_scalar::<_, bool>(sqlx::AssertSqlSafe(source.verify_sql))
250 .fetch_all(&mut *transaction)
251 .await?;
252 if rows.len() != 1 {
253 return Err(PostgresError::InvalidConfig(format!(
254 "seed {} verification must return exactly one boolean row",
255 seed.id
256 )));
257 }
258 verification.push(SeedVerification {
259 seed_id: seed.id.clone(),
260 verified: rows[0],
261 });
262 }
263 transaction.rollback().await?;
264 Ok(verification)
265}
266
267pub async fn ready(pool: &PgPool) -> bool {
268 matches!(
269 sqlx::query_scalar::<_, i32>("SELECT 1")
270 .fetch_one(pool)
271 .await,
272 Ok(1)
273 )
274}
275
276fn validate_seed_plan(plan: &SeedPlan) -> Result<(), PostgresError> {
277 validate_seed_model_plan(plan).map_err(|error| PostgresError::SeedSource(error.to_string()))?;
278 if plan.seeds.is_empty() {
279 return Err(PostgresError::InvalidConfig(
280 "seed plan contains no seeds".into(),
281 ));
282 }
283 if plan
284 .seeds
285 .iter()
286 .any(|seed| seed.backend != DatabaseBackend::Postgres)
287 {
288 return Err(PostgresError::InvalidConfig(
289 "seed plan contains a non-PostgreSQL seed".into(),
290 ));
291 }
292 if plan
293 .seeds
294 .iter()
295 .any(|seed| seed.transaction != plan.seeds[0].transaction)
296 {
297 return Err(PostgresError::InvalidConfig(
298 "seed plan mixes transaction behaviors".into(),
299 ));
300 }
301 Ok(())
302}
303
304async fn table_exists(pool: &PgPool, table: &str) -> Result<bool, PostgresError> {
305 validate_identifier(table, "table")?;
306 Ok(sqlx::query_scalar::<_, bool>(
307 "SELECT EXISTS (
308 SELECT 1
309 FROM pg_catalog.pg_class
310 WHERE oid = to_regclass($1)
311 AND relkind IN ('r', 'p')
312 )",
313 )
314 .bind(table)
315 .fetch_one(pool)
316 .await?)
317}
318
319fn validate_set(set: &MigrationSet) -> Result<(), PostgresError> {
320 if set.backend != DatabaseBackend::Postgres {
321 return Err(PostgresError::InvalidConfig(format!(
322 "migration set {} targets a different database backend",
323 set.id
324 )));
325 }
326 validate_identifier(&set.history_table, "migration history table")?;
327 for table in &set.verify_tables {
328 validate_identifier(table, "verification table")?;
329 }
330 Ok(())
331}
332
333fn migration_root(project_root: &Path, set: &MigrationSet) -> Result<PathBuf, PostgresError> {
334 let project_root = project_root.canonicalize().map_err(PostgresError::Io)?;
335 if set.root.is_absolute() {
336 return Err(PostgresError::InvalidConfig(format!(
337 "migration set {} has an absolute source root",
338 set.id
339 )));
340 }
341 let root = project_root
342 .join(&set.root)
343 .canonicalize()
344 .map_err(PostgresError::Io)?;
345 if !root.starts_with(&project_root) {
346 return Err(PostgresError::InvalidConfig(format!(
347 "migration set {} source root escapes the project",
348 set.id
349 )));
350 }
351 Ok(root)
352}
353
354fn verify_resolved_migrations(
355 migrator: &sqlx::migrate::Migrator,
356 set: &MigrationSet,
357) -> Result<(), PostgresError> {
358 let resolved = migrator.iter().collect::<Vec<_>>();
359 if resolved.len() != set.migrations.len() {
360 return Err(PostgresError::SourceDrift(set.id.clone()));
361 }
362 for (resolved, expected) in resolved.iter().zip(&set.migrations) {
363 if resolved.version != expected.version
364 || hex(resolved.checksum.as_ref()) != expected.sqlx_checksum_sha384
365 {
366 return Err(PostgresError::SourceDrift(set.id.clone()));
367 }
368 }
369 Ok(())
370}
371
372fn hex(bytes: &[u8]) -> String {
373 const DIGITS: &[u8; 16] = b"0123456789abcdef";
374 let mut output = String::with_capacity(bytes.len() * 2);
375 for byte in bytes {
376 output.push(char::from(DIGITS[usize::from(byte >> 4)]));
377 output.push(char::from(DIGITS[usize::from(byte & 0x0f)]));
378 }
379 output
380}
381
382fn validate_identifier(value: &str, description: &str) -> Result<(), PostgresError> {
383 let mut bytes = value.bytes();
384 let valid_start = bytes
385 .next()
386 .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_');
387 if !valid_start
388 || value.len() > 63
389 || !bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
390 {
391 return Err(PostgresError::InvalidConfig(format!(
392 "{description} must be a PostgreSQL identifier of at most 63 ASCII characters"
393 )));
394 }
395 Ok(())
396}
397
398#[derive(Debug, Error)]
399pub enum PostgresError {
400 #[error("invalid PostgreSQL configuration: {0}")]
401 InvalidConfig(String),
402 #[error("PostgreSQL error: {0}")]
403 Sqlx(#[from] sqlx::Error),
404 #[error("PostgreSQL migration error: {0}")]
405 Migration(#[from] sqlx::migrate::MigrateError),
406 #[error("PostgreSQL migration source changed after planning for set {0}")]
407 SourceDrift(String),
408 #[error("PostgreSQL migration plan advisory lock was not held at unlock")]
409 PlanLockLost,
410 #[error("PostgreSQL migration filesystem operation failed: {0}")]
411 Io(#[from] std::io::Error),
412 #[error("PostgreSQL seed source validation failed: {0}")]
413 SeedSource(String),
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 use minco_db::{MigrationState, compare_target, load_catalog};
420 use std::fs;
421 use tempfile::TempDir;
422 use uuid::Uuid;
423
424 #[test]
425 fn serverless_defaults_bound_connection_pressure() {
426 let config = PostgresPoolConfig::serverless("postgres://example.invalid/db");
427 assert_eq!(config.max_connections, 2);
428 assert_eq!(config.acquire_timeout_seconds, 5);
429 }
430
431 #[test]
432 fn pool_configuration_debug_redacts_database_credentials() {
433 let config =
434 PostgresPoolConfig::serverless("postgres://minco:secret-password@example.invalid/db");
435 let debug = format!("{config:?}");
436 assert!(!debug.contains("secret-password"));
437 assert!(!debug.contains("postgres://"));
438 }
439
440 #[tokio::test]
441 async fn migration_history_table_rejects_dynamic_sql_tokens() {
442 let config = PostgresPoolConfig::serverless("postgres://example.invalid/db");
443 let pool = connect_lazy(&config).expect("lazy pool");
444 let result =
445 migrate_with_history_table(&pool, Path::new("missing"), "_migrations;DROP").await;
446 assert!(matches!(result, Err(PostgresError::InvalidConfig(_))));
447 }
448
449 #[tokio::test]
450 async fn lifecycle_rejects_a_set_for_another_backend_before_connecting() {
451 let config = PostgresPoolConfig::serverless("postgres://example.invalid/db");
452 let pool = connect_lazy(&config).expect("lazy pool");
453 let set = MigrationSet {
454 id: "wrong-backend".into(),
455 owner: "application:test".into(),
456 backend: DatabaseBackend::Sqlite,
457 root: "migrations".into(),
458 history_table: "_minco_test_migrations".into(),
459 depends_on: Vec::new(),
460 verify_tables: vec!["example".into()],
461 digest: "digest".into(),
462 migrations: Vec::new(),
463 };
464
465 let error = migration_target_state(&pool, &set)
466 .await
467 .expect_err("backend mismatch must fail before database access");
468 assert!(matches!(error, PostgresError::InvalidConfig(_)));
469 }
470
471 #[tokio::test]
472 async fn lifecycle_migration_is_behavioral_when_postgres_is_configured() {
473 let Ok(url) = std::env::var("MINCO_TEST_POSTGRES_URL") else {
474 eprintln!("MINCO_TEST_POSTGRES_URL not set; PostgreSQL lifecycle proof skipped");
475 return;
476 };
477 let suffix = Uuid::new_v4().simple().to_string();
478 let table = format!("minco_lifecycle_{suffix}");
479 let history = format!("_minco_lifecycle_{suffix}");
480 let project = TempDir::new().expect("temporary migration project");
481 let migrations = project.path().join("migrations");
482 fs::create_dir(&migrations).expect("create migration directory");
483 fs::write(
484 migrations.join("0001_example.sql"),
485 format!("CREATE TABLE {table} (id BIGINT PRIMARY KEY);\n"),
486 )
487 .expect("write migration");
488 fs::write(
489 migrations.join(minco_db::MIGRATION_SET_MANIFEST),
490 format!(
491 concat!(
492 "schema = 1\n",
493 "id = \"test-postgres\"\n",
494 "owner = \"application:test\"\n",
495 "backend = \"postgres\"\n",
496 "history_table = \"{}\"\n",
497 "verify_tables = [\"{}\"]\n",
498 "\n",
499 "[[migration]]\n",
500 "version = 1\n",
501 "risk = \"additive\"\n",
502 "reversible = false\n",
503 ),
504 history, table
505 ),
506 )
507 .expect("write lifecycle manifest");
508 let set = load_catalog(project.path(), &[Path::new("migrations").to_path_buf()])
509 .expect("load lifecycle catalog")
510 .sets
511 .into_iter()
512 .next()
513 .expect("migration set");
514 let pool = connect(&PostgresPoolConfig::serverless(url))
515 .await
516 .expect("connect PostgreSQL");
517
518 let before = migration_target_state(&pool, &set)
519 .await
520 .expect("read empty target state");
521 assert!(before.applied.is_empty());
522 let (first, second) = tokio::join!(
523 apply_migration_set(&pool, project.path(), &set),
524 apply_migration_set(&pool, project.path(), &set)
525 );
526 first.expect("apply first concurrent migration plan");
527 second.expect("apply second concurrent migration plan");
528 let after = migration_target_state(&pool, &set)
529 .await
530 .expect("read applied target state");
531 assert_eq!(
532 compare_target(&set, &after).entries[0].state,
533 MigrationState::Applied
534 );
535 assert!(
536 verify_migration_tables(&pool, &set)
537 .await
538 .expect("verify tables")
539 .is_empty()
540 );
541
542 sqlx::query(sqlx::AssertSqlSafe(format!("DROP TABLE IF EXISTS {table}")))
543 .execute(&pool)
544 .await
545 .expect("clean up verified table");
546 sqlx::query(sqlx::AssertSqlSafe(format!(
547 "CREATE VIEW {table} AS SELECT 1::BIGINT AS id"
548 )))
549 .execute(&pool)
550 .await
551 .expect("replace verified table with a view");
552 let missing_tables = verify_migration_tables(&pool, &set)
553 .await
554 .expect("reject view as a verification table");
555 assert_eq!(missing_tables.as_slice(), std::slice::from_ref(&table));
556 sqlx::query(sqlx::AssertSqlSafe(format!("DROP VIEW {table}")))
557 .execute(&pool)
558 .await
559 .expect("clean up verified view");
560 sqlx::query(sqlx::AssertSqlSafe(format!(
561 "DROP TABLE IF EXISTS {history}"
562 )))
563 .execute(&pool)
564 .await
565 .expect("clean up migration history");
566 }
567}