1use 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
443pub struct PoolWithSchema {
449 pool: Arc<DbPool>,
450 schema_ready: AtomicBool,
451 postgres_migration_timeout: Duration,
452}
453
454impl PoolWithSchema {
455 #[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 #[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 pub fn pool(&self) -> &Arc<DbPool> {
473 &self.pool
474 }
475
476 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
512pub struct SchemaManager<'a> {
517 pool: &'a DbPool,
518}
519
520impl<'a> SchemaManager<'a> {
521 #[must_use]
523 pub fn new(pool: &'a DbPool) -> Self {
524 Self { pool }
525 }
526
527 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 let result = pool_with_schema.ensure_schema_ready().await;
628 assert!(result.is_ok(), "ensure_schema_ready failed: {result:?}");
629
630 assert!(pool_with_schema.schema_ready.load(Ordering::SeqCst));
632
633 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 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 pwc1.ensure_schema_ready().await.expect("pool1 failed");
654 pwc2.ensure_schema_ready().await.expect("pool2 failed");
655
656 assert!(pwc1.schema_ready.load(Ordering::SeqCst));
658 assert!(pwc2.schema_ready.load(Ordering::SeqCst));
659
660 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}