Skip to main content

reliar_store_postgres/
migrate.rs

1//! Explicit migration entry point (SRS §35, §35.1, ADR 0018, contract §7 J3/J4).
2//!
3//! **Never invoked implicitly** — no constructor, `Default`, or `acquire` runs a migration.
4//! Reliar's bookkeeping lives in its own schema's `_migrations` table, never the shared,
5//! one-per-database `_sqlx_migrations` sqlx would otherwise write to, so this can be added to a
6//! database a host already migrates with its own tooling without either side noticing the other.
7
8use core::fmt;
9
10use sqlx::migrate::Migrator;
11use sqlx::postgres::PgConnection;
12use sqlx::{Connection, Executor, PgPool};
13
14/// The crate's migrations, embedded at compile time from `migrations/` — the single source of
15/// truth (ADR 0018): `cargo publish` packages only files under the crate's own directory, and
16/// `sqlx::migrate!` resolves relative to `CARGO_MANIFEST_DIR` at compile time, so the SQL must
17/// live here rather than at the repository root.
18static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
19
20/// Where [`migrate`] creates Reliar's schema and its bookkeeping table.
21#[derive(Clone, Copy, Debug)]
22#[non_exhaustive]
23pub struct MigrateOptions<'a> {
24    /// The schema to create (`CREATE SCHEMA IF NOT EXISTS`) and use for both the data tables
25    /// and the `_migrations` bookkeeping table. SHALL agree with
26    /// [`crate::PostgresOutboxSettings::schema`] — [`crate::PostgresOutboxStore::connect`]'s
27    /// startup verification fails otherwise, since `outbox` will not resolve where it expects.
28    pub schema: &'a str,
29}
30
31impl Default for MigrateOptions<'_> {
32    fn default() -> Self {
33        Self { schema: "reliar" }
34    }
35}
36
37impl<'a> MigrateOptions<'a> {
38    /// Sets [`Self::schema`]. `#[non_exhaustive]` forbids struct-literal construction outside
39    /// this crate, so this is the only way to migrate into a non-default schema.
40    #[must_use]
41    pub const fn schema(mut self, schema: &'a str) -> Self {
42        self.schema = schema;
43        self
44    }
45}
46
47/// [`migrate`]'s failure. **Provider-owned**, not a re-export of `sqlx::migrate::MigrateError`
48/// (contract §7 J3/J4): a rejected schema identifier has no variant in `sqlx`'s own type to
49/// report it as, since that check happens before any `sqlx::migrate` code runs at all.
50#[derive(Debug)]
51#[non_exhaustive]
52pub enum MigrateError {
53    /// `options.schema` is not a valid PostgreSQL identifier
54    /// (`[A-Za-z_][A-Za-z0-9_$]*`, at most 63 bytes). Checked **before** the name reaches
55    /// `dangerous_set_table_name`, which is string interpolation into DDL.
56    InvalidSchema {
57        /// The rejected schema name.
58        schema: String,
59    },
60    /// Any failure from `sqlx::migrate::Migrator::run` or the dedicated connection's own setup
61    /// (a connection failure, a checksum mismatch against an already-applied file, …).
62    Sqlx {
63        /// The underlying `sqlx` migration error.
64        source: sqlx::migrate::MigrateError,
65    },
66}
67
68impl fmt::Display for MigrateError {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        match self {
71            Self::InvalidSchema { schema } => write!(
72                f,
73                "{schema:?} is not a valid PostgreSQL identifier (expected \
74                 [A-Za-z_][A-Za-z0-9_$]*, at most 63 bytes)"
75            ),
76            Self::Sqlx { source } => write!(f, "migration failed: {source}"),
77        }
78    }
79}
80
81impl std::error::Error for MigrateError {
82    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
83        match self {
84            Self::Sqlx { source } => Some(source),
85            Self::InvalidSchema { .. } => None,
86        }
87    }
88}
89
90impl From<sqlx::migrate::MigrateError> for MigrateError {
91    fn from(source: sqlx::migrate::MigrateError) -> Self {
92        Self::Sqlx { source }
93    }
94}
95
96impl From<sqlx::Error> for MigrateError {
97    fn from(source: sqlx::Error) -> Self {
98        Self::Sqlx {
99            source: sqlx::migrate::MigrateError::Execute(source),
100        }
101    }
102}
103
104/// Applies Reliar's migrations. **Never invoked implicitly** (SRS §35).
105///
106/// Creates `options.schema` if it does not exist, keeps bookkeeping in
107/// `<schema>._migrations` — never `_sqlx_migrations` — and serializes concurrent callers with
108/// an advisory lock, so every caller after the first observes `Ok(())`. **Idempotent.**
109/// Self-contained: does not depend on the caller's `search_path` (ADR 0018) — `create_schema`
110/// plus the qualified bookkeeping table name make it work over a pool whose URL never set one.
111///
112/// # Errors
113///
114/// Returns [`MigrateError::InvalidSchema`] when `options.schema` is not a valid PostgreSQL
115/// identifier, or [`MigrateError::Sqlx`] for a connection failure, a checksum mismatch against
116/// an already applied file, or any other failure `sqlx::migrate::Migrator::run` reports.
117pub async fn migrate(pool: &PgPool, options: MigrateOptions<'_>) -> Result<(), MigrateError> {
118    // Validated once, before it is ever interpolated into `dangerous_set_table_name`/`SET
119    // search_path` below, both of which build SQL text from this value rather than binding it
120    // as data (contract §7 J4).
121    if !crate::error::is_valid_schema_name(options.schema) {
122        return Err(MigrateError::InvalidSchema {
123            schema: options.schema.to_owned(),
124        });
125    }
126
127    // `Migrator` has no `Clone` impl, but every field is public (`migrate!()` relies on that to
128    // construct the static in a const-promotable context), so a field-by-field copy is the
129    // sanctioned way to get a mutable instance without touching the static (ADR 0018).
130    let mut migrator = Migrator {
131        migrations: MIGRATOR.migrations.clone(),
132        ignore_missing: MIGRATOR.ignore_missing,
133        locking: MIGRATOR.locking,
134        no_tx: MIGRATOR.no_tx,
135        table_name: MIGRATOR.table_name.clone(),
136        create_schemas: MIGRATOR.create_schemas.clone(),
137    };
138    migrator.create_schema(options.schema.to_owned());
139    migrator.dangerous_set_table_name(format!("{}._migrations", options.schema));
140    migrator.set_locking(true);
141
142    // `SET search_path` (unqualified migration SQL needs it, ADR 0018) is session-level and
143    // sqlx never resets it on release, so this is a dedicated connection, never `pool.acquire()`.
144    let connect_options = pool.connect_options();
145    let mut conn = PgConnection::connect_with(&connect_options).await?;
146    conn.execute(sqlx::query(sqlx::AssertSqlSafe(format!(
147        "SET search_path = \"{}\", public",
148        options.schema.replace('"', "\"\"")
149    ))))
150    .await?;
151    migrator.run(&mut conn).await?;
152    conn.close().await?;
153    Ok(())
154}