Skip to main content

soma_schema/
postgres.rs

1use async_trait::async_trait;
2use sqlx::{postgres::PgConnection, Connection, Executor, PgPool};
3use std::sync::Arc;
4use tokio::sync::Mutex;
5
6use crate::driver::{AppliedMigration, LockGuard, MigrationDriver};
7use crate::error::{Error, Result};
8use crate::migration::Migration;
9
10/// Validate that an identifier contains only [A-Za-z0-9_].
11/// A leading digit is allowed (e.g. `00_schema_migrations`).
12fn validate_ident(s: &str) -> Result<()> {
13    if s.is_empty() || !s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
14        return Err(Error::InvalidIdentifier(s.to_owned()));
15    }
16    Ok(())
17}
18
19/// Configuration for the Postgres driver.
20///
21/// # Construction
22///
23/// `PostgresConfig` is intentionally constructable with struct-literal syntax so callers
24/// can write `PostgresConfig { schema: Some("app".into()), ..Default::default() }`.
25/// It is therefore NOT marked `#[non_exhaustive]` — doing so would break that ergonomic.
26/// New fields added in future versions will always have defaults provided via `Default`.
27#[derive(Debug, Clone)]
28pub struct PostgresConfig {
29    /// Target schema. `None` means use the connection's search_path default.
30    pub schema: Option<String>,
31    /// Tracking table name. Defaults to `00_schema_migrations`.
32    pub table: String,
33    /// Advisory lock key. Defaults to 918273645.
34    pub advisory_lock_key: i64,
35}
36
37impl Default for PostgresConfig {
38    fn default() -> Self {
39        Self {
40            schema: None,
41            table: "00_schema_migrations".to_owned(),
42            advisory_lock_key: 918273645,
43        }
44    }
45}
46
47/// A held advisory lock. Keeps a dedicated connection alive.
48/// Releases the lock on Drop via a background task.
49pub struct PgLockGuard {
50    /// The connection is wrapped in Arc<Mutex<>> so we can move it into the drop task.
51    conn: Arc<Mutex<Option<PgConnection>>>,
52    key: i64,
53}
54
55impl LockGuard for PgLockGuard {}
56
57impl Drop for PgLockGuard {
58    fn drop(&mut self) {
59        let conn_arc = Arc::clone(&self.conn);
60        let key = self.key;
61        // ponytail: async cleanup in Drop is inherently unreliable — if the runtime is
62        // draining at shutdown, spawn() silently drops the future. In practice Postgres
63        // releases session-level advisory locks when the connection closes (either via
64        // conn.close() or TCP teardown), so this is best-effort cleanup: the lock will
65        // be released eventually, but an operator-visible log line on failure is better
66        // than silence.
67        if let Ok(handle) = tokio::runtime::Handle::try_current() {
68            handle.spawn(async move {
69                let mut guard = conn_arc.lock().await;
70                if let Some(mut conn) = guard.take() {
71                    if let Err(e) = sqlx::query("SELECT pg_advisory_unlock($1)")
72                        .bind(key)
73                        .execute(&mut conn)
74                        .await
75                    {
76                        // ponytail: can't propagate errors from Drop; log so operators
77                        // can see it. Postgres releases session locks on TCP close anyway.
78                        eprintln!("WARN soma-schema: pg_advisory_unlock(key={key}) failed: {e}; lock will be released when the connection closes");
79                    }
80                    let _ = conn.close().await;
81                }
82            });
83        }
84    }
85}
86
87/// Postgres implementation of `MigrationDriver`.
88///
89/// The pool must have at least 2 connections: one is reserved for the advisory lock,
90/// and at least one more is needed for migration queries. `PgPoolOptions::new().max_connections(2)`
91/// is the recommended minimum for CLI use.
92#[derive(Debug)]
93pub struct PostgresDriver {
94    pool: PgPool,
95    config: PostgresConfig,
96}
97
98impl PostgresDriver {
99    /// Create a new `PostgresDriver`.
100    ///
101    /// # Errors
102    ///
103    /// Returns `Error::InvalidIdentifier` if the table or schema name contains
104    /// characters outside `[A-Za-z0-9_]`.
105    ///
106    /// Returns `Error::PoolTooSmall` if the pool has fewer than 2 connections.
107    /// One connection is reserved for the advisory lock; all migration queries
108    /// need at least one more. With `max_connections == 1` every `pool.begin()`
109    /// call would wait forever.
110    pub fn new(pool: PgPool, config: PostgresConfig) -> Result<Self> {
111        validate_ident(&config.table)?;
112        if let Some(ref s) = config.schema {
113            validate_ident(s)?;
114        }
115        // One connection is permanently held for the advisory lock.
116        // Every migration query needs a second connection from the pool.
117        if pool.options().get_max_connections() < 2 {
118            return Err(Error::PoolTooSmall);
119        }
120        Ok(Self { pool, config })
121    }
122
123    /// Return `"schema"."table"` or just `"table"` depending on whether schema is set.
124    fn qualified_table(&self) -> String {
125        match self.config.schema.as_deref() {
126            Some(s) => format!("\"{s}\".\"{}\"", &self.config.table),
127            None => format!("\"{}\"", &self.config.table),
128        }
129    }
130
131    fn set_search_path_sql(&self) -> Option<String> {
132        // SET LOCAL is transaction-scoped and cannot escape the transaction boundary,
133        // preventing search_path leaking to other pooled connections after commit.
134        self.config
135            .schema
136            .as_deref()
137            .map(|s| format!("SET LOCAL search_path TO \"{s}\""))
138    }
139}
140
141#[async_trait]
142impl MigrationDriver for PostgresDriver {
143    async fn acquire_lock(&self) -> Result<Box<dyn LockGuard>> {
144        let mut conn = self.pool.acquire().await?.detach();
145        sqlx::query("SELECT pg_advisory_lock($1)")
146            .bind(self.config.advisory_lock_key)
147            .execute(&mut conn)
148            .await?;
149        let guard = PgLockGuard {
150            conn: Arc::new(Mutex::new(Some(conn))),
151            key: self.config.advisory_lock_key,
152        };
153        Ok(Box::new(guard))
154    }
155
156    async fn run_setup_sql(&self, name: &str, sql: &str) -> Result<()> {
157        // Own the strings up front: async_trait boxes the future and requires all
158        // captured values to be 'async_trait; owned Strings satisfy that.
159        let name = name.to_owned();
160        let sql = sql.to_owned();
161        let mut tx = self.pool.begin().await?;
162        if let Some(sp) = self.set_search_path_sql() {
163            sqlx::query(&sp)
164                .execute(&mut *tx)
165                .await
166                .map_err(|e| Error::SetupFailed {
167                    file: name.clone(),
168                    source: e,
169                })?;
170        }
171        // raw_sql sends the whole file as a simple-query batch (simple protocol,
172        // not prepared), so multi-statement DDL and PL/pgSQL $$ blocks work correctly.
173        // We call Executor::execute on the connection directly (not the RawSql inherent
174        // execute), because the inherent method is async fn and captures the executor
175        // reference, which causes an HRTB Send failure inside async_trait boxed futures.
176        // Calling through the Executor trait returns BoxFuture<'e> which does not capture.
177        {
178            let conn: &mut PgConnection = &mut tx;
179            conn.execute(sqlx::raw_sql(&sql))
180                .await
181                .map_err(|e| Error::SetupFailed {
182                    file: name.clone(),
183                    source: e,
184                })?;
185        }
186        tx.commit().await.map_err(|e| Error::SetupFailed {
187            file: name,
188            source: e,
189        })?;
190        Ok(())
191    }
192
193    async fn ensure_tracking_table(&self) -> Result<()> {
194        let mut tx = self.pool.begin().await?;
195        // Create schema if configured.
196        if let Some(schema) = &self.config.schema {
197            let create_schema = format!("CREATE SCHEMA IF NOT EXISTS \"{schema}\"");
198            sqlx::query(&create_schema).execute(&mut *tx).await?;
199        }
200        // Set search_path so the table creation lands in the right schema.
201        if let Some(sp) = self.set_search_path_sql() {
202            sqlx::query(&sp).execute(&mut *tx).await?;
203        }
204        let qt = self.qualified_table();
205        let create_table = format!(
206            r#"CREATE TABLE IF NOT EXISTS {qt} (
207    version        INTEGER      NOT NULL,
208    file           VARCHAR(255) NOT NULL,
209    name           VARCHAR(255) NOT NULL,
210    checksum       TEXT         NOT NULL,
211    description    TEXT,
212    batch          INTEGER      NOT NULL,
213    applied_at     TIMESTAMPTZ  NOT NULL DEFAULT now(),
214    applied_by     TEXT         NOT NULL DEFAULT current_user,
215    execution_ms   INTEGER,
216    PRIMARY KEY (version, file)
217)"#
218        );
219        sqlx::query(&create_table).execute(&mut *tx).await?;
220        tx.commit().await?;
221        Ok(())
222    }
223
224    async fn applied(&self) -> Result<Vec<AppliedMigration>> {
225        use chrono::{DateTime, Utc};
226        let qt = self.qualified_table();
227        let sql = format!(
228            "SELECT version, file, name, checksum, description, batch, applied_at, applied_by, execution_ms \
229             FROM {qt} ORDER BY version ASC, file ASC"
230        );
231        let rows = sqlx::query_as::<
232            _,
233            (
234                i32,
235                String,
236                String,
237                String,
238                Option<String>,
239                i32,
240                DateTime<Utc>,
241                String,
242                Option<i32>,
243            ),
244        >(&sql)
245        .fetch_all(&self.pool)
246        .await?;
247        Ok(rows
248            .into_iter()
249            .map(
250                |(
251                    version,
252                    file,
253                    name,
254                    checksum,
255                    description,
256                    batch,
257                    applied_at,
258                    applied_by,
259                    execution_ms,
260                )| {
261                    AppliedMigration {
262                        version: version as u32,
263                        file,
264                        name,
265                        checksum,
266                        description,
267                        batch,
268                        applied_at,
269                        applied_by,
270                        execution_ms,
271                    }
272                },
273            )
274            .collect())
275    }
276
277    async fn apply(&self, migration: &Migration, up_sql: &str, batch: i32) -> Result<()> {
278        // Own SQL up front so the async_trait boxed future doesn't borrow across the lifetime boundary.
279        let up_sql = up_sql.to_owned();
280        let start = std::time::Instant::now();
281        let mut tx = self.pool.begin().await?;
282        // Set search_path so DDL lands in the right schema.
283        if let Some(sp) = self.set_search_path_sql() {
284            sqlx::query(&sp).execute(&mut *tx).await?;
285        }
286        // raw_sql sends the whole file as a simple-query batch — handles multi-statement
287        // DDL and PL/pgSQL $$ bodies that the old per-statement loop mis-split.
288        // Call through Executor::execute (BoxFuture) not RawSql::execute (async fn) to
289        // avoid the HRTB Send failure that async fn captures cause inside async_trait.
290        {
291            let conn: &mut PgConnection = &mut tx;
292            conn.execute(sqlx::raw_sql(&up_sql)).await?;
293        }
294        let elapsed_ms = start.elapsed().as_millis() as i32;
295        let qt = self.qualified_table();
296        let insert = format!(
297            "INSERT INTO {qt} (version, file, name, checksum, description, batch, applied_at, applied_by, execution_ms) \
298             VALUES ($1, $2, $3, $4, $5, $6, now(), current_user, $7)"
299        );
300        sqlx::query(&insert)
301            .bind(migration.version as i32)
302            .bind(&migration.file)
303            .bind(&migration.name)
304            .bind(&migration.checksum)
305            .bind(migration.why.as_deref())
306            .bind(batch)
307            .bind(elapsed_ms)
308            .execute(&mut *tx)
309            .await?;
310        tx.commit().await?;
311        Ok(())
312    }
313
314    async fn revert(&self, applied: &AppliedMigration, down_sql: &str) -> Result<()> {
315        // Own SQL up front so the async_trait boxed future doesn't borrow across the lifetime boundary.
316        let down_sql = down_sql.to_owned();
317        let mut tx = self.pool.begin().await?;
318        if let Some(sp) = self.set_search_path_sql() {
319            sqlx::query(&sp).execute(&mut *tx).await?;
320        }
321        // raw_sql sends the whole file as a simple-query batch — handles PL/pgSQL $$ bodies correctly.
322        // Call through Executor::execute (BoxFuture) not RawSql::execute (async fn) to
323        // avoid the HRTB Send failure that async fn captures cause inside async_trait.
324        {
325            let conn: &mut PgConnection = &mut tx;
326            conn.execute(sqlx::raw_sql(&down_sql)).await?;
327        }
328        let qt = self.qualified_table();
329        let delete = format!("DELETE FROM {qt} WHERE version = $1 AND file = $2");
330        sqlx::query(&delete)
331            .bind(applied.version as i32)
332            .bind(&applied.file)
333            .execute(&mut *tx)
334            .await?;
335        tx.commit().await?;
336        Ok(())
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    #[test]
345    fn valid_ident() {
346        assert!(validate_ident("00_schema_migrations").is_ok());
347        assert!(validate_ident("my_schema").is_ok());
348        assert!(validate_ident("ABC123").is_ok());
349    }
350
351    #[test]
352    fn invalid_ident_rejects_special_chars() {
353        assert!(matches!(
354            validate_ident("bad-name"),
355            Err(Error::InvalidIdentifier(_))
356        ));
357        assert!(matches!(
358            validate_ident("has space"),
359            Err(Error::InvalidIdentifier(_))
360        ));
361        assert!(matches!(
362            validate_ident(""),
363            Err(Error::InvalidIdentifier(_))
364        ));
365        assert!(matches!(
366            validate_ident("semi;colon"),
367            Err(Error::InvalidIdentifier(_))
368        ));
369    }
370
371    #[test]
372    fn leading_digit_allowed() {
373        // e.g. "00_schema_migrations" — leading digit is valid
374        assert!(validate_ident("00_schema_migrations").is_ok());
375        assert!(validate_ident("01_vault").is_ok());
376    }
377
378    #[test]
379    fn qualified_table_format() {
380        // Verify schema.table ordering without needing a live pool.
381        let with_schema = {
382            let s = "myschema";
383            let t = "00_schema_migrations";
384            format!("\"{s}\".\"{t}\"")
385        };
386        assert_eq!(with_schema, "\"myschema\".\"00_schema_migrations\"");
387
388        let without_schema = {
389            let t = "00_schema_migrations";
390            format!("\"{t}\"")
391        };
392        assert_eq!(without_schema, "\"00_schema_migrations\"");
393    }
394}