Skip to main content

agentic_core/storage/
schema.rs

1//! Database schema management and migrations.
2
3use std::env;
4use std::sync::Arc;
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::time::Duration;
7
8use sqlx::Connection;
9use tracing::{debug, info};
10
11use super::backend::{DatabaseBackend, configure_postgres_timeouts};
12use super::pool::DbPool;
13use crate::config::DEFAULT_POSTGRES_MIGRATION_TIMEOUT_SECONDS;
14
15type DbResult<T> = Result<T, sqlx::Error>;
16
17const POSTGRES_SCHEMA_ADVISORY_LOCK: i64 = 7_194_963_546_799_751;
18const REQUIRED_POSTGRES_SCHEMA_COLUMN_COUNT: i64 = 19;
19const REQUIRED_POSTGRES_CONSTRAINT_COUNT: i64 = 6;
20const REQUIRED_POSTGRES_INTEGER_COLUMN_COUNT: i64 = 4;
21const POSTGRES_INTEGER_WIDENING_SQL: &str = "
22    ALTER TABLE conversations
23        ALTER COLUMN created_at TYPE BIGINT USING created_at::BIGINT;
24    ALTER TABLE items
25        ALTER COLUMN created_at TYPE BIGINT USING created_at::BIGINT,
26        ALTER COLUMN seq TYPE BIGINT USING seq::BIGINT;
27    ALTER TABLE responses
28        ALTER COLUMN created_at TYPE BIGINT USING created_at::BIGINT;
29";
30
31async fn configure_postgres_migration_timeout(
32    connection: &mut sqlx::AnyConnection,
33    migration_timeout: Duration,
34) -> DbResult<()> {
35    configure_postgres_timeouts(connection, migration_timeout, migration_timeout).await
36}
37
38async fn widen_postgres_integer_columns(connection: &mut sqlx::AnyConnection) -> DbResult<()> {
39    let mut transaction = connection.begin().await?;
40    sqlx::query("SELECT pg_advisory_xact_lock($1)")
41        .bind(POSTGRES_SCHEMA_ADVISORY_LOCK)
42        .execute(&mut *transaction)
43        .await?;
44    let schema_column_count = postgres_required_schema_column_count(&mut *transaction).await?;
45    let constraint_count = postgres_required_constraint_count(&mut *transaction).await?;
46    let (integer_column_count, narrow_column_count) = postgres_integer_column_compatibility(&mut *transaction).await?;
47    let sequence_index_ready = postgres_sequence_index_ready(&mut *transaction).await?;
48    validate_required_postgres_schema(
49        schema_column_count,
50        constraint_count,
51        integer_column_count,
52        sequence_index_ready,
53    )?;
54    if narrow_column_count > 0 {
55        sqlx::raw_sql(POSTGRES_INTEGER_WIDENING_SQL)
56            .execute(&mut *transaction)
57            .await?;
58    }
59    transaction.commit().await
60}
61
62async fn postgres_required_schema_column_count<'e, E>(executor: E) -> DbResult<i64>
63where
64    E: sqlx::Executor<'e, Database = sqlx::Any>,
65{
66    sqlx::query_scalar(
67        "WITH required(table_name, column_name, data_type, is_nullable) AS ( \
68             VALUES \
69                 ('conversations', 'id', 'text', 'NO'), \
70                 ('conversations', 'created_at', 'integer', 'NO'), \
71                 ('conversations', 'tenant_id', 'text', 'YES'), \
72                 ('conversations', 'metadata', 'text', 'YES'), \
73                 ('items', 'id', 'text', 'NO'), \
74                 ('items', 'data', 'text', 'NO'), \
75                 ('items', 'created_at', 'integer', 'NO'), \
76                 ('items', 'conversation_id', 'text', 'YES'), \
77                 ('items', 'seq', 'integer', 'YES'), \
78                 ('items', 'tenant_id', 'text', 'YES'), \
79                 ('items', 'raw_tokens', 'text', 'YES'), \
80                 ('responses', 'id', 'text', 'NO'), \
81                 ('responses', 'conversation_id', 'text', 'YES'), \
82                 ('responses', 'previous_response_id', 'text', 'YES'), \
83                 ('responses', 'history_item_ids', 'text', 'YES'), \
84                 ('responses', 'metadata', 'text', 'YES'), \
85                 ('responses', 'created_at', 'integer', 'NO'), \
86                 ('responses', 'tenant_id', 'text', 'YES'), \
87                 ('responses', 'raw_tokens', 'text', 'YES') \
88         ) \
89         SELECT COUNT(*) \
90         FROM required \
91         JOIN information_schema.columns actual \
92           ON actual.table_name = required.table_name \
93          AND actual.column_name = required.column_name \
94          AND actual.is_nullable = required.is_nullable \
95          AND (actual.data_type = required.data_type \
96               OR (required.data_type = 'integer' AND actual.data_type = 'bigint')) \
97         JOIN pg_class table_relation \
98           ON table_relation.relname = actual.table_name \
99          AND table_relation.relkind IN ('r', 'p') \
100          AND pg_table_is_visible(table_relation.oid) \
101         JOIN pg_namespace table_namespace \
102           ON table_namespace.oid = table_relation.relnamespace \
103          AND table_namespace.nspname = actual.table_schema",
104    )
105    .fetch_one(executor)
106    .await
107}
108
109async fn postgres_required_constraint_count<'e, E>(executor: E) -> DbResult<i64>
110where
111    E: sqlx::Executor<'e, Database = sqlx::Any>,
112{
113    sqlx::query_scalar(
114        "WITH required(table_name, constraint_type, definition) AS ( \
115             VALUES \
116                 ('conversations', 'p', 'PRIMARY KEY (id)'), \
117                 ('items', 'p', 'PRIMARY KEY (id)'), \
118                 ('responses', 'p', 'PRIMARY KEY (id)'), \
119                 ('items', 'f', \
120                  'FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE'), \
121                 ('responses', 'f', \
122                  'FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE SET NULL'), \
123                 ('responses', 'f', \
124                  'FOREIGN KEY (previous_response_id) REFERENCES responses(id) ON DELETE SET NULL') \
125         ) \
126         SELECT COUNT(*) \
127         FROM required \
128         WHERE EXISTS ( \
129             SELECT 1 \
130             FROM pg_constraint constraint_metadata \
131             JOIN pg_class table_relation ON table_relation.oid = constraint_metadata.conrelid \
132             WHERE table_relation.relname = required.table_name \
133             AND pg_table_is_visible(table_relation.oid) \
134             AND constraint_metadata.contype::text = required.constraint_type \
135             AND pg_get_constraintdef(constraint_metadata.oid) = required.definition \
136         )",
137    )
138    .fetch_one(executor)
139    .await
140}
141
142async fn postgres_integer_column_compatibility<'e, E>(executor: E) -> DbResult<(i64, i64)>
143where
144    E: sqlx::Executor<'e, Database = sqlx::Any>,
145{
146    sqlx::query_as(
147        "SELECT COUNT(*), COUNT(*) FILTER (WHERE actual.data_type <> 'bigint') \
148         FROM information_schema.columns actual \
149         JOIN pg_class table_relation \
150           ON table_relation.relname = actual.table_name \
151          AND table_relation.relkind IN ('r', 'p') \
152          AND pg_table_is_visible(table_relation.oid) \
153         JOIN pg_namespace table_namespace \
154           ON table_namespace.oid = table_relation.relnamespace \
155          AND table_namespace.nspname = actual.table_schema \
156         WHERE (actual.table_name = 'conversations' AND actual.column_name = 'created_at') \
157            OR (actual.table_name = 'items' AND actual.column_name IN ('created_at', 'seq')) \
158            OR (actual.table_name = 'responses' AND actual.column_name = 'created_at')",
159    )
160    .fetch_one(executor)
161    .await
162}
163
164async fn postgres_sequence_index_ready<'e, E>(executor: E) -> DbResult<bool>
165where
166    E: sqlx::Executor<'e, Database = sqlx::Any>,
167{
168    sqlx::query_scalar(
169        "SELECT EXISTS ( \
170             SELECT 1 \
171             FROM pg_index index_metadata \
172             JOIN pg_class index_relation ON index_relation.oid = index_metadata.indexrelid \
173             JOIN pg_class table_relation ON table_relation.oid = index_metadata.indrelid \
174             WHERE table_relation.relname = 'items' \
175             AND pg_table_is_visible(table_relation.oid) \
176             AND index_relation.relname = 'idx_items_conversation_id' \
177             AND index_metadata.indisvalid \
178             AND index_metadata.indisready \
179             AND index_metadata.indisunique \
180             AND pg_get_indexdef(index_metadata.indexrelid) LIKE '%(conversation_id, seq)' \
181         )",
182    )
183    .fetch_one(executor)
184    .await
185}
186
187fn validate_required_postgres_schema(
188    schema_column_count: i64,
189    constraint_count: i64,
190    integer_column_count: i64,
191    sequence_index_ready: bool,
192) -> DbResult<()> {
193    if schema_column_count == REQUIRED_POSTGRES_SCHEMA_COLUMN_COUNT
194        && constraint_count == REQUIRED_POSTGRES_CONSTRAINT_COUNT
195        && integer_column_count == REQUIRED_POSTGRES_INTEGER_COLUMN_COUNT
196        && sequence_index_ready
197    {
198        return Ok(());
199    }
200    Err(sqlx::Error::Configuration(
201        "database schema is missing required PostgreSQL tables, columns, constraints, or indexes".into(),
202    ))
203}
204
205fn validate_supervisor_schema(
206    schema_column_count: i64,
207    constraint_count: i64,
208    integer_column_count: i64,
209    narrow_column_count: i64,
210    sequence_index_ready: bool,
211) -> DbResult<()> {
212    validate_required_postgres_schema(
213        schema_column_count,
214        constraint_count,
215        integer_column_count,
216        sequence_index_ready,
217    )?;
218    if narrow_column_count == 0 {
219        return Ok(());
220    }
221    Err(sqlx::Error::Configuration(
222        "supervisor-managed PostgreSQL schema requires BIGINT compatibility upgrade; \
223         apply the documented ALTER TABLE statements before setting AGENTIC_API_SCHEMA_READY"
224            .into(),
225    ))
226}
227
228async fn verify_supervisor_managed_postgres_schema(
229    pool: &DbPool,
230    postgres_migration_timeout: Duration,
231) -> DbResult<()> {
232    let mut connection = pool.acquire().await?;
233    if DatabaseBackend::from_connection(&connection) != DatabaseBackend::Postgres {
234        return Ok(());
235    }
236
237    if let Err(error) = configure_postgres_migration_timeout(&mut connection, postgres_migration_timeout).await {
238        let _ = connection.close().await;
239        return Err(error);
240    }
241    let compatibility_result = async {
242        let schema_column_count = postgres_required_schema_column_count(&mut *connection).await?;
243        let constraint_count = postgres_required_constraint_count(&mut *connection).await?;
244        let (integer_column_count, narrow_column_count) =
245            postgres_integer_column_compatibility(&mut *connection).await?;
246        let sequence_index_ready = postgres_sequence_index_ready(&mut *connection).await?;
247        validate_supervisor_schema(
248            schema_column_count,
249            constraint_count,
250            integer_column_count,
251            narrow_column_count,
252            sequence_index_ready,
253        )
254    }
255    .await;
256    let close_result = connection.close().await;
257    compatibility_result?;
258    close_result
259}
260
261async fn apply_postgres_compatibility(
262    connection: &mut sqlx::AnyConnection,
263    postgres_migration_timeout: Duration,
264) -> DbResult<()> {
265    configure_postgres_migration_timeout(connection, postgres_migration_timeout).await?;
266    widen_postgres_integer_columns(connection).await
267}
268
269fn migration_error(error: sqlx::migrate::MigrateError) -> sqlx::Error {
270    error.into()
271}
272
273pub(crate) async fn pin_postgres_persistence_schema(connection: &mut sqlx::AnyConnection) -> DbResult<()> {
274    let populated_search_path_schemas: Vec<String> = sqlx::query_scalar(
275        "SELECT DISTINCT table_namespace.nspname::text \
276         FROM pg_class table_relation \
277         JOIN pg_namespace table_namespace ON table_namespace.oid = table_relation.relnamespace \
278         WHERE table_namespace.nspname = ANY(current_schemas(false)) \
279         AND table_relation.relkind IN ('r', 'p', 'v', 'm', 'f') \
280         AND table_relation.relname IN ('_sqlx_migrations', 'conversations', 'items', 'responses') \
281         ORDER BY table_namespace.nspname::text",
282    )
283    .fetch_all(&mut *connection)
284    .await?;
285    let target_schema = match populated_search_path_schemas.as_slice() {
286        [] => sqlx::query_scalar::<_, Option<String>>("SELECT current_schema()::text")
287            .fetch_one(&mut *connection)
288            .await?
289            .ok_or_else(|| {
290                sqlx::Error::Configuration(
291                    "PostgreSQL search_path does not contain an existing schema for migrations".into(),
292                )
293            })?,
294        [schema] => schema.clone(),
295        _ => {
296            return Err(sqlx::Error::Configuration(
297                "PostgreSQL persistence tables or migration history exist in multiple search_path schemas".into(),
298            ));
299        }
300    };
301    sqlx::query("SELECT set_config('search_path', quote_ident($1), false)")
302        .bind(target_schema)
303        .execute(&mut *connection)
304        .await?;
305    Ok(())
306}
307
308pub(crate) async fn verify_persistence_writable(pool: &DbPool) -> DbResult<()> {
309    let mut transaction = pool.begin().await?;
310    let probe_result = async {
311        let suffix = uuid::Uuid::now_v7().simple();
312        let conversation_id = format!("conv_readiness_{suffix}");
313        let item_id = format!("item_readiness_{suffix}");
314        let response_id = format!("resp_readiness_{suffix}");
315        let created_at = crate::utils::common::utcnow_str();
316        sqlx::query("INSERT INTO conversations (id, created_at) VALUES ($1, $2)")
317            .bind(&conversation_id)
318            .bind(created_at)
319            .execute(&mut *transaction)
320            .await?;
321        crate::storage::models::conversation::lock_in_tx(&mut transaction, &conversation_id).await?;
322        crate::storage::models::item::create_in_tx(
323            &mut transaction,
324            vec![(item_id.clone(), "{}".to_owned())],
325            Some(&conversation_id),
326        )
327        .await?;
328        crate::storage::models::response::create_in_tx(
329            &mut transaction,
330            &response_id,
331            Some(&conversation_id),
332            None,
333            Some(&format!("[\"{item_id}\"]")),
334            Some("{}"),
335        )
336        .await?;
337        Ok(())
338    }
339    .await;
340    match probe_result {
341        Ok(()) => transaction.rollback().await,
342        Err(error) => {
343            let _ = transaction.rollback().await;
344            Err(error)
345        }
346    }
347}
348
349pub(crate) async fn verify_persistence_ready(pool: &DbPool) -> DbResult<()> {
350    let mut connection = pool.acquire().await?;
351    match DatabaseBackend::from_connection(&connection) {
352        DatabaseBackend::Postgres => {
353            let ready: bool = sqlx::query_scalar(
354                "WITH required(table_name, privilege) AS ( \
355                     VALUES \
356                         ('conversations', 'SELECT'), \
357                         ('conversations', 'INSERT'), \
358                         ('conversations', 'UPDATE'), \
359                         ('items', 'SELECT'), \
360                         ('items', 'INSERT'), \
361                         ('responses', 'SELECT'), \
362                         ('responses', 'INSERT') \
363                 ) \
364                 SELECT current_setting('transaction_read_only') = 'off' \
365                    AND COUNT(table_relation.oid) = 7 \
366                    AND COALESCE(BOOL_AND( \
367                        has_table_privilege(current_user, table_relation.oid, required.privilege) \
368                    ), false) \
369                 FROM required \
370                 LEFT JOIN pg_class table_relation \
371                   ON table_relation.relname = required.table_name \
372                  AND table_relation.relkind IN ('r', 'p') \
373                  AND pg_table_is_visible(table_relation.oid)",
374            )
375            .fetch_one(&mut *connection)
376            .await?;
377            if !ready {
378                return Err(sqlx::Error::Configuration(
379                    "PostgreSQL persistence tables are unavailable, read-only, or missing required privileges".into(),
380                ));
381            }
382        }
383        DatabaseBackend::Sqlite => {
384            let query_only: i64 = sqlx::query_scalar("PRAGMA query_only")
385                .fetch_one(&mut *connection)
386                .await?;
387            if query_only != 0 {
388                return Err(sqlx::Error::Configuration("SQLite persistence is read-only".into()));
389            }
390            for statement in [
391                "SELECT id FROM conversations LIMIT 0",
392                "SELECT id FROM items LIMIT 0",
393                "SELECT id FROM responses LIMIT 0",
394            ] {
395                sqlx::query(statement).execute(&mut *connection).await?;
396            }
397        }
398        DatabaseBackend::Other => {
399            sqlx::query("SELECT 1").execute(&mut *connection).await?;
400        }
401    }
402    Ok(())
403}
404
405async fn run_embedded_migrations(pool: &DbPool, postgres_migration_timeout: Duration) -> DbResult<()> {
406    let mut connection = pool.acquire().await?;
407    let is_postgres = DatabaseBackend::from_connection(&connection) == DatabaseBackend::Postgres;
408    if is_postgres {
409        if let Err(error) = configure_postgres_migration_timeout(&mut connection, postgres_migration_timeout).await {
410            let _ = connection.close().await;
411            return Err(error);
412        }
413    }
414
415    let migration_result = sqlx::migrate!("./migrations")
416        .run(&mut *connection)
417        .await
418        .map_err(migration_error);
419    let postgres_result = if migration_result.is_ok() && is_postgres {
420        apply_postgres_compatibility(&mut connection, postgres_migration_timeout).await
421    } else {
422        Ok(())
423    };
424    let close_result = if is_postgres {
425        connection.close().await
426    } else {
427        drop(connection);
428        Ok(())
429    };
430
431    migration_result?;
432    postgres_result?;
433    close_result
434}
435
436fn is_marked_ready() -> bool {
437    matches!(
438        env::var("AGENTIC_API_SCHEMA_READY").as_deref(),
439        Ok("1" | "true" | "t" | "yes" | "y" | "on")
440    )
441}
442
443/// Database pool with per-pool schema readiness tracking.
444///
445/// Wraps `DbPool` and adds an `AtomicBool` flag to track schema initialization
446/// per pool instance. This eliminates the issue of global state interfering
447/// when multiple pools point to different databases.
448pub struct PoolWithSchema {
449    pool: Arc<DbPool>,
450    schema_ready: AtomicBool,
451    postgres_migration_timeout: Duration,
452}
453
454impl PoolWithSchema {
455    /// Creates a new pool with schema tracking.
456    #[must_use]
457    pub fn new(pool: Arc<DbPool>) -> Self {
458        Self::with_postgres_migration_timeout(pool, Duration::from_secs(DEFAULT_POSTGRES_MIGRATION_TIMEOUT_SECONDS))
459    }
460
461    /// Creates a new pool with schema tracking and a `PostgreSQL` migration timeout.
462    #[must_use]
463    pub fn with_postgres_migration_timeout(pool: Arc<DbPool>, postgres_migration_timeout: Duration) -> Self {
464        Self {
465            pool,
466            schema_ready: AtomicBool::new(false),
467            postgres_migration_timeout,
468        }
469    }
470
471    /// Returns a reference to the underlying database pool.
472    pub fn pool(&self) -> &Arc<DbPool> {
473        &self.pool
474    }
475
476    /// Ensures database schema is ready by running pending migrations.
477    ///
478    /// Checks if migrations have already been applied via one of:
479    /// 1. Per-pool flag (`schema_ready`)
480    /// 2. `AGENTIC_API_SCHEMA_READY` environment variable
481    ///
482    /// If none of the above, runs all pending migrations from the `migrations/` directory.
483    /// Supervisor-managed `PostgreSQL` schemas still verify required compatibility upgrades.
484    ///
485    /// # Errors
486    ///
487    /// Returns a [`sqlx::Error`] if migrations fail.
488    pub async fn ensure_schema_ready(&self) -> DbResult<()> {
489        self.ensure_schema_ready_with_marker(is_marked_ready()).await
490    }
491
492    async fn ensure_schema_ready_with_marker(&self, supervisor_managed: bool) -> DbResult<()> {
493        if self.schema_ready.load(Ordering::SeqCst) {
494            return Ok(());
495        }
496
497        if supervisor_managed {
498            debug!("[schema] Migrations skipped — marked ready by supervisor.");
499            verify_supervisor_managed_postgres_schema(self.pool.as_ref(), self.postgres_migration_timeout).await?;
500            self.schema_ready.store(true, Ordering::SeqCst);
501            return Ok(());
502        }
503
504        debug!("[schema] Running migrations...");
505        run_embedded_migrations(self.pool.as_ref(), self.postgres_migration_timeout).await?;
506        info!("[schema] DB schema ready.");
507        self.schema_ready.store(true, Ordering::SeqCst);
508        Ok(())
509    }
510}
511
512/// Manages database schema initialization and migrations (deprecated).
513///
514/// This struct is kept for backward compatibility. New code should use
515/// [`PoolWithSchema::ensure_schema_ready`] instead.
516pub struct SchemaManager<'a> {
517    pool: &'a DbPool,
518}
519
520impl<'a> SchemaManager<'a> {
521    /// Creates a new schema manager for the given database pool (deprecated).
522    #[must_use]
523    pub fn new(pool: &'a DbPool) -> Self {
524        Self { pool }
525    }
526
527    /// Runs migrations without checking any flag.
528    ///
529    /// # Errors
530    ///
531    /// Returns a [`sqlx::Error`] if migrations fail.
532    pub async fn run_migrations(&self) -> DbResult<()> {
533        debug!("[schema] Running migrations...");
534        run_embedded_migrations(
535            self.pool,
536            Duration::from_secs(DEFAULT_POSTGRES_MIGRATION_TIMEOUT_SECONDS),
537        )
538        .await?;
539        info!("[schema] DB schema ready.");
540        Ok(())
541    }
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547
548    #[test]
549    fn migration_errors_preserve_their_type_and_source() {
550        use std::error::Error as _;
551
552        let error = migration_error(sqlx::migrate::MigrateError::VersionMissing(7));
553        assert!(matches!(error, sqlx::Error::Migrate(_)));
554        assert!(error.source().is_some());
555    }
556
557    #[test]
558    fn supervisor_schema_validation_requires_columns_constraints_and_sequence_index() {
559        assert!(
560            validate_supervisor_schema(
561                REQUIRED_POSTGRES_SCHEMA_COLUMN_COUNT - 1,
562                REQUIRED_POSTGRES_CONSTRAINT_COUNT,
563                REQUIRED_POSTGRES_INTEGER_COLUMN_COUNT,
564                0,
565                true,
566            )
567            .is_err()
568        );
569        assert!(
570            validate_supervisor_schema(
571                REQUIRED_POSTGRES_SCHEMA_COLUMN_COUNT,
572                REQUIRED_POSTGRES_CONSTRAINT_COUNT,
573                REQUIRED_POSTGRES_INTEGER_COLUMN_COUNT,
574                0,
575                false,
576            )
577            .is_err()
578        );
579        assert!(
580            validate_supervisor_schema(
581                REQUIRED_POSTGRES_SCHEMA_COLUMN_COUNT,
582                REQUIRED_POSTGRES_CONSTRAINT_COUNT - 1,
583                REQUIRED_POSTGRES_INTEGER_COLUMN_COUNT,
584                0,
585                true,
586            )
587            .is_err()
588        );
589    }
590
591    #[test]
592    fn test_env_var_pattern() {
593        let test_values = vec![
594            ("1", true),
595            ("true", true),
596            ("t", true),
597            ("yes", true),
598            ("y", true),
599            ("on", true),
600            ("0", false),
601            ("false", false),
602            ("f", false),
603            ("no", false),
604            ("n", false),
605            ("off", false),
606            ("", false),
607        ];
608
609        for (val, expected) in test_values {
610            let matches = matches!(
611                Ok::<&str, String>(val).as_deref(),
612                Ok("1" | "true" | "t" | "yes" | "y" | "on")
613            );
614            assert_eq!(matches, expected, "Mismatch for value '{val}'");
615        }
616    }
617
618    #[tokio::test]
619    async fn test_pool_with_schema_ready() {
620        let pool = crate::storage::pool::create_pool(Some("sqlite://?mode=memory"))
621            .await
622            .expect("failed to create pool");
623
624        let pool_with_schema = PoolWithSchema::new(pool);
625
626        // First call should run migrations
627        let result = pool_with_schema.ensure_schema_ready().await;
628        assert!(result.is_ok(), "ensure_schema_ready failed: {result:?}");
629
630        // Flag should now be set
631        assert!(pool_with_schema.schema_ready.load(Ordering::SeqCst));
632
633        // Second call should return immediately without doing work
634        let result = pool_with_schema.ensure_schema_ready().await;
635        assert!(result.is_ok());
636    }
637
638    #[tokio::test]
639    async fn test_multiple_pools_independent() {
640        // Create two in-memory pools
641        let pool1 = crate::storage::pool::create_pool(Some("sqlite://?mode=memory"))
642            .await
643            .expect("failed to create pool1");
644
645        let pool2 = crate::storage::pool::create_pool(Some("sqlite://?mode=memory"))
646            .await
647            .expect("failed to create pool2");
648
649        let pwc1 = PoolWithSchema::new(pool1);
650        let pwc2 = PoolWithSchema::new(pool2);
651
652        // Initialize both
653        pwc1.ensure_schema_ready().await.expect("pool1 failed");
654        pwc2.ensure_schema_ready().await.expect("pool2 failed");
655
656        // Both should be marked ready independently
657        assert!(pwc1.schema_ready.load(Ordering::SeqCst));
658        assert!(pwc2.schema_ready.load(Ordering::SeqCst));
659
660        // Subsequent calls should succeed without re-running migrations
661        pwc1.ensure_schema_ready().await.expect("pool1 repeat failed");
662        pwc2.ensure_schema_ready().await.expect("pool2 repeat failed");
663    }
664
665    #[tokio::test]
666    #[ignore = "requires TEST_POSTGRES_URL pointing to an isolated PostgreSQL database"]
667    #[allow(
668        clippy::too_many_lines,
669        reason = "keeps the complete migration lifecycle in one integration test"
670    )]
671    async fn postgres_integer_widening_upgrade_preserves_existing_state() {
672        let database_url = std::env::var("TEST_POSTGRES_URL").expect("TEST_POSTGRES_URL must be set");
673        let pool = crate::storage::pool::create_pool(Some(&database_url))
674            .await
675            .expect("create PostgreSQL upgrade-test pool");
676        let schema = format!("upgrade_{}", uuid::Uuid::now_v7().simple());
677        let mut connection = pool.acquire().await.expect("acquire PostgreSQL upgrade connection");
678        sqlx::query(&format!("CREATE SCHEMA {schema}"))
679            .execute(&mut *connection)
680            .await
681            .expect("create isolated upgrade schema");
682        sqlx::query(&format!("SET search_path TO {schema}"))
683            .execute(&mut *connection)
684            .await
685            .expect("select isolated upgrade schema");
686        for migration in [
687            include_str!("../../migrations/0001_initial.sql"),
688            include_str!("../../migrations/0002_add_placeholders.sql"),
689            include_str!("../../migrations/0003_index_conversation_sequence.sql"),
690        ] {
691            sqlx::raw_sql(migration)
692                .execute(&mut *connection)
693                .await
694                .expect("apply portable migration");
695        }
696        sqlx::query("INSERT INTO conversations (id, created_at, metadata) VALUES ($1, $2, $3)")
697            .bind("conv_upgrade")
698            .bind(1_704_067_200_i64)
699            .bind("{\"source\":\"upgrade\"}")
700            .execute(&mut *connection)
701            .await
702            .expect("seed conversation");
703        sqlx::query("INSERT INTO items (id, data, created_at, conversation_id, seq) VALUES ($1, $2, $3, $4, $5)")
704            .bind("item_upgrade")
705            .bind("{}")
706            .bind(1_704_067_200_i64)
707            .bind("conv_upgrade")
708            .bind(0_i64)
709            .execute(&mut *connection)
710            .await
711            .expect("seed item");
712        sqlx::query(
713            "INSERT INTO responses \
714             (id, conversation_id, history_item_ids, metadata, created_at) VALUES ($1, $2, $3, $4, $5)",
715        )
716        .bind("resp_upgrade")
717        .bind("conv_upgrade")
718        .bind("[\"item_upgrade\"]")
719        .bind("{\"source\":\"upgrade\"}")
720        .bind(1_704_067_200_i64)
721        .execute(&mut *connection)
722        .await
723        .expect("seed response");
724
725        let schema_column_count = postgres_required_schema_column_count(&mut *connection)
726            .await
727            .expect("inspect pre-upgrade PostgreSQL schema");
728        let constraint_count = postgres_required_constraint_count(&mut *connection)
729            .await
730            .expect("inspect pre-upgrade PostgreSQL constraints");
731        let (integer_column_count, narrow_column_count) = postgres_integer_column_compatibility(&mut *connection)
732            .await
733            .expect("inspect pre-upgrade PostgreSQL columns");
734        let sequence_index_ready = postgres_sequence_index_ready(&mut *connection)
735            .await
736            .expect("inspect pre-upgrade PostgreSQL sequence index");
737        assert_eq!(schema_column_count, REQUIRED_POSTGRES_SCHEMA_COLUMN_COUNT);
738        assert_eq!(constraint_count, REQUIRED_POSTGRES_CONSTRAINT_COUNT);
739        assert_eq!(integer_column_count, REQUIRED_POSTGRES_INTEGER_COLUMN_COUNT);
740        assert_eq!(narrow_column_count, 4);
741        assert!(sequence_index_ready);
742        assert!(
743            validate_supervisor_schema(
744                schema_column_count,
745                constraint_count,
746                integer_column_count,
747                narrow_column_count,
748                sequence_index_ready,
749            )
750            .is_err()
751        );
752
753        let supervisor_schema_name = schema.clone();
754        let supervisor_pool = sqlx::any::AnyPoolOptions::new()
755            .max_connections(1)
756            .after_connect(move |connection, _metadata| {
757                let supervisor_schema_name = supervisor_schema_name.clone();
758                Box::pin(async move {
759                    sqlx::query("SELECT set_config('search_path', $1, false)")
760                        .bind(supervisor_schema_name)
761                        .execute(connection)
762                        .await?;
763                    Ok(())
764                })
765            })
766            .connect(&database_url)
767            .await
768            .expect("create supervisor-managed PostgreSQL pool");
769        let supervisor_schema =
770            PoolWithSchema::with_postgres_migration_timeout(Arc::new(supervisor_pool), Duration::from_secs(5));
771        let supervisor_error = supervisor_schema
772            .ensure_schema_ready_with_marker(true)
773            .await
774            .expect_err("narrow supervisor-managed schema should fail compatibility check");
775        assert!(supervisor_error.to_string().contains("BIGINT compatibility upgrade"));
776        assert!(!supervisor_schema.schema_ready.load(Ordering::SeqCst));
777
778        apply_postgres_compatibility(&mut connection, Duration::from_secs(5))
779            .await
780            .expect("widen PostgreSQL integer columns");
781        supervisor_schema
782            .ensure_schema_ready_with_marker(true)
783            .await
784            .expect("widened supervisor-managed schema should pass compatibility check");
785        assert!(supervisor_schema.schema_ready.load(Ordering::SeqCst));
786
787        let future_timestamp = i64::from(i32::MAX) + 1;
788        sqlx::query("UPDATE conversations SET created_at = $1 WHERE id = $2")
789            .bind(future_timestamp)
790            .bind("conv_upgrade")
791            .execute(&mut *connection)
792            .await
793            .expect("write timestamp beyond PostgreSQL INT4 range");
794        let linked_state: (i64, String, String) = sqlx::query_as(
795            "SELECT conversations.created_at, items.id, responses.id \
796             FROM conversations \
797             JOIN items ON items.conversation_id = conversations.id \
798             JOIN responses ON responses.conversation_id = conversations.id \
799             WHERE conversations.id = $1",
800        )
801        .bind("conv_upgrade")
802        .fetch_one(&mut *connection)
803        .await
804        .expect("load migrated linked state");
805        assert_eq!(
806            linked_state,
807            (future_timestamp, "item_upgrade".to_owned(), "resp_upgrade".to_owned())
808        );
809        let bigint_columns: i64 = sqlx::query_scalar(
810            "SELECT COUNT(*) FROM information_schema.columns \
811             WHERE table_schema = $1 AND data_type = 'bigint' \
812             AND ((table_name = 'conversations' AND column_name = 'created_at') \
813               OR (table_name = 'items' AND column_name IN ('created_at', 'seq')) \
814               OR (table_name = 'responses' AND column_name = 'created_at'))",
815        )
816        .bind(&schema)
817        .fetch_one(&mut *connection)
818        .await
819        .expect("inspect widened PostgreSQL columns");
820        assert_eq!(bigint_columns, 4);
821        assert!(
822            validate_supervisor_schema(
823                REQUIRED_POSTGRES_SCHEMA_COLUMN_COUNT,
824                REQUIRED_POSTGRES_CONSTRAINT_COUNT,
825                REQUIRED_POSTGRES_INTEGER_COLUMN_COUNT,
826                4 - bigint_columns,
827                true,
828            )
829            .is_ok()
830        );
831        let foreign_key_error =
832            sqlx::query("INSERT INTO items (id, data, created_at, conversation_id) VALUES ($1, $2, $3, $4)")
833                .bind("item_invalid")
834                .bind("{}")
835                .bind(future_timestamp)
836                .bind("conv_missing")
837                .execute(&mut *connection)
838                .await;
839        assert!(foreign_key_error.is_err());
840
841        sqlx::raw_sql(
842            "ALTER TABLE items DROP CONSTRAINT items_conversation_id_fkey; \
843             ALTER TABLE responses ADD CONSTRAINT responses_conversation_id_duplicate_fkey \
844             FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE SET NULL;",
845        )
846        .execute(&mut *connection)
847        .await
848        .expect("replace one required foreign key with a duplicate");
849        let invalid_constraint_schema =
850            PoolWithSchema::with_postgres_migration_timeout(supervisor_schema.pool.clone(), Duration::from_secs(5));
851        let constraint_error = invalid_constraint_schema
852            .ensure_schema_ready_with_marker(true)
853            .await
854            .expect_err("a duplicate foreign key must not compensate for a missing required constraint");
855        assert!(
856            constraint_error
857                .to_string()
858                .contains("missing required PostgreSQL tables, columns, constraints, or indexes"),
859            "{constraint_error}"
860        );
861
862        sqlx::query("SET search_path TO public")
863            .execute(&mut *connection)
864            .await
865            .expect("restore public schema");
866        supervisor_schema.pool.close().await;
867        sqlx::query(&format!("DROP SCHEMA {schema} CASCADE"))
868            .execute(&mut *connection)
869            .await
870            .expect("drop isolated upgrade schema");
871        drop(connection);
872        pool.close().await;
873    }
874
875    #[tokio::test]
876    #[ignore = "requires TEST_POSTGRES_URL pointing to an isolated PostgreSQL database"]
877    #[allow(
878        clippy::too_many_lines,
879        reason = "keeps the complete multi-schema migration and runtime connection lifecycle together"
880    )]
881    async fn postgres_migrations_use_visible_tables_when_current_schema_is_empty() {
882        let database_url = std::env::var("TEST_POSTGRES_URL").expect("TEST_POSTGRES_URL must be set");
883        let pool = crate::storage::pool::create_pool(Some(&database_url))
884            .await
885            .expect("create PostgreSQL schema-visibility test pool");
886        let table_schema = format!("visible_{}", uuid::Uuid::now_v7().simple());
887        let empty_schema = format!("empty_{}", uuid::Uuid::now_v7().simple());
888        let mut connection = pool.acquire().await.expect("acquire PostgreSQL test connection");
889
890        sqlx::query(&format!("CREATE SCHEMA {table_schema}"))
891            .execute(&mut *connection)
892            .await
893            .expect("create table schema");
894        sqlx::query(&format!("CREATE SCHEMA {empty_schema}"))
895            .execute(&mut *connection)
896            .await
897            .expect("create empty schema");
898        sqlx::query("SELECT set_config('search_path', $1, false)")
899            .bind(&table_schema)
900            .execute(&mut *connection)
901            .await
902            .expect("select table schema");
903        sqlx::migrate!("./migrations")
904            .run(&mut *connection)
905            .await
906            .expect("apply portable migrations to table schema");
907        sqlx::query("INSERT INTO conversations (id, created_at) VALUES ($1, $2)")
908            .bind("conv_visible_schema")
909            .bind(1_704_067_200_i64)
910            .execute(&mut *connection)
911            .await
912            .expect("seed visible-schema conversation");
913
914        sqlx::query("SELECT set_config('search_path', $1, false)")
915            .bind(format!("{empty_schema},{table_schema}"))
916            .execute(&mut *connection)
917            .await
918            .expect("put empty schema first in search path");
919        let current_schema: String = sqlx::query_scalar("SELECT current_schema()::text")
920            .fetch_one(&mut *connection)
921            .await
922            .expect("inspect current schema");
923        assert_eq!(current_schema, empty_schema);
924
925        pin_postgres_persistence_schema(&mut connection)
926            .await
927            .expect("pin visible PostgreSQL migration schema");
928        let migration_result = sqlx::migrate!("./migrations").run(&mut *connection).await;
929        let compatibility_result = apply_postgres_compatibility(&mut connection, Duration::from_secs(5)).await;
930        let bigint_columns: i64 = sqlx::query_scalar(
931            "SELECT COUNT(*) FROM information_schema.columns \
932             WHERE table_schema = $1 AND data_type = 'bigint' \
933             AND ((table_name = 'conversations' AND column_name = 'created_at') \
934               OR (table_name = 'items' AND column_name IN ('created_at', 'seq')) \
935               OR (table_name = 'responses' AND column_name = 'created_at'))",
936        )
937        .bind(&table_schema)
938        .fetch_one(&mut *connection)
939        .await
940        .expect("inspect visible PostgreSQL columns");
941        let shadow_table_count: i64 = sqlx::query_scalar(
942            "SELECT COUNT(*) FROM information_schema.tables \
943             WHERE table_schema = $1 AND table_name IN ('conversations', 'items', 'responses')",
944        )
945        .bind(&empty_schema)
946        .fetch_one(&mut *connection)
947        .await
948        .expect("inspect empty schema for shadow tables");
949        let seeded_conversation_count: i64 = sqlx::query_scalar(&format!(
950            "SELECT COUNT(*) FROM {table_schema}.conversations WHERE id = $1"
951        ))
952        .bind("conv_visible_schema")
953        .fetch_one(&mut *connection)
954        .await
955        .expect("inspect seeded conversation");
956        let query_separator = if database_url.contains('?') { '&' } else { '?' };
957        let runtime_database_url =
958            format!("{database_url}{query_separator}options=-csearch_path%3D{empty_schema}%2C{table_schema}");
959        let runtime_pool = crate::storage::pool::create_pool(Some(&runtime_database_url))
960            .await
961            .expect("create runtime pool with multi-schema search path");
962        let runtime_schema: String = sqlx::query_scalar("SELECT current_schema()::text")
963            .fetch_one(runtime_pool.as_ref())
964            .await
965            .expect("inspect runtime pool schema");
966        let runtime_seeded_conversation_count: i64 =
967            sqlx::query_scalar("SELECT COUNT(*) FROM conversations WHERE id = $1")
968                .bind("conv_visible_schema")
969                .fetch_one(runtime_pool.as_ref())
970                .await
971                .expect("load seeded conversation through runtime pool");
972        verify_persistence_writable(runtime_pool.as_ref())
973            .await
974            .expect("run PostgreSQL functional persistence probe");
975        verify_persistence_ready(runtime_pool.as_ref())
976            .await
977            .expect("run PostgreSQL read-only persistence probe");
978        runtime_pool.close().await;
979
980        sqlx::query("SET search_path TO public")
981            .execute(&mut *connection)
982            .await
983            .expect("restore public schema");
984        sqlx::query(&format!("DROP SCHEMA {empty_schema} CASCADE"))
985            .execute(&mut *connection)
986            .await
987            .expect("drop empty schema");
988        sqlx::query(&format!("DROP SCHEMA {table_schema} CASCADE"))
989            .execute(&mut *connection)
990            .await
991            .expect("drop table schema");
992        drop(connection);
993        pool.close().await;
994
995        migration_result.expect("visible PostgreSQL schema should accept repeated migrations");
996        compatibility_result.expect("visible PostgreSQL schema should pass compatibility check");
997        assert_eq!(shadow_table_count, 0);
998        assert_eq!(seeded_conversation_count, 1);
999        assert_eq!(runtime_schema, table_schema);
1000        assert_eq!(runtime_seeded_conversation_count, 1);
1001        assert_eq!(bigint_columns, REQUIRED_POSTGRES_INTEGER_COLUMN_COUNT);
1002    }
1003
1004    #[tokio::test]
1005    #[ignore = "requires TEST_POSTGRES_URL pointing to an isolated PostgreSQL database"]
1006    async fn supervisor_managed_postgres_schema_rejects_missing_tables() {
1007        let database_url = std::env::var("TEST_POSTGRES_URL").expect("TEST_POSTGRES_URL must be set");
1008        let administration_pool = crate::storage::pool::create_pool(Some(&database_url))
1009            .await
1010            .expect("create PostgreSQL administration pool");
1011        let schema_name = format!("empty_{}", uuid::Uuid::now_v7().simple());
1012        sqlx::query(&format!("CREATE SCHEMA {schema_name}"))
1013            .execute(administration_pool.as_ref())
1014            .await
1015            .expect("create isolated empty schema");
1016
1017        let connection_schema_name = schema_name.clone();
1018        let supervisor_pool = sqlx::any::AnyPoolOptions::new()
1019            .max_connections(1)
1020            .after_connect(move |connection, _metadata| {
1021                let connection_schema_name = connection_schema_name.clone();
1022                Box::pin(async move {
1023                    sqlx::query("SELECT set_config('search_path', $1, false)")
1024                        .bind(connection_schema_name)
1025                        .execute(connection)
1026                        .await?;
1027                    Ok(())
1028                })
1029            })
1030            .connect(&database_url)
1031            .await
1032            .expect("create supervisor-managed PostgreSQL pool");
1033        let supervisor_schema =
1034            PoolWithSchema::with_postgres_migration_timeout(Arc::new(supervisor_pool), Duration::from_secs(5));
1035
1036        let validation_result = supervisor_schema.ensure_schema_ready_with_marker(true).await;
1037        supervisor_schema.pool.close().await;
1038        sqlx::query(&format!("DROP SCHEMA {schema_name} CASCADE"))
1039            .execute(administration_pool.as_ref())
1040            .await
1041            .expect("drop isolated empty schema");
1042        administration_pool.close().await;
1043
1044        let error = validation_result.expect_err("empty supervisor-managed schema should fail validation");
1045        assert!(
1046            error
1047                .to_string()
1048                .contains("missing required PostgreSQL tables, columns, constraints, or indexes"),
1049            "{error}"
1050        );
1051        assert!(!supervisor_schema.schema_ready.load(Ordering::SeqCst));
1052    }
1053}