Skip to main content

memoir_core/migration/
mod.rs

1//! Memoir core's database migrations.
2//!
3//! Migrations run in a configurable Postgres schema (default `memoir`),
4//! isolating memoir-core's tables from the caller's `public` schema.
5//! Call [`bootstrap_and_migrate`] from `Client::migrate`.
6
7use once_cell::sync::Lazy;
8use regex::Regex;
9use sea_orm_migration::prelude::*;
10use sea_orm_migration::sea_orm::{ConnectionTrait, DatabaseConnection};
11
12mod m20000000_000001_create_memories;
13mod m20000000_000002_create_memory_jobs;
14mod m20000000_000003_add_superseded_by;
15mod m20000000_000004_add_event_at;
16mod m20000000_000005_create_supersession_events;
17mod m20000000_000006_add_confidence_category_retirement;
18mod m20000000_000007_add_categorize_job_kind;
19mod m20000000_000008_add_reprocess_job_kind;
20mod m20000000_000009_add_relational_extract_job_kind;
21mod m20000000_000010_add_synthesize_job_kind;
22mod m20000000_000011_create_graph_triple_staging;
23
24/// Default Postgres schema for memoir-core's tables.
25pub const DEFAULT_SCHEMA: &str = "memoir";
26
27static SCHEMA_NAME_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[a-z_][a-z0-9_]*$").unwrap());
28
29/// Failure modes for [`bootstrap_and_migrate`].
30#[derive(Debug, thiserror::Error)]
31pub enum MigrationError {
32    #[error("invalid schema name '{0}': must match [a-z_][a-z0-9_]*")]
33    InvalidSchema(String),
34
35    #[error("database error: {0}")]
36    Database(#[from] DbErr),
37}
38
39/// SeaORM migrator for memoir-core's schema.
40pub struct Migrator;
41
42#[async_trait::async_trait]
43impl MigratorTrait for Migrator {
44    /// Ledger table for memoir-core's migration history.
45    ///
46    /// Distinct from sea-orm's default `seaql_migrations` so memoir-core and
47    /// memoir-service can share one Postgres schema (both default to `public`
48    /// for schema-less entity codegen) without their ledgers colliding. Each
49    /// migrator reads only its own rows; sharing one table makes each reject
50    /// the other's applied migrations as "missing files".
51    fn migration_table_name() -> DynIden {
52        Alias::new("memoir_core_migrations").into_iden()
53    }
54
55    fn migrations() -> Vec<Box<dyn MigrationTrait>> {
56        vec![
57            Box::new(m20000000_000001_create_memories::Migration),
58            Box::new(m20000000_000002_create_memory_jobs::Migration),
59            Box::new(m20000000_000003_add_superseded_by::Migration),
60            Box::new(m20000000_000004_add_event_at::Migration),
61            Box::new(m20000000_000005_create_supersession_events::Migration),
62            Box::new(m20000000_000006_add_confidence_category_retirement::Migration),
63            Box::new(m20000000_000007_add_categorize_job_kind::Migration),
64            Box::new(m20000000_000008_add_reprocess_job_kind::Migration),
65            Box::new(m20000000_000009_add_relational_extract_job_kind::Migration),
66            Box::new(m20000000_000010_add_synthesize_job_kind::Migration),
67            Box::new(m20000000_000011_create_graph_triple_staging::Migration),
68        ]
69    }
70}
71
72/// Creates the configured schema if absent, then applies all migrations.
73///
74/// The schema name is validated against `[a-z_][a-z0-9_]*` before any SQL
75/// executes; this is the only safe path because the schema name is
76/// interpolated into raw SQL (Postgres does not accept bound parameters for
77/// identifiers).
78///
79/// # Errors
80///
81/// Returns [`MigrationError::InvalidSchema`] if `schema` fails validation,
82/// [`MigrationError::Database`] for any Postgres failure during schema
83/// creation, search-path configuration, or migration application.
84pub async fn bootstrap_and_migrate(db: &DatabaseConnection, schema: &str) -> Result<(), MigrationError> {
85    validate_schema_name(schema)?;
86
87    db.execute_unprepared(&format!("CREATE SCHEMA IF NOT EXISTS {schema}"))
88        .await?;
89    db.execute_unprepared(&format!("SET search_path TO {schema}, public"))
90        .await?;
91
92    Migrator::up(db, None).await?;
93
94    Ok(())
95}
96
97fn validate_schema_name(schema: &str) -> Result<(), MigrationError> {
98    if SCHEMA_NAME_RE.is_match(schema) {
99        Ok(())
100    } else {
101        Err(MigrationError::InvalidSchema(schema.to_string()))
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn should_accept_simple_lowercase_schema_name() {
111        assert!(validate_schema_name("memoir").is_ok());
112        assert!(validate_schema_name("project_a").is_ok());
113        assert!(validate_schema_name("_underscore_start").is_ok());
114        assert!(validate_schema_name("with123digits").is_ok());
115    }
116
117    #[test]
118    fn should_reject_schema_with_sql_injection_attempt() {
119        let result = validate_schema_name("memoir; DROP TABLE users; --");
120        assert!(matches!(result, Err(MigrationError::InvalidSchema(_))));
121    }
122
123    #[test]
124    fn should_reject_schema_starting_with_digit() {
125        let result = validate_schema_name("1memoir");
126        assert!(matches!(result, Err(MigrationError::InvalidSchema(_))));
127    }
128
129    #[test]
130    fn should_reject_empty_schema() {
131        let result = validate_schema_name("");
132        assert!(matches!(result, Err(MigrationError::InvalidSchema(_))));
133    }
134
135    #[test]
136    fn should_reject_uppercase_schema() {
137        let result = validate_schema_name("Memoir");
138        assert!(matches!(result, Err(MigrationError::InvalidSchema(_))));
139    }
140
141    #[test]
142    fn should_reject_schema_with_special_chars() {
143        assert!(matches!(
144            validate_schema_name("memoir-project"),
145            Err(MigrationError::InvalidSchema(_))
146        ));
147        assert!(matches!(
148            validate_schema_name("memoir.project"),
149            Err(MigrationError::InvalidSchema(_))
150        ));
151        assert!(matches!(
152            validate_schema_name("memoir project"),
153            Err(MigrationError::InvalidSchema(_))
154        ));
155    }
156}