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}