0.0.4-alpha.2
This commit is contained in:
20
crates/knowledge-store-postgres-lib/Cargo.toml
Normal file
20
crates/knowledge-store-postgres-lib/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
# file: crates/knowledge-store-postgres-lib/Cargo.toml
|
||||
# version: 1
|
||||
|
||||
[package]
|
||||
name = "knowledge-store-postgres-lib"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
deadpool-postgres = { version = "0.14.2", default-features = false, features = ["rt_tokio_1"] }
|
||||
refinery = { version = "0.9.2", default-features = false, features = ["tokio-postgres"] }
|
||||
tokio-postgres = { version = "0.7.18", default-features = false, features = ["runtime"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.53.1", default-features = false, features = ["macros", "rt-multi-thread"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,13 @@
|
||||
-- file: crates/knowledge-store-postgres-lib/migrations/V1__initial_sources.sql
|
||||
-- version: 1
|
||||
|
||||
CREATE TABLE knowledge_sources (
|
||||
source_id TEXT PRIMARY KEY,
|
||||
family TEXT NOT NULL CHECK (family IN ('pgr', 'howto', 'snippet', 'project_local')),
|
||||
git_reference TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE knowledge_source_sync_state (
|
||||
source_id TEXT PRIMARY KEY REFERENCES knowledge_sources(source_id) ON DELETE CASCADE,
|
||||
last_synchronized_revision TEXT NOT NULL
|
||||
);
|
||||
39
crates/knowledge-store-postgres-lib/src/config.rs
Normal file
39
crates/knowledge-store-postgres-lib/src/config.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
// file: crates/knowledge-store-postgres-lib/src/config.rs
|
||||
// version: 1
|
||||
|
||||
//! Explicit PostgreSQL pool configuration supplied by application composition.
|
||||
|
||||
/// Configuration required to construct the shared PostgreSQL store.
|
||||
///
|
||||
/// The type intentionally does not implement `Debug` because the database URL may contain a
|
||||
/// credential. Callers must not log or persist the URL as diagnostic output.
|
||||
#[derive(Clone)]
|
||||
pub struct PostgresStoreConfig {
|
||||
database_url: String,
|
||||
max_pool_size: usize,
|
||||
}
|
||||
|
||||
impl crate::PostgresStoreConfig {
|
||||
/// Build a PostgreSQL configuration from an explicit connection URL and maximum pool size.
|
||||
pub fn new(database_url: &str, max_pool_size: usize) -> Result<crate::PostgresStoreConfig, crate::PostgresStoreError> {
|
||||
if database_url.trim().is_empty() {
|
||||
return Err(crate::PostgresStoreError::EmptyDatabaseUrl);
|
||||
}
|
||||
if max_pool_size == 0 {
|
||||
return Err(crate::PostgresStoreError::InvalidPoolSize);
|
||||
}
|
||||
return Ok(Self { database_url: database_url.to_owned(), max_pool_size });
|
||||
}
|
||||
|
||||
pub(crate) fn database_url(&self) -> &str {
|
||||
return self.database_url.as_str();
|
||||
}
|
||||
|
||||
pub(crate) fn max_pool_size(&self) -> usize {
|
||||
return self.max_pool_size;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/config.rs"]
|
||||
mod tests;
|
||||
36
crates/knowledge-store-postgres-lib/src/error.rs
Normal file
36
crates/knowledge-store-postgres-lib/src/error.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
// file: crates/knowledge-store-postgres-lib/src/error.rs
|
||||
// version: 1
|
||||
|
||||
//! Recoverable errors owned by the PostgreSQL storage boundary.
|
||||
|
||||
/// Recoverable failure returned by the PostgreSQL storage foundation.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PostgresStoreError {
|
||||
/// The explicit database URL is empty or whitespace only.
|
||||
EmptyDatabaseUrl,
|
||||
/// The configured pool size is zero.
|
||||
InvalidPoolSize,
|
||||
/// The database URL cannot be parsed as PostgreSQL connection configuration.
|
||||
InvalidDatabaseConfiguration,
|
||||
/// The connection pool cannot be constructed from the supplied configuration.
|
||||
PoolBuild,
|
||||
/// A PostgreSQL connection cannot be acquired from the pool.
|
||||
PoolAcquisition,
|
||||
/// The embedded migration set cannot be applied.
|
||||
Migration,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for crate::PostgresStoreError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
crate::PostgresStoreError::EmptyDatabaseUrl => return formatter.write_str("PostgreSQL database URL must not be empty"),
|
||||
crate::PostgresStoreError::InvalidPoolSize => return formatter.write_str("PostgreSQL pool size must be greater than zero"),
|
||||
crate::PostgresStoreError::InvalidDatabaseConfiguration => return formatter.write_str("PostgreSQL database configuration is invalid"),
|
||||
crate::PostgresStoreError::PoolBuild => return formatter.write_str("PostgreSQL connection pool could not be constructed"),
|
||||
crate::PostgresStoreError::PoolAcquisition => return formatter.write_str("PostgreSQL connection could not be acquired"),
|
||||
crate::PostgresStoreError::Migration => return formatter.write_str("PostgreSQL migrations could not be applied"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for crate::PostgresStoreError {}
|
||||
16
crates/knowledge-store-postgres-lib/src/lib.rs
Normal file
16
crates/knowledge-store-postgres-lib/src/lib.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
// file: crates/knowledge-store-postgres-lib/src/lib.rs
|
||||
// version: 1
|
||||
|
||||
//! PostgreSQL storage foundation shared by SaseDev Knowledge applications.
|
||||
//!
|
||||
//! The crate owns explicit PostgreSQL configuration, the connection pool and versioned migrations.
|
||||
//! It does not read process environment variables, expose driver types through its public API or
|
||||
//! replace Git as the authority for published content.
|
||||
|
||||
mod config;
|
||||
mod error;
|
||||
mod store;
|
||||
|
||||
pub use crate::config::PostgresStoreConfig;
|
||||
pub use crate::error::PostgresStoreError;
|
||||
pub use crate::store::PostgresStore;
|
||||
44
crates/knowledge-store-postgres-lib/src/store.rs
Normal file
44
crates/knowledge-store-postgres-lib/src/store.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
// file: crates/knowledge-store-postgres-lib/src/store.rs
|
||||
// version: 1
|
||||
|
||||
//! Connection-pool ownership and explicit migration execution.
|
||||
|
||||
mod embedded {
|
||||
refinery::embed_migrations!();
|
||||
}
|
||||
|
||||
/// Shared PostgreSQL store backed by a bounded Deadpool connection pool.
|
||||
#[derive(Clone)]
|
||||
pub struct PostgresStore {
|
||||
pool: deadpool_postgres::Pool,
|
||||
}
|
||||
|
||||
impl crate::PostgresStore {
|
||||
/// Construct the PostgreSQL store without reading process environment variables.
|
||||
pub fn new(config: crate::PostgresStoreConfig) -> Result<crate::PostgresStore, crate::PostgresStoreError> {
|
||||
let postgres_config = match config.database_url().parse::<tokio_postgres::Config>() {
|
||||
Ok(value) => value,
|
||||
Err(_) => return Err(crate::PostgresStoreError::InvalidDatabaseConfiguration),
|
||||
};
|
||||
let manager = deadpool_postgres::Manager::new(postgres_config, tokio_postgres::NoTls);
|
||||
let pool = match deadpool_postgres::Pool::builder(manager).max_size(config.max_pool_size()).build() {
|
||||
Ok(value) => value,
|
||||
Err(_) => return Err(crate::PostgresStoreError::PoolBuild),
|
||||
};
|
||||
return Ok(Self { pool });
|
||||
}
|
||||
|
||||
/// Apply every embedded versioned migration that has not yet been recorded by Refinery.
|
||||
pub async fn apply_migrations(&self) -> Result<(), crate::PostgresStoreError> {
|
||||
let mut connection = match self.pool.get().await {
|
||||
Ok(value) => value,
|
||||
Err(_) => return Err(crate::PostgresStoreError::PoolAcquisition),
|
||||
};
|
||||
let wrapper = std::ops::DerefMut::deref_mut(&mut connection);
|
||||
let client = std::ops::DerefMut::deref_mut(wrapper);
|
||||
match embedded::migrations::runner().run_async(client).await {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(_) => return Err(crate::PostgresStoreError::Migration),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// file: crates/knowledge-store-postgres-lib/tests/postgres_migration.rs
|
||||
// version: 1
|
||||
|
||||
fn test_schema_name() -> String {
|
||||
let elapsed = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
|
||||
Ok(value) => value,
|
||||
Err(_) => return format!("knowledge_test_{}", std::process::id()),
|
||||
};
|
||||
return format!("knowledge_test_{}_{}", std::process::id(), elapsed.as_nanos());
|
||||
}
|
||||
|
||||
fn database_url_with_search_path(database_url: &str, schema: &str) -> String {
|
||||
let separator = if database_url.contains('?') { '&' } else { '?' };
|
||||
return format!("{database_url}{separator}options=-c%20search_path%3D{schema}");
|
||||
}
|
||||
|
||||
async fn connect(database_url: &str) -> Option<tokio_postgres::Client> {
|
||||
let connection_result = tokio_postgres::connect(database_url, tokio_postgres::NoTls).await;
|
||||
assert!(connection_result.is_ok(), "test PostgreSQL connection failed");
|
||||
let (client, connection) = match connection_result {
|
||||
Ok(value) => value,
|
||||
Err(_) => return None,
|
||||
};
|
||||
let _connection_task = tokio::spawn(async move {
|
||||
let _connection_result = connection.await;
|
||||
return;
|
||||
});
|
||||
return Some(client);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires KNOWLEDGE_POSTGRES_TEST_URL"]
|
||||
async fn initial_migration_runs_in_isolated_schema() {
|
||||
let database_url_result = std::env::var("KNOWLEDGE_POSTGRES_TEST_URL");
|
||||
assert!(database_url_result.is_ok(), "KNOWLEDGE_POSTGRES_TEST_URL is required");
|
||||
let database_url = match database_url_result {
|
||||
Ok(value) => value,
|
||||
Err(_) => return,
|
||||
};
|
||||
let schema = test_schema_name();
|
||||
let admin = match connect(database_url.as_str()).await {
|
||||
Some(value) => value,
|
||||
None => return,
|
||||
};
|
||||
let create_schema = format!("CREATE SCHEMA {schema}");
|
||||
let create_result = admin.batch_execute(create_schema.as_str()).await;
|
||||
assert!(create_result.is_ok(), "isolated PostgreSQL schema creation failed");
|
||||
if create_result.is_err() {
|
||||
return;
|
||||
}
|
||||
let isolated_url = database_url_with_search_path(database_url.as_str(), schema.as_str());
|
||||
let config_result = knowledge_store_postgres_lib::PostgresStoreConfig::new(isolated_url.as_str(), 2);
|
||||
assert!(config_result.is_ok(), "PostgreSQL store configuration failed");
|
||||
let config = match config_result {
|
||||
Ok(value) => value,
|
||||
Err(_) => return,
|
||||
};
|
||||
let store_result = knowledge_store_postgres_lib::PostgresStore::new(config);
|
||||
assert!(store_result.is_ok(), "PostgreSQL store construction failed");
|
||||
let store = match store_result {
|
||||
Ok(value) => value,
|
||||
Err(_) => return,
|
||||
};
|
||||
let migration_result = store.apply_migrations().await;
|
||||
assert!(migration_result.is_ok(), "initial PostgreSQL migration failed");
|
||||
let table_result = admin
|
||||
.query(
|
||||
"SELECT table_name FROM information_schema.tables WHERE table_schema = $1 AND table_name IN ('knowledge_sources', 'knowledge_source_sync_state', 'refinery_schema_history') ORDER BY table_name",
|
||||
&[&schema],
|
||||
)
|
||||
.await;
|
||||
assert!(table_result.is_ok(), "PostgreSQL migration verification query failed");
|
||||
let table_rows = match table_result {
|
||||
Ok(value) => value,
|
||||
Err(_) => return,
|
||||
};
|
||||
assert_eq!(table_rows.len(), 3);
|
||||
if table_rows.len() == 3 {
|
||||
assert_eq!(table_rows[0].get::<usize, &str>(0), "knowledge_source_sync_state");
|
||||
assert_eq!(table_rows[1].get::<usize, &str>(0), "knowledge_sources");
|
||||
assert_eq!(table_rows[2].get::<usize, &str>(0), "refinery_schema_history");
|
||||
}
|
||||
drop(store);
|
||||
let drop_schema = format!("DROP SCHEMA {schema} CASCADE");
|
||||
let drop_result = admin.batch_execute(drop_schema.as_str()).await;
|
||||
assert!(drop_result.is_ok(), "isolated PostgreSQL schema cleanup failed");
|
||||
return;
|
||||
}
|
||||
23
crates/knowledge-store-postgres-lib/unit_tests/config.rs
Normal file
23
crates/knowledge-store-postgres-lib/unit_tests/config.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
// file: crates/knowledge-store-postgres-lib/unit_tests/config.rs
|
||||
// version: 1
|
||||
|
||||
#[test]
|
||||
fn config_rejects_empty_database_url() {
|
||||
let result = crate::PostgresStoreConfig::new(" ", 4);
|
||||
assert!(matches!(result, Err(crate::PostgresStoreError::EmptyDatabaseUrl)));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_rejects_zero_pool_size() {
|
||||
let result = crate::PostgresStoreConfig::new("postgresql://localhost/knowledge", 0);
|
||||
assert!(matches!(result, Err(crate::PostgresStoreError::InvalidPoolSize)));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_accepts_explicit_non_empty_values() {
|
||||
let result = crate::PostgresStoreConfig::new("postgresql://localhost/knowledge", 4);
|
||||
assert!(result.is_ok());
|
||||
return;
|
||||
}
|
||||
Reference in New Issue
Block a user