Skip to main content

lenso_postgres_kit/
lifecycle.rs

1use std::str::FromStr;
2
3use sqlx::{
4    AssertSqlSafe, Connection, PgConnection, PgPool, Postgres, Row, Transaction,
5    postgres::{PgConnectOptions, PgPoolOptions},
6};
7
8use crate::{Migration, PostgresKitError, SchemaPlan, SetupOutcome, UpgradeOutcome};
9
10const LEDGER_TABLE: &str = "_lenso_schema_migrations";
11
12/// A verified, schema-scoped `PostgreSQL` pool for one Module.
13#[derive(Clone, Debug)]
14pub struct OwnedPostgres {
15    plan: SchemaPlan,
16    pool: PgPool,
17}
18
19impl OwnedPostgres {
20    /// Connects and verifies the exact owned schema before exposing a pool.
21    ///
22    /// This is the runtime preparation path. It never creates a schema or
23    /// applies a migration.
24    pub async fn prepare(database_url: &str, plan: SchemaPlan) -> Result<Self, PostgresKitError> {
25        Self::prepare_with_pool_options(database_url, plan, PgPoolOptions::new()).await
26    }
27
28    /// Connects with caller-selected pool sizing and verifies the exact schema.
29    pub async fn prepare_with_pool_options(
30        database_url: &str,
31        plan: SchemaPlan,
32        pool_options: PgPoolOptions,
33    ) -> Result<Self, PostgresKitError> {
34        let search_path = format!("{},pg_catalog", plan.schema());
35        let connect_options = PgConnectOptions::from_str(database_url)
36            .map_err(|error| PostgresKitError::database("parse connection options", error))?
37            .options([
38                ("search_path", search_path.as_str()),
39                ("application_name", "lenso-postgres-kit"),
40            ]);
41        let pool = pool_options
42            .connect_with(connect_options)
43            .await
44            .map_err(|error| PostgresKitError::database("connect runtime pool", error))?;
45
46        if let Err(error) = verify_pool(&pool, &plan).await {
47            pool.close().await;
48            return Err(error);
49        }
50        Ok(Self { plan, pool })
51    }
52
53    /// Returns the verified Module-owned schema name.
54    pub fn schema(&self) -> &str {
55        self.plan.schema()
56    }
57
58    /// Returns the verified schema version.
59    pub fn schema_version(&self) -> u64 {
60        self.plan.current_version()
61    }
62
63    /// Returns the `SQLx` pool scoped to the owned schema through `search_path`.
64    ///
65    /// Production deployments should also use one non-superuser `PostgreSQL`
66    /// role per Module; `PostgreSQL` grants, not `search_path`, are the security
67    /// boundary against access to another Module's schema.
68    pub const fn pool(&self) -> &PgPool {
69        &self.pool
70    }
71}
72
73/// Explicit operator-only setup and upgrade workflows for one owned schema.
74#[derive(Debug)]
75pub struct SchemaOperator {
76    plan: SchemaPlan,
77    pool: PgPool,
78}
79
80impl SchemaOperator {
81    /// Connects an operator without mutating the database.
82    pub async fn connect(database_url: &str, plan: SchemaPlan) -> Result<Self, PostgresKitError> {
83        let connect_options = PgConnectOptions::from_str(database_url)
84            .map_err(|error| PostgresKitError::database("parse connection options", error))?
85            .options([("application_name", "lenso-postgres-kit-operator")]);
86        let pool = PgPoolOptions::new()
87            .max_connections(1)
88            .connect_with(connect_options)
89            .await
90            .map_err(|error| PostgresKitError::database("connect operator pool", error))?;
91        Ok(Self { plan, pool })
92    }
93
94    /// Creates a missing schema and applies the complete authored plan atomically.
95    ///
96    /// Setup is idempotent for an already-current managed schema, but will not
97    /// adopt an unmanaged schema or perform an upgrade.
98    pub async fn setup(&self) -> Result<SetupOutcome, PostgresKitError> {
99        let mut connection = self
100            .pool
101            .acquire()
102            .await
103            .map_err(|error| PostgresKitError::database("acquire setup connection", error))?;
104        let mut transaction = connection
105            .begin()
106            .await
107            .map_err(|error| PostgresKitError::database("begin setup transaction", error))?;
108        acquire_schema_lock(&mut transaction, self.plan.schema()).await?;
109
110        match inspect_schema(&mut transaction, &self.plan).await? {
111            SchemaState::Missing => {
112                create_managed_schema(&mut transaction, &self.plan).await?;
113                apply_migrations(&mut transaction, &self.plan, 0).await?;
114                transaction
115                    .commit()
116                    .await
117                    .map_err(|error| PostgresKitError::database("commit schema setup", error))?;
118                Ok(SetupOutcome::Created {
119                    version: self.plan.current_version(),
120                    applied: self.plan.migrations().len(),
121                })
122            }
123            SchemaState::Unmanaged => Err(PostgresKitError::UnmanagedSchema {
124                schema: self.plan.schema().to_owned(),
125            }),
126            SchemaState::Managed { applied } => {
127                let current = validate_history(&self.plan, &applied)?;
128                if current == self.plan.current_version() {
129                    Ok(SetupOutcome::AlreadyCurrent { version: current })
130                } else {
131                    Err(PostgresKitError::UpgradeRequired {
132                        schema: self.plan.schema().to_owned(),
133                        current,
134                        expected: self.plan.current_version(),
135                    })
136                }
137            }
138        }
139    }
140
141    /// Applies only pending migrations to an existing managed schema atomically.
142    pub async fn upgrade(&self) -> Result<UpgradeOutcome, PostgresKitError> {
143        let mut connection = self
144            .pool
145            .acquire()
146            .await
147            .map_err(|error| PostgresKitError::database("acquire upgrade connection", error))?;
148        let mut transaction = connection
149            .begin()
150            .await
151            .map_err(|error| PostgresKitError::database("begin upgrade transaction", error))?;
152        acquire_schema_lock(&mut transaction, self.plan.schema()).await?;
153
154        let applied = match inspect_schema(&mut transaction, &self.plan).await? {
155            SchemaState::Missing => {
156                return Err(PostgresKitError::SetupRequired {
157                    schema: self.plan.schema().to_owned(),
158                });
159            }
160            SchemaState::Unmanaged => {
161                return Err(PostgresKitError::UnmanagedSchema {
162                    schema: self.plan.schema().to_owned(),
163                });
164            }
165            SchemaState::Managed { applied } => applied,
166        };
167        let current = validate_history(&self.plan, &applied)?;
168        if current == self.plan.current_version() {
169            return Ok(UpgradeOutcome::AlreadyCurrent { version: current });
170        }
171
172        let applied_count = self.plan.migrations().len() - applied.len();
173        apply_migrations(&mut transaction, &self.plan, applied.len()).await?;
174        transaction
175            .commit()
176            .await
177            .map_err(|error| PostgresKitError::database("commit schema upgrade", error))?;
178        Ok(UpgradeOutcome::Applied {
179            from: current,
180            to: self.plan.current_version(),
181            applied: applied_count,
182        })
183    }
184}
185
186#[derive(Debug)]
187enum SchemaState {
188    Missing,
189    Unmanaged,
190    Managed { applied: Vec<AppliedMigration> },
191}
192
193#[derive(Debug)]
194struct AppliedMigration {
195    version: u64,
196    name: String,
197    checksum: String,
198}
199
200async fn verify_pool(pool: &PgPool, plan: &SchemaPlan) -> Result<(), PostgresKitError> {
201    let mut connection = pool
202        .acquire()
203        .await
204        .map_err(|error| PostgresKitError::database("acquire verification connection", error))?;
205    match inspect_schema(&mut connection, plan).await? {
206        SchemaState::Missing => Err(PostgresKitError::SetupRequired {
207            schema: plan.schema().to_owned(),
208        }),
209        SchemaState::Unmanaged => Err(PostgresKitError::UnmanagedSchema {
210            schema: plan.schema().to_owned(),
211        }),
212        SchemaState::Managed { applied } => {
213            let current = validate_history(plan, &applied)?;
214            if current == plan.current_version() {
215                Ok(())
216            } else {
217                Err(PostgresKitError::UpgradeRequired {
218                    schema: plan.schema().to_owned(),
219                    current,
220                    expected: plan.current_version(),
221                })
222            }
223        }
224    }
225}
226
227async fn inspect_schema(
228    connection: &mut PgConnection,
229    plan: &SchemaPlan,
230) -> Result<SchemaState, PostgresKitError> {
231    let owner: Option<String> = sqlx::query_scalar(
232        "SELECT roles.rolname::text\n\
233         FROM pg_namespace AS namespaces\n\
234         JOIN pg_roles AS roles ON roles.oid = namespaces.nspowner\n\
235         WHERE namespaces.nspname = $1",
236    )
237    .bind(plan.schema())
238    .fetch_optional(&mut *connection)
239    .await
240    .map_err(|error| PostgresKitError::database("inspect schema ownership", error))?;
241    let Some(owner) = owner else {
242        return Ok(SchemaState::Missing);
243    };
244
245    let current_role: String = sqlx::query_scalar("SELECT current_user::text")
246        .fetch_one(&mut *connection)
247        .await
248        .map_err(|error| PostgresKitError::database("inspect current database role", error))?;
249    if owner != current_role {
250        return Err(PostgresKitError::OwnershipMismatch {
251            schema: plan.schema().to_owned(),
252            owner,
253            current_role,
254        });
255    }
256
257    let ledger_exists: bool = sqlx::query_scalar(
258        "SELECT EXISTS (\n\
259           SELECT 1\n\
260           FROM pg_class AS relations\n\
261           JOIN pg_namespace AS namespaces ON namespaces.oid = relations.relnamespace\n\
262           WHERE namespaces.nspname = $1\n\
263             AND relations.relname = $2\n\
264             AND relations.relkind = 'r'\n\
265         )",
266    )
267    .bind(plan.schema())
268    .bind(LEDGER_TABLE)
269    .fetch_one(&mut *connection)
270    .await
271    .map_err(|error| PostgresKitError::database("inspect migration ledger", error))?;
272    if !ledger_exists {
273        return Ok(SchemaState::Unmanaged);
274    }
275
276    let ledger = qualified_table(plan.schema(), LEDGER_TABLE);
277    let read_ledger = format!("SELECT version, name, checksum FROM {ledger} ORDER BY version");
278    let rows = sqlx::query(AssertSqlSafe(read_ledger))
279        .fetch_all(&mut *connection)
280        .await
281        .map_err(|error| PostgresKitError::database("read migration ledger", error))?;
282    let mut applied = Vec::with_capacity(rows.len());
283    for row in rows {
284        let version: i64 = row
285            .try_get("version")
286            .map_err(|error| PostgresKitError::database("decode migration version", error))?;
287        let version = u64::try_from(version).map_err(|_| PostgresKitError::HistoryDiverged {
288            schema: plan.schema().to_owned(),
289            version: 0,
290        })?;
291        applied.push(AppliedMigration {
292            version,
293            name: row
294                .try_get("name")
295                .map_err(|error| PostgresKitError::database("decode migration name", error))?,
296            checksum: row
297                .try_get("checksum")
298                .map_err(|error| PostgresKitError::database("decode migration checksum", error))?,
299        });
300    }
301    Ok(SchemaState::Managed { applied })
302}
303
304fn validate_history(
305    plan: &SchemaPlan,
306    applied: &[AppliedMigration],
307) -> Result<u64, PostgresKitError> {
308    if let Some(actual) = applied.iter().map(|migration| migration.version).max()
309        && actual > plan.current_version()
310    {
311        return Err(PostgresKitError::SchemaAhead {
312            schema: plan.schema().to_owned(),
313            actual,
314            expected: plan.current_version(),
315        });
316    }
317
318    for (index, actual) in applied.iter().enumerate() {
319        let Some(expected) = plan.migrations().get(index) else {
320            return Err(PostgresKitError::SchemaAhead {
321                schema: plan.schema().to_owned(),
322                actual: actual.version,
323                expected: plan.current_version(),
324            });
325        };
326        if actual.version != expected.version()
327            || actual.name != expected.name()
328            || actual.checksum != expected.checksum()
329        {
330            return Err(PostgresKitError::HistoryDiverged {
331                schema: plan.schema().to_owned(),
332                version: actual.version,
333            });
334        }
335    }
336    Ok(applied.last().map_or(0, |migration| migration.version))
337}
338
339async fn acquire_schema_lock(
340    transaction: &mut Transaction<'_, Postgres>,
341    schema: &str,
342) -> Result<(), PostgresKitError> {
343    sqlx::query(
344        "SELECT pg_advisory_xact_lock(\n\
345           hashtextextended(current_database() || ':' || $1, 0)\n\
346         )",
347    )
348    .bind(schema)
349    .execute(&mut **transaction)
350    .await
351    .map_err(|error| PostgresKitError::database("lock owned schema", error))?;
352    Ok(())
353}
354
355async fn create_managed_schema(
356    transaction: &mut Transaction<'_, Postgres>,
357    plan: &SchemaPlan,
358) -> Result<(), PostgresKitError> {
359    let schema = quote_identifier(plan.schema());
360    sqlx::raw_sql(AssertSqlSafe(format!("CREATE SCHEMA {schema}")))
361        .execute(&mut **transaction)
362        .await
363        .map_err(|error| PostgresKitError::database("create owned schema", error))?;
364    let ledger = qualified_table(plan.schema(), LEDGER_TABLE);
365    sqlx::raw_sql(AssertSqlSafe(format!(
366        "CREATE TABLE {ledger} (\n\
367           version bigint PRIMARY KEY CHECK (version > 0),\n\
368           name text NOT NULL,\n\
369           checksum text NOT NULL,\n\
370           applied_at timestamptz NOT NULL DEFAULT transaction_timestamp()\n\
371         )"
372    )))
373    .execute(&mut **transaction)
374    .await
375    .map_err(|error| PostgresKitError::database("create migration ledger", error))?;
376    Ok(())
377}
378
379async fn apply_migrations(
380    transaction: &mut Transaction<'_, Postgres>,
381    plan: &SchemaPlan,
382    skip: usize,
383) -> Result<(), PostgresKitError> {
384    let search_path = format!(
385        "SET LOCAL search_path TO {}, pg_catalog",
386        quote_identifier(plan.schema())
387    );
388    sqlx::raw_sql(AssertSqlSafe(search_path))
389        .execute(&mut **transaction)
390        .await
391        .map_err(|error| PostgresKitError::database("select owned schema", error))?;
392
393    let ledger = qualified_table(plan.schema(), LEDGER_TABLE);
394    for migration in plan.migrations().iter().skip(skip) {
395        sqlx::raw_sql(migration.sql())
396            .execute(&mut **transaction)
397            .await
398            .map_err(|error| PostgresKitError::database("apply owned migration", error))?;
399        record_migration(transaction, &ledger, migration).await?;
400    }
401    Ok(())
402}
403
404async fn record_migration(
405    transaction: &mut Transaction<'_, Postgres>,
406    ledger: &str,
407    migration: &Migration,
408) -> Result<(), PostgresKitError> {
409    let version = i64::try_from(migration.version()).expect("validated migration version fits i64");
410    sqlx::query(AssertSqlSafe(format!(
411        "INSERT INTO {ledger} (version, name, checksum) VALUES ($1, $2, $3)"
412    )))
413    .bind(version)
414    .bind(migration.name())
415    .bind(migration.checksum())
416    .execute(&mut **transaction)
417    .await
418    .map_err(|error| PostgresKitError::database("record owned migration", error))?;
419    Ok(())
420}
421
422fn quote_identifier(identifier: &str) -> String {
423    format!("\"{identifier}\"")
424}
425
426fn qualified_table(schema: &str, table: &str) -> String {
427    format!("{}.{}", quote_identifier(schema), quote_identifier(table))
428}