From c4f56d9e854b3914af009ccb4de877198909058c Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Sat, 29 Aug 2026 19:53:00 +0200 Subject: [PATCH] v0.3.2-pre.006 --- Cargo.toml | 9 +- crates/ksp-store-lib/src/error.rs | 8 +- crates/ksp-store-lib/src/lib.rs | 14 +- crates/ksp-store-lib/src/store.rs | 9 +- crates/ksp-store-lib/tests/public_api.rs | 5 +- crates/ksp-store-postgres-lib/Cargo.toml | 3 +- .../migrations/V000__bootstrap.sql | 6 + crates/ksp-store-postgres-lib/src/error.rs | 8 +- crates/ksp-store-postgres-lib/src/lib.rs | 11 +- .../ksp-store-postgres-lib/src/migration.rs | 312 ++++++++++++++++++ crates/ksp-store-postgres-lib/src/runtime.rs | 31 +- .../tests/dependency_boundary.rs | 16 +- .../tests/public_api.rs | 10 +- .../unit_tests/migration.rs | 50 +++ .../unit_tests/runtime.rs | 5 +- deltas/0.3.2/pre.006.md | 143 ++++++++ ...3-V0_3_2_STORE_POSTGRES_FOUNDATION_PLAN.md | 31 +- .../019-V0_3_2_STORE_POSTGRES_FOUNDATION.md | 52 +-- 18 files changed, 674 insertions(+), 49 deletions(-) create mode 100644 crates/ksp-store-postgres-lib/migrations/V000__bootstrap.sql create mode 100644 crates/ksp-store-postgres-lib/src/migration.rs create mode 100644 crates/ksp-store-postgres-lib/unit_tests/migration.rs create mode 100644 deltas/0.3.2/pre.006.md diff --git a/Cargo.toml b/Cargo.toml index 00ab8c1..7d4c8be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 339 +# version: 340 [workspace] resolver = "3" members = ["crates/ksp-app-config-desk", "crates/ksp-app-solprices-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-interface-lib", "crates/ksp-logging-lib", "crates/ksp-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-program-api", "crates/ksp-store-api", "crates/ksp-store-lib", "crates/ksp-store-postgres-lib", "crates/ksp-wallet-lib"] [workspace.package] -version = "0.3.2-pre.5.fix.1" +version = "0.3.2-pre.6" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" @@ -14,7 +14,7 @@ authors = ["SinuS von SifriduS "] publish = false [workspace.dependencies] -argon2 = { version = "^0.5", default-features = false } +argon2 = { version = "^0.6", default-features = false } base64 = { version = "^0.23" } chacha20poly1305 = { version = "^0.11", default-features = false } chrono = { version = "^0.4", default-features = false } @@ -25,12 +25,13 @@ fs2 = { version = "^0.4" } futures-util = { version = "^0.3", default-features = false } getrandom = { version = "^0.4", default-features = false } http = { version = "^1.5", default-features = false } -jsonschema = { version = "^0.51", default-features = false } +jsonschema = { version = "^0.52", default-features = false } reqwest = { version = "^0.13", default-features = false } rustls = { version = "^0.23", default-features = false } rustls-native-certs = { version = "^0.8", default-features = false } serde = { version = "^1.0" } serde_json = { version = "^1.0" } +sha2 = { version = "^0.11", default-features = false } solana-keypair = { version = "^3.1", default-features = false } solana-pubkey = { version = "^4.3", default-features = false } tauri = { version = "^2.11" } diff --git a/crates/ksp-store-lib/src/error.rs b/crates/ksp-store-lib/src/error.rs index 605ad65..4d47e3f 100644 --- a/crates/ksp-store-lib/src/error.rs +++ b/crates/ksp-store-lib/src/error.rs @@ -1,5 +1,5 @@ // file: crates/ksp-store-lib/src/error.rs -// version: 2 +// version: 3 /// Error code reserved for operations attempted after a Store backend has entered its closed state. pub const ERROR_CODE_BACKEND_CLOSED: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "backend_closed"); @@ -11,8 +11,14 @@ pub const ERROR_CODE_BACKEND_OPEN_FAILED: ksp_store_api::ErrorCode = ksp_store_a pub const ERROR_CODE_POSTGRES_CONFIG_INVALID: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_config_invalid"); /// Error code used when PostgreSQL physical connection establishment fails without exposing remote or credential details. pub const ERROR_CODE_POSTGRES_CONNECT_FAILED: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_connect_failed"); +/// Error code used when PostgreSQL migration/bootstrap execution fails without exposing server text or SQL. +pub const ERROR_CODE_POSTGRES_MIGRATION_FAILED: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_migration_failed"); +/// Error code used when persisted PostgreSQL migration history diverges from the embedded immutable KSP history. +pub const ERROR_CODE_POSTGRES_MIGRATION_MISMATCH: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_migration_mismatch"); /// Error code used when a bounded PostgreSQL pool wait, create or recycle operation reaches its deadline. pub const ERROR_CODE_POSTGRES_POOL_TIMEOUT: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_pool_timeout"); +/// Error code used when PostgreSQL history contains a migration newer than this Store runtime understands. +pub const ERROR_CODE_POSTGRES_SCHEMA_NEWER: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_schema_newer"); /// Error code used when verified PostgreSQL TLS setup or negotiation cannot be completed safely. pub const ERROR_CODE_POSTGRES_TLS_FAILED: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_tls_failed"); /// Error code used when backend-neutral Store settings violate runtime bounds or invariants. diff --git a/crates/ksp-store-lib/src/lib.rs b/crates/ksp-store-lib/src/lib.rs index 04b3f3d..f7d6e17 100644 --- a/crates/ksp-store-lib/src/lib.rs +++ b/crates/ksp-store-lib/src/lib.rs @@ -1,5 +1,5 @@ // file: crates/ksp-store-lib/src/lib.rs -// version: 4 +// version: 5 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -7,9 +7,9 @@ //! Common backend-neutral Store runtime facade for KSP. //! -//! `0.3.2-pre.005` materializes the first physical PostgreSQL runtime path: -//! one bounded Deadpool pool, one startup connection proof and explicit TLS -//! policy. SQL migrations remain private future slices of this release. +//! `0.3.2-pre.006` materializes the physical PostgreSQL runtime plus its private +//! migration/bootstrap foundation: bounded pool, explicit TLS, versioned SHA-256 +//! history and no business persistence schema. //! //! The default `postgres` feature compiles the official PostgreSQL backend as //! an optional implementation dependency. No backend implementation type is @@ -30,8 +30,14 @@ pub use self::error::ERROR_CODE_BACKEND_OPEN_FAILED; pub use self::error::ERROR_CODE_POSTGRES_CONFIG_INVALID; /// Error code used when PostgreSQL physical connection establishment fails. pub use self::error::ERROR_CODE_POSTGRES_CONNECT_FAILED; +/// Error code used when PostgreSQL migration/bootstrap execution fails safely. +pub use self::error::ERROR_CODE_POSTGRES_MIGRATION_FAILED; +/// Error code used when PostgreSQL migration history diverges from the embedded immutable KSP history. +pub use self::error::ERROR_CODE_POSTGRES_MIGRATION_MISMATCH; /// Error code used when a bounded PostgreSQL pool operation reaches its deadline. pub use self::error::ERROR_CODE_POSTGRES_POOL_TIMEOUT; +/// Error code used when PostgreSQL schema history is newer than this Store runtime. +pub use self::error::ERROR_CODE_POSTGRES_SCHEMA_NEWER; /// Error code used when PostgreSQL verified TLS setup or negotiation fails. pub use self::error::ERROR_CODE_POSTGRES_TLS_FAILED; /// Error code used when Store settings violate backend-neutral bounds or invariants. diff --git a/crates/ksp-store-lib/src/store.rs b/crates/ksp-store-lib/src/store.rs index 8e5616e..852d8f7 100644 --- a/crates/ksp-store-lib/src/store.rs +++ b/crates/ksp-store-lib/src/store.rs @@ -1,5 +1,5 @@ // file: crates/ksp-store-lib/src/store.rs -// version: 2 +// version: 3 /// Opaque common Store runtime facade. /// @@ -90,6 +90,7 @@ async fn open_postgres( shutdown_timeout: std::time::Duration, settings: &crate::PostgresStoreSettings, ) -> ksp_store_api::Result { + let bootstrap = settings.bootstrap(); let pool = settings.pool(); let tls_mode = match settings.tls_mode() { crate::PostgresTlsMode::Disabled => ksp_store_postgres_lib::PostgresBackendTlsMode::Disabled, @@ -104,6 +105,9 @@ async fn open_postgres( pool.create_timeout(), pool.recycle_timeout(), tls_mode, + bootstrap.auto_migrate(), + bootstrap.migration_timeout(), + bootstrap.migration_lock_timeout(), ); let opened = ksp_store_postgres_lib::PostgresBackend::open(backend_settings).await; return match opened { @@ -136,6 +140,9 @@ fn map_postgres_error(error: ksp_store_postgres_lib::PostgresBackendError, backe ksp_store_postgres_lib::PostgresBackendErrorKind::ConfigInvalid => crate::ERROR_CODE_POSTGRES_CONFIG_INVALID, ksp_store_postgres_lib::PostgresBackendErrorKind::ConnectFailed => crate::ERROR_CODE_POSTGRES_CONNECT_FAILED, ksp_store_postgres_lib::PostgresBackendErrorKind::PoolTimeout => crate::ERROR_CODE_POSTGRES_POOL_TIMEOUT, + ksp_store_postgres_lib::PostgresBackendErrorKind::MigrationFailed => crate::ERROR_CODE_POSTGRES_MIGRATION_FAILED, + ksp_store_postgres_lib::PostgresBackendErrorKind::MigrationMismatch => crate::ERROR_CODE_POSTGRES_MIGRATION_MISMATCH, + ksp_store_postgres_lib::PostgresBackendErrorKind::SchemaNewer => crate::ERROR_CODE_POSTGRES_SCHEMA_NEWER, ksp_store_postgres_lib::PostgresBackendErrorKind::ShutdownTimeout => crate::ERROR_CODE_SHUTDOWN_TIMEOUT, ksp_store_postgres_lib::PostgresBackendErrorKind::TlsFailed => crate::ERROR_CODE_POSTGRES_TLS_FAILED, _ => crate::ERROR_CODE_BACKEND_OPEN_FAILED, diff --git a/crates/ksp-store-lib/tests/public_api.rs b/crates/ksp-store-lib/tests/public_api.rs index 7d5a09b..c58a900 100644 --- a/crates/ksp-store-lib/tests/public_api.rs +++ b/crates/ksp-store-lib/tests/public_api.rs @@ -1,5 +1,5 @@ // file: crates/ksp-store-lib/tests/public_api.rs -// version: 3 +// version: 4 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -37,6 +37,9 @@ fn pre_005_common_and_postgres_error_codes_are_stable_and_store_owned() { assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_CONFIG_INVALID.code(), "postgres_config_invalid"); assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_CONNECT_FAILED.code(), "postgres_connect_failed"); assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_POOL_TIMEOUT.code(), "postgres_pool_timeout"); + assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_MIGRATION_FAILED.code(), "postgres_migration_failed"); + assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_MIGRATION_MISMATCH.code(), "postgres_migration_mismatch"); + assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_SCHEMA_NEWER.code(), "postgres_schema_newer"); assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_TLS_FAILED.code(), "postgres_tls_failed"); assert_eq!(ksp_store_lib::ERROR_CODE_BACKEND_CLOSED.code(), "backend_closed"); assert_eq!(ksp_store_lib::ERROR_CODE_SHUTDOWN_TIMEOUT.code(), "shutdown_timeout"); diff --git a/crates/ksp-store-postgres-lib/Cargo.toml b/crates/ksp-store-postgres-lib/Cargo.toml index e48a602..5f8f1f6 100644 --- a/crates/ksp-store-postgres-lib/Cargo.toml +++ b/crates/ksp-store-postgres-lib/Cargo.toml @@ -1,5 +1,5 @@ # file: crates/ksp-store-postgres-lib/Cargo.toml -# version: 2 +# version: 3 [package] name = "ksp-store-postgres-lib" @@ -13,6 +13,7 @@ ksp-logging-lib = { path = "../ksp-logging-lib" } ksp-store-api = { path = "../ksp-store-api" } rustls = { workspace = true, features = ["aws_lc_rs", "std", "tls12"] } rustls-native-certs.workspace = true +sha2.workspace = true tokio = { workspace = true, features = ["rt", "time"] } tokio-postgres = { workspace = true, features = ["runtime"] } tokio-postgres-rustls = { workspace = true, features = ["aws-lc-rs"] } diff --git a/crates/ksp-store-postgres-lib/migrations/V000__bootstrap.sql b/crates/ksp-store-postgres-lib/migrations/V000__bootstrap.sql new file mode 100644 index 0000000..3fe2001 --- /dev/null +++ b/crates/ksp-store-postgres-lib/migrations/V000__bootstrap.sql @@ -0,0 +1,6 @@ +CREATE TABLE ksp_store_schema_migrations ( + version BIGINT PRIMARY KEY, + name TEXT NOT NULL, + checksum TEXT NOT NULL, + applied_at TIMESTAMPTZ NOT NULL +); diff --git a/crates/ksp-store-postgres-lib/src/error.rs b/crates/ksp-store-postgres-lib/src/error.rs index 13c6871..052f05f 100644 --- a/crates/ksp-store-postgres-lib/src/error.rs +++ b/crates/ksp-store-postgres-lib/src/error.rs @@ -1,5 +1,5 @@ // file: crates/ksp-store-postgres-lib/src/error.rs -// version: 1 +// version: 2 /// Safe backend-local classification used by the Store facade for stable error mapping. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -11,6 +11,12 @@ pub enum PostgresBackendErrorKind { ConnectFailed, /// A bounded pool wait, create or recycle operation reached its deadline. PoolTimeout, + /// PostgreSQL migration/bootstrap execution failed without exposing server text or SQL. + MigrationFailed, + /// Applied PostgreSQL migration history diverges from the embedded immutable KSP history. + MigrationMismatch, + /// The database schema history contains a migration newer than this runtime understands. + SchemaNewer, /// Explicit backend shutdown did not drain inside the supplied deadline. ShutdownTimeout, /// Verified TLS configuration or negotiation could not be established. diff --git a/crates/ksp-store-postgres-lib/src/lib.rs b/crates/ksp-store-postgres-lib/src/lib.rs index f39bc8d..17bd5f6 100644 --- a/crates/ksp-store-postgres-lib/src/lib.rs +++ b/crates/ksp-store-postgres-lib/src/lib.rs @@ -1,5 +1,5 @@ // file: crates/ksp-store-postgres-lib/src/lib.rs -// version: 3 +// version: 4 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -7,9 +7,9 @@ //! Official PostgreSQL backend implementation for KSP Store. //! -//! `0.3.2-pre.005` owns the physical `tokio-postgres` connection, bounded -//! Deadpool pool and explicit Rustls TLS policy. SQL migrations and business -//! persistence remain absent until their dedicated prereleases. +//! `0.3.2-pre.006` owns the physical `tokio-postgres` connection, bounded +//! Deadpool pool, explicit Rustls TLS policy and private KSP migration/bootstrap +//! engine. Business persistence remains absent from this foundation release. //! //! This crate depends on `ksp-store-api` and never on `ksp-store-lib`. The //! common facade consumes only this crate's narrow backend bridge and never @@ -17,6 +17,7 @@ mod constants; mod error; +mod migration; mod runtime; /// Safe backend-local error returned to the common Store facade. @@ -32,5 +33,7 @@ pub use self::runtime::PostgresBackendTlsMode; /// Crate-owned tracing target for PostgreSQL backend behavior. pub(crate) use self::constants::TRACING_TARGET; +/// Private migration/bootstrap runner consumed by the physical backend runtime. +pub(crate) use self::migration::bootstrap; const _: &str = crate::TRACING_TARGET; diff --git a/crates/ksp-store-postgres-lib/src/migration.rs b/crates/ksp-store-postgres-lib/src/migration.rs new file mode 100644 index 0000000..5ee7ef5 --- /dev/null +++ b/crates/ksp-store-postgres-lib/src/migration.rs @@ -0,0 +1,312 @@ +// file: crates/ksp-store-postgres-lib/src/migration.rs +// version: 1 + +use sha2::Digest; // rust-rules: trait-import + +const ADVISORY_LOCK_KEY: i64 = 0x4b53_5053_544f_5245; +const BOOTSTRAP_MIGRATION_NAME: &str = "bootstrap"; +const BOOTSTRAP_MIGRATION_SQL: &str = include_str!("../migrations/V000__bootstrap.sql"); +const BOOTSTRAP_MIGRATION_VERSION: i64 = 0; +const HEX_LOWER: &[u8; 16] = b"0123456789abcdef"; +const HISTORY_INSERT_SQL: &str = "INSERT INTO ksp_store_schema_migrations (version, name, checksum, applied_at) VALUES ($1, $2, $3, CURRENT_TIMESTAMP)"; +const HISTORY_LOAD_SQL: &str = "SELECT version, name, checksum FROM ksp_store_schema_migrations ORDER BY version"; +const LOCK_POLL_INTERVAL_MS: u64 = 25; +const METADATA_EXISTS_SQL: &str = r#"SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = current_schema() + AND table_name = 'ksp_store_schema_migrations' + AND table_type = 'BASE TABLE' +)"#; +const METADATA_PRIMARY_KEY_SQL: &str = r#"SELECT COUNT(*)::BIGINT, + COUNT(*) FILTER (WHERE kcu.column_name = 'version')::BIGINT +FROM information_schema.table_constraints tc +JOIN information_schema.key_column_usage kcu + ON tc.constraint_catalog = kcu.constraint_catalog + AND tc.constraint_schema = kcu.constraint_schema + AND tc.constraint_name = kcu.constraint_name +WHERE tc.table_schema = current_schema() + AND tc.table_name = 'ksp_store_schema_migrations' + AND tc.constraint_type = 'PRIMARY KEY'"#; +const METADATA_SHAPE_SQL: &str = r#"SELECT column_name, data_type, is_nullable +FROM information_schema.columns +WHERE table_schema = current_schema() + AND table_name = 'ksp_store_schema_migrations' +ORDER BY ordinal_position"#; +const SET_STATEMENT_TIMEOUT_SQL: &str = "SELECT set_config('statement_timeout', $1, true)"; + +struct AppliedMigration { + checksum: std::string::String, + name: std::string::String, + version: i64, +} + +/// Runs the private bounded PostgreSQL schema bootstrap on one dedicated pooled client. +pub(crate) async fn bootstrap( + client: &mut deadpool_postgres::Client, + auto_migrate: bool, + migration_timeout: std::time::Duration, + migration_lock_timeout: std::time::Duration, +) -> std::result::Result<(), crate::PostgresBackendError> { + let bounded = tokio::time::timeout(migration_timeout, bootstrap_inner(client, auto_migrate, migration_timeout, migration_lock_timeout)).await; + return match bounded { + std::result::Result::Ok(result) => result, + std::result::Result::Err(_) => { + std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "migration_timeout")) + }, + }; +} + +async fn bootstrap_inner( + client: &mut deadpool_postgres::Client, + auto_migrate: bool, + migration_timeout: std::time::Duration, + migration_lock_timeout: std::time::Duration, +) -> std::result::Result<(), crate::PostgresBackendError> { + let transaction_result = client.transaction().await; + let transaction = match transaction_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "migration_begin")); + }, + }; + let lock_result = acquire_advisory_lock(&transaction, migration_lock_timeout).await; + if let std::result::Result::Err(error) = lock_result { + return std::result::Result::Err(error); + } + let timeout_result = set_statement_timeout(&transaction, migration_timeout).await; + if let std::result::Result::Err(error) = timeout_result { + return std::result::Result::Err(error); + } + let exists_result = metadata_exists(&transaction).await; + let metadata_exists = match exists_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let checksum = bootstrap_checksum(); + if !metadata_exists { + if !auto_migrate { + return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "migration_pending")); + } + let create_result = transaction.batch_execute(BOOTSTRAP_MIGRATION_SQL).await; + if create_result.is_err() { + return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "metadata_create")); + } + let shape_result = verify_metadata_shape(&transaction).await; + if let std::result::Result::Err(error) = shape_result { + return std::result::Result::Err(error); + } + let insert_result = transaction.execute(HISTORY_INSERT_SQL, &[&BOOTSTRAP_MIGRATION_VERSION, &BOOTSTRAP_MIGRATION_NAME, &checksum]).await; + match insert_result { + std::result::Result::Ok(1) => {}, + std::result::Result::Ok(_) | std::result::Result::Err(_) => { + return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "history_insert")); + }, + } + } else { + let shape_result = verify_metadata_shape(&transaction).await; + if let std::result::Result::Err(error) = shape_result { + return std::result::Result::Err(error); + } + let history_result = load_history(&transaction).await; + let history = match history_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let validation_result = validate_history(history.as_slice(), checksum.as_str()); + if let std::result::Result::Err(error) = validation_result { + return std::result::Result::Err(error); + } + } + let commit_result = transaction.commit().await; + return match commit_result { + std::result::Result::Ok(()) => std::result::Result::Ok(()), + std::result::Result::Err(_) => { + std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "migration_commit")) + }, + }; +} + +async fn acquire_advisory_lock( + transaction: &deadpool_postgres::Transaction<'_>, + timeout: std::time::Duration, +) -> std::result::Result<(), crate::PostgresBackendError> { + let started = tokio::time::Instant::now(); + let deadline = match started.checked_add(timeout) { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "migration_lock_timeout")); + }, + }; + loop { + let row_result = transaction.query_one("SELECT pg_try_advisory_xact_lock($1)", &[&ADVISORY_LOCK_KEY]).await; + let row = match row_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "migration_lock")); + }, + }; + let acquired_result = row.try_get::(0); + let acquired = match acquired_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "migration_lock_decode")); + }, + }; + if acquired { + return std::result::Result::Ok(()); + } + let now = tokio::time::Instant::now(); + if now >= deadline { + return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "migration_lock_timeout")); + } + let candidate = now + std::time::Duration::from_millis(LOCK_POLL_INTERVAL_MS); + let wake = if candidate < deadline { candidate } else { deadline }; + tokio::time::sleep_until(wake).await; + } +} + +async fn set_statement_timeout( + transaction: &deadpool_postgres::Transaction<'_>, + timeout: std::time::Duration, +) -> std::result::Result<(), crate::PostgresBackendError> { + let timeout_value = format!("{}ms", timeout.as_millis()); + let result = transaction.query_one(SET_STATEMENT_TIMEOUT_SQL, &[&timeout_value]).await; + return match result { + std::result::Result::Ok(_) => std::result::Result::Ok(()), + std::result::Result::Err(_) => { + std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "statement_timeout")) + }, + }; +} + +async fn metadata_exists(transaction: &deadpool_postgres::Transaction<'_>) -> std::result::Result { + let result = transaction.query_one(METADATA_EXISTS_SQL, &[]).await; + let row = match result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "metadata_probe")); + }, + }; + return match row.try_get::(0) { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(_) => { + std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "metadata_probe_decode")) + }, + }; +} + +async fn verify_metadata_shape(transaction: &deadpool_postgres::Transaction<'_>) -> std::result::Result<(), crate::PostgresBackendError> { + let result = transaction.query(METADATA_SHAPE_SQL, &[]).await; + let rows = match result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "metadata_shape")); + }, + }; + let expected = [("version", "bigint", "NO"), ("name", "text", "NO"), ("checksum", "text", "NO"), ("applied_at", "timestamp with time zone", "NO")]; + let mut found = [false; 4]; + for row in rows { + let column_result = row.try_get::(0); + let data_type_result = row.try_get::(1); + let nullable_result = row.try_get::(2); + let (column, data_type, nullable) = match (column_result, data_type_result, nullable_result) { + (std::result::Result::Ok(column), std::result::Result::Ok(data_type), std::result::Result::Ok(nullable)) => (column, data_type, nullable), + _ => { + return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "metadata_shape_decode")); + }, + }; + for (index, expected_row) in expected.iter().enumerate() { + if column == expected_row.0 { + if found[index] || data_type != expected_row.1 || nullable != expected_row.2 { + return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "metadata_shape")); + } + found[index] = true; + break; + } + } + } + for required in found { + if !required { + return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "metadata_shape")); + } + } + let key_result = transaction.query_one(METADATA_PRIMARY_KEY_SQL, &[]).await; + let key_row = match key_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "metadata_primary_key")); + }, + }; + let key_count = key_row.try_get::(0); + let version_count = key_row.try_get::(1); + return match (key_count, version_count) { + (std::result::Result::Ok(1), std::result::Result::Ok(1)) => std::result::Result::Ok(()), + (std::result::Result::Ok(_), std::result::Result::Ok(_)) => { + std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "metadata_primary_key")) + }, + _ => std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "metadata_primary_key_decode")), + }; +} + +async fn load_history(transaction: &deadpool_postgres::Transaction<'_>) -> std::result::Result, crate::PostgresBackendError> { + let result = transaction.query(HISTORY_LOAD_SQL, &[]).await; + let rows = match result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "history_load")); + }, + }; + let mut history = std::vec::Vec::with_capacity(rows.len()); + for row in rows { + let version_result = row.try_get::(0); + let name_result = row.try_get::(1); + let checksum_result = row.try_get::(2); + match (version_result, name_result, checksum_result) { + (std::result::Result::Ok(version), std::result::Result::Ok(name), std::result::Result::Ok(checksum)) => { + history.push(AppliedMigration { checksum, name, version }); + }, + _ => { + return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "history_decode")); + }, + } + } + return std::result::Result::Ok(history); +} + +fn validate_history(history: &[AppliedMigration], expected_checksum: &str) -> std::result::Result<(), crate::PostgresBackendError> { + let mut sentinel_found = false; + for applied in history { + if applied.version > BOOTSTRAP_MIGRATION_VERSION { + return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::SchemaNewer, "history_newer")); + } + if applied.version < BOOTSTRAP_MIGRATION_VERSION { + return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "history_unknown")); + } + if applied.name != BOOTSTRAP_MIGRATION_NAME || applied.checksum != expected_checksum { + return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "history_diverged")); + } + sentinel_found = true; + } + if !sentinel_found { + return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "history_missing")); + } + return std::result::Result::Ok(()); +} + +fn bootstrap_checksum() -> std::string::String { + let mut hasher = sha2::Sha256::new(); + hasher.update(BOOTSTRAP_MIGRATION_SQL.as_bytes()); + let digest = hasher.finalize(); + let bytes = digest.as_slice(); + let mut encoded = std::string::String::with_capacity(bytes.len() * 2); + for byte in bytes { + let value = *byte; + encoded.push(char::from(HEX_LOWER[(value >> 4) as usize])); + encoded.push(char::from(HEX_LOWER[(value & 0x0f) as usize])); + } + return encoded; +} + +#[cfg(test)] +#[path = "../unit_tests/migration.rs"] +mod tests; diff --git a/crates/ksp-store-postgres-lib/src/runtime.rs b/crates/ksp-store-postgres-lib/src/runtime.rs index 1106c52..389ff44 100644 --- a/crates/ksp-store-postgres-lib/src/runtime.rs +++ b/crates/ksp-store-postgres-lib/src/runtime.rs @@ -1,5 +1,5 @@ // file: crates/ksp-store-postgres-lib/src/runtime.rs -// version: 1 +// version: 2 const APPLICATION_NAME: &str = "ksp-store"; const MAX_CONNECTION_URI_BYTES: usize = 4_096; @@ -31,7 +31,10 @@ pub struct PostgresBackendSettings { connect_timeout: std::time::Duration, connection_uri: std::string::String, create_timeout: std::time::Duration, + auto_migrate: bool, max_connections: u32, + migration_lock_timeout: std::time::Duration, + migration_timeout: std::time::Duration, network: ksp_store_api::RawNetworkId, recycle_timeout: std::time::Duration, tls_mode: PostgresBackendTlsMode, @@ -50,12 +53,18 @@ impl PostgresBackendSettings { create_timeout: std::time::Duration, recycle_timeout: std::time::Duration, tls_mode: PostgresBackendTlsMode, + auto_migrate: bool, + migration_timeout: std::time::Duration, + migration_lock_timeout: std::time::Duration, ) -> Self { return Self { + auto_migrate, connect_timeout, connection_uri: connection_uri.into(), create_timeout, max_connections, + migration_lock_timeout, + migration_timeout, network, recycle_timeout, tls_mode, @@ -82,7 +91,10 @@ impl std::fmt::Debug for PostgresBackendSettings { .debug_struct("PostgresBackendSettings") .field("network", &self.network) .field("connection_uri", &"") + .field("auto_migrate", &self.auto_migrate) .field("max_connections", &self.max_connections) + .field("migration_timeout", &self.migration_timeout) + .field("migration_lock_timeout", &self.migration_lock_timeout) .field("connect_timeout", &self.connect_timeout) .field("wait_timeout", &self.wait_timeout) .field("create_timeout", &self.create_timeout) @@ -129,16 +141,27 @@ impl PostgresBackend { std::result::Result::Err(error) => return std::result::Result::Err(error), }; let probe = pool.get().await; - match probe { - std::result::Result::Ok(client) => drop(client), + let mut client = match probe { + std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(map_pool_error(error)), - } + }; ksp_logging_lib::debug!( target: crate::TRACING_TARGET, network = settings.network().as_str(), tls_mode = settings.tls_mode().code(), "PostgreSQL Store backend established initial physical connection" ); + let bootstrap_result = crate::bootstrap(&mut client, settings.auto_migrate, settings.migration_timeout, settings.migration_lock_timeout).await; + if let std::result::Result::Err(error) = bootstrap_result { + return std::result::Result::Err(error); + } + drop(client); + ksp_logging_lib::debug!( + target: crate::TRACING_TARGET, + network = settings.network().as_str(), + auto_migrate = settings.auto_migrate, + "PostgreSQL Store migration/bootstrap foundation verified" + ); return std::result::Result::Ok(Self { network: settings.network, pool }); } diff --git a/crates/ksp-store-postgres-lib/tests/dependency_boundary.rs b/crates/ksp-store-postgres-lib/tests/dependency_boundary.rs index 107d327..eb00354 100644 --- a/crates/ksp-store-postgres-lib/tests/dependency_boundary.rs +++ b/crates/ksp-store-postgres-lib/tests/dependency_boundary.rs @@ -1,5 +1,5 @@ // file: crates/ksp-store-postgres-lib/tests/dependency_boundary.rs -// version: 3 +// version: 4 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -10,12 +10,22 @@ #[test] fn pre_005_backend_owns_exact_physical_runtime_dependencies_without_reverse_facade_edge() { let manifest = include_str!("../Cargo.toml"); - for required in ["deadpool-postgres", "ksp-logging-lib", "ksp-store-api", "rustls", "rustls-native-certs", "tokio-postgres", "tokio-postgres-rustls"] { + for required in + ["deadpool-postgres", "ksp-logging-lib", "ksp-store-api", "rustls", "rustls-native-certs", "sha2", "tokio-postgres", "tokio-postgres-rustls"] + { assert!(manifest.contains(required), "missing PostgreSQL backend dependency: {required}"); } for forbidden in ["ksp-store-lib", "ksp-config-lib", "ksp-materializer", "ksp-program", "ksp-onchain-transport-lib", "ksp-offchain-transport-lib", "sqlx"] { assert!(!manifest.contains(forbidden), "forbidden PostgreSQL backend dependency detected: {forbidden}"); } + let migration = include_str!("../src/migration.rs"); + let bootstrap_sql = include_str!("../migrations/V000__bootstrap.sql"); + assert!(migration.contains("include_str!(\"../migrations/V000__bootstrap.sql\")")); + assert!(bootstrap_sql.contains("ksp_store_schema_migrations")); + for forbidden in ["RawTransaction", "RawAccountState", "raw_transaction", "raw_account", "CORE", "DECODE", "SPECIALIZED"] { + assert!(!migration.contains(forbidden), "business migration implementation leaked into foundation: {forbidden}"); + assert!(!bootstrap_sql.contains(forbidden), "business schema leaked into foundation SQL: {forbidden}"); + } return; } @@ -23,6 +33,7 @@ fn pre_005_backend_owns_exact_physical_runtime_dependencies_without_reverse_faca fn pre_005_backend_keeps_environment_sql_migrations_and_physical_types_private() { let crate_root = include_str!("../src/lib.rs"); assert!(crate_root.contains("mod error;")); + assert!(crate_root.contains("mod migration;")); assert!(crate_root.contains("mod runtime;")); assert!(crate_root.contains("const _: &str = crate::TRACING_TARGET;")); for forbidden in [ @@ -52,7 +63,6 @@ fn pre_005_backend_keeps_environment_sql_migrations_and_physical_types_private() "UPDATE ", "DELETE FROM", "SELECT ", - "mod migration", ] { assert!(!runtime.contains(forbidden), "forbidden PostgreSQL backend ownership/scope content detected: {forbidden}"); } diff --git a/crates/ksp-store-postgres-lib/tests/public_api.rs b/crates/ksp-store-postgres-lib/tests/public_api.rs index 667fada..751ddf4 100644 --- a/crates/ksp-store-postgres-lib/tests/public_api.rs +++ b/crates/ksp-store-postgres-lib/tests/public_api.rs @@ -1,5 +1,5 @@ // file: crates/ksp-store-postgres-lib/tests/public_api.rs -// version: 1 +// version: 2 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -22,6 +22,9 @@ fn pre_005_backend_bridge_is_constructible_without_io() { std::time::Duration::from_secs(10), std::time::Duration::from_secs(5), ksp_store_postgres_lib::PostgresBackendTlsMode::VerifyFull, + true, + std::time::Duration::from_secs(30), + std::time::Duration::from_secs(10), ); assert_eq!(settings.network().as_str(), "devnet"); assert_eq!(settings.tls_mode().code(), "verify_full"); @@ -36,9 +39,12 @@ fn pre_005_backend_error_projection_is_safe_and_static() { ksp_store_postgres_lib::PostgresBackendErrorKind::ConfigInvalid, ksp_store_postgres_lib::PostgresBackendErrorKind::ConnectFailed, ksp_store_postgres_lib::PostgresBackendErrorKind::PoolTimeout, + ksp_store_postgres_lib::PostgresBackendErrorKind::MigrationFailed, + ksp_store_postgres_lib::PostgresBackendErrorKind::MigrationMismatch, + ksp_store_postgres_lib::PostgresBackendErrorKind::SchemaNewer, ksp_store_postgres_lib::PostgresBackendErrorKind::ShutdownTimeout, ksp_store_postgres_lib::PostgresBackendErrorKind::TlsFailed, ]; - assert_eq!(kinds.len(), 5); + assert_eq!(kinds.len(), 8); return; } diff --git a/crates/ksp-store-postgres-lib/unit_tests/migration.rs b/crates/ksp-store-postgres-lib/unit_tests/migration.rs new file mode 100644 index 0000000..abacc06 --- /dev/null +++ b/crates/ksp-store-postgres-lib/unit_tests/migration.rs @@ -0,0 +1,50 @@ +// file: crates/ksp-store-postgres-lib/unit_tests/migration.rs +// version: 1 + +fn applied(version: i64, name: &str, checksum: &str) -> super::AppliedMigration { + return super::AppliedMigration { checksum: checksum.to_owned(), name: name.to_owned(), version }; +} + +#[test] +fn bootstrap_migration_is_static_metadata_only_and_checksum_is_stable_sha256() { + assert_eq!(super::BOOTSTRAP_MIGRATION_VERSION, 0); + assert_eq!(super::BOOTSTRAP_MIGRATION_NAME, "bootstrap"); + assert!(super::BOOTSTRAP_MIGRATION_SQL.contains("CREATE TABLE ksp_store_schema_migrations")); + for forbidden in ["RawTransaction", "RawAccountState", "raw_transaction", "raw_account", "CORE", "DECODE", "SPECIALIZED"] { + assert!(!super::BOOTSTRAP_MIGRATION_SQL.contains(forbidden), "business schema leaked into bootstrap SQL: {forbidden}"); + } + let checksum = super::bootstrap_checksum(); + assert_eq!(checksum.len(), 64); + assert_eq!(checksum, "d29068b8c13b9dc0cc9ef6aaadd0fa12d41e0fe4c56541a1118c4bfc846a1450"); + return; +} + +#[test] +fn matching_sentinel_history_is_accepted() { + let checksum = super::bootstrap_checksum(); + let history = [applied(0, "bootstrap", checksum.as_str())]; + assert!(super::validate_history(&history, checksum.as_str()).is_ok()); + return; +} + +#[test] +fn divergent_or_missing_sentinel_is_terminal_mismatch() { + let checksum = super::bootstrap_checksum(); + let wrong_name = [applied(0, "changed", checksum.as_str())]; + let wrong_checksum = [applied(0, "bootstrap", "00")]; + let missing: [super::AppliedMigration; 0] = []; + for history in [&wrong_name[..], &wrong_checksum[..], &missing[..]] { + let result = super::validate_history(history, checksum.as_str()); + assert_eq!(result.err().map(|value| return value.kind()), std::option::Option::Some(crate::PostgresBackendErrorKind::MigrationMismatch)); + } + return; +} + +#[test] +fn newer_history_is_rejected_without_down_migration() { + let checksum = super::bootstrap_checksum(); + let history = [applied(0, "bootstrap", checksum.as_str()), applied(1, "future", "future-checksum")]; + let result = super::validate_history(&history, checksum.as_str()); + assert_eq!(result.err().map(|value| return value.kind()), std::option::Option::Some(crate::PostgresBackendErrorKind::SchemaNewer)); + return; +} diff --git a/crates/ksp-store-postgres-lib/unit_tests/runtime.rs b/crates/ksp-store-postgres-lib/unit_tests/runtime.rs index a5598cb..77bebdd 100644 --- a/crates/ksp-store-postgres-lib/unit_tests/runtime.rs +++ b/crates/ksp-store-postgres-lib/unit_tests/runtime.rs @@ -1,5 +1,5 @@ // file: crates/ksp-store-postgres-lib/unit_tests/runtime.rs -// version: 2 +// version: 3 fn network() -> ksp_store_api::RawNetworkId { return match ksp_store_api::RawNetworkId::new("devnet") { @@ -18,6 +18,9 @@ fn settings(connection_uri: &str, tls_mode: crate::PostgresBackendTlsMode) -> cr std::time::Duration::from_secs(10), std::time::Duration::from_secs(5), tls_mode, + true, + std::time::Duration::from_secs(30), + std::time::Duration::from_secs(10), ); } diff --git a/deltas/0.3.2/pre.006.md b/deltas/0.3.2/pre.006.md new file mode 100644 index 0000000..d6d408f --- /dev/null +++ b/deltas/0.3.2/pre.006.md @@ -0,0 +1,143 @@ + + + +# Delta `0.3.2-pre.006` — migration/bootstrap PostgreSQL foundation + +## 1. Base + +Base exacte : `0.3.2-pre.005-fix.001`. + +Le gate opérateur du 29 août 2026 est entièrement vert : audits Rust/Markdown, workspace check/Clippy, tests `ksp-store-postgres-lib`, tests `ksp-store-lib` avec et sans feature PostgreSQL et compilation `--no-default-features` passent. Les versions physiques réellement résolues observées sont `tokio-postgres 0.7.18`, `deadpool-postgres 0.14.2` et `tokio-postgres-rustls 0.14.0`. + +## 2. Objet + +Matérialiser le moteur privé de migration/bootstrap de la fondation PostgreSQL, sans aucune persistence métier : + +```text +ksp_store_schema_migrations uniquement +sentinel version 0 +nom bootstrap immuable +SHA-256 du SQL exact +advisory transaction lock borné +statement timeout transaction-local +transaction unique +validation metadata/history +newer-runtime guard +rollback du run courant +``` + +## 3. Dépendance SHA-256 + +`sha2 ^0.11` devient une dépendance workspace explicite et une dépendance directe de `ksp-store-postgres-lib`, car le backend calcule lui-même le checksum persistant. + +Le digest SHA-256 est encodé explicitement en 64 caractères hex minuscules ; aucune dépendance de codec hex supplémentaire n'est ajoutée. + +## 4. Migration bootstrap embarquée + +La seule ressource SQL de production ajoutée est : + +```text +crates/ksp-store-postgres-lib/migrations/V000__bootstrap.sql +``` + +Elle crée uniquement : + +```text +ksp_store_schema_migrations +``` + +avec les champs minimaux : + +```text +version BIGINT PRIMARY KEY +name TEXT NOT NULL +checksum TEXT NOT NULL +applied_at TIMESTAMPTZ NOT NULL +``` + +Aucune table/index RAW, CORE, DECODE ou SPECIALIZED n'est introduit. + +## 5. Algorithme runtime + +Après la première acquisition physique déjà introduite en `pre.005`, le backend conserve ce client dédié et exécute : + +```text +begin transaction +pg_try_advisory_xact_lock($1) en polling borné +set_config('statement_timeout', $1, true) +probe metadata +si absente et auto_migrate=false -> migration_pending sans DDL +si absente et auto_migrate=true -> V000 + sentinel dans la même transaction +si présente -> vérification forme minimale + PK version +load history ordered +missing/divergence -> migration mismatch +version > 0 -> schema newer +commit unique +``` + +L'ensemble du bootstrap est aussi borné par `migration_timeout`. Une sortie avant `commit()` droppe la transaction ; le rollback PostgreSQL implicite protège DDL et history du run courant. + +Les colonnes obligatoires de metadata sont vérifiées sans interdire des colonnes supplémentaires futures. Cela permet à un ancien runtime de lire une history plus récente et de produire `schema_newer` plutôt qu'un faux mismatch de forme. + +## 6. Erreurs façade + +Le bridge backend ajoute : + +```text +MigrationFailed +MigrationMismatch +SchemaNewer +``` + +La façade mappe vers : + +```text +store.postgres_migration_failed +store.postgres_migration_mismatch +store.postgres_schema_newer +``` + +Aucun texte serveur, SQL, URI ou credential n'est propagé. + +## 7. Tests/canaris + +Les tests unitaires sans serveur couvrent : + +```text +checksum exact/stable du V000 +absence de schéma métier dans le SQL +sentinel valide +sentinel missing +nom/checksum divergent +history plus récente +bridge backend étendu +frontière dépendances + sha2 direct +``` + +La concurrence réelle, l'idempotence réelle et le rollback injecté restent au test PostgreSQL opt-in de `pre.008`. + +## 8. Version + +```text +workspace.package.version = 0.3.2-pre.6 +``` + +## 9. Gate opérateur + +```bash +cargo fmt --all +python3 scripts/audit_rust_workspace_rules.py +python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.3.2 +cargo check --workspace +cargo clippy --workspace --all-targets +cargo test -p ksp-store-postgres-lib +cargo test -p ksp-store-lib +cargo test -p ksp-store-lib --no-default-features +cargo check -p ksp-store-lib --no-default-features +cargo tree -p ksp-store-postgres-lib --edges normal +cargo tree -p ksp-store-lib --edges normal +``` + +## 10. Suite + +Après gate propre, `pre.007` ferme la composition end-to-end et ajoute la projection health/readiness sûre, sans ouvrir encore les tables RAW métier. diff --git a/docs/plans/023-V0_3_2_STORE_POSTGRES_FOUNDATION_PLAN.md b/docs/plans/023-V0_3_2_STORE_POSTGRES_FOUNDATION_PLAN.md index 169f371..b90bb38 100644 --- a/docs/plans/023-V0_3_2_STORE_POSTGRES_FOUNDATION_PLAN.md +++ b/docs/plans/023-V0_3_2_STORE_POSTGRES_FOUNDATION_PLAN.md @@ -1,5 +1,5 @@ - + # Plan `0.3.2` — Store/PostgreSQL runtime foundation @@ -942,7 +942,7 @@ Matérialiser `StoreSettings`, `StoreBackendSettings`, `PostgresStoreSettings`, **Statut : matérialisé par `0.3.2-pre.003`, gate opérateur requis.** -La tranche fixe : +La tranche fixe initialement : ```text 73 exports crate-root de ksp-store-lib : 60 reexports ksp-store-api + 13 éléments runtime Store @@ -980,9 +980,34 @@ ksp-store-postgres-lib Après parsing, la policy KSP réécrit `application_name`, `connect_timeout`, `sslnegotiation` et `sslmode`; les `options=` serveur sont rejetées. `Disabled` force le plaintext, `VerifyFull` force TLS + roots système + identité serveur et interdit un target `hostaddr` sans `host` ainsi que les sockets Unix, sur lesquels PostgreSQL ne négocie pas TLS. Le pool applique `max_size` et deadlines `wait/create/recycle`, utilise le recycling `Verified`, et `Store::open` ne réussit qu'après une première acquisition physique. Les erreurs externes ne sont jamais conservées comme source/texte public. `Store::close` ferme et draine sous deadline; `Drop` reste best-effort. Aucun SQL KSP, migration ou table métier n'est introduit. +Le gate opérateur de `pre.005-fix.001` est vert : audits, workspace check/Clippy, tests backend, façade avec et sans feature PostgreSQL et compilation `--no-default-features` passent. Les versions résolues observées sont `tokio-postgres 0.7.18`, `deadpool-postgres 0.14.2` et `tokio-postgres-rustls 0.14.0`. + ### `pre.006` — Migration/bootstrap foundation -Créer history metadata, sentinel, SHA-256, advisory lock borné, transaction, version/checksum/newer/missing handling et rollback. Aucun schéma RAW. +Statut : matérialisé par `0.3.2-pre.006`, gate Cargo opérateur à exécuter. + +La tranche introduit un moteur privé `ksp-store-postgres-lib` sans crate de migration externe : + +```text +sha2 0.11.x explicite +V000__bootstrap.sql embarqué par include_str! +ksp_store_schema_migrations uniquement +version 0 / nom bootstrap / SHA-256 exact du SQL +transaction unique +pg_try_advisory_xact_lock($1) KSP fixe sous deadline +statement_timeout transaction-local via set_config(..., true) +forme metadata minimale vérifiée +history divergente/missing -> migration mismatch +version appliquée > 0 -> schema newer +aucun down migration +aucun schéma RAW +``` + +`auto_migrate = false` ne crée jamais la metadata absente : l'ouverture échoue avec un état `migration_pending` sûr. Si la metadata existe, elle est toujours vérifiée. Une table valide mais sans sentinel n'est jamais réparée implicitement : elle est considérée divergente. Les colonnes metadata obligatoires sont vérifiées sans interdire d'éventuelles colonnes supplémentaires futures, afin qu'un ancien runtime puisse encore lire l'historique puis classer correctement une version plus récente comme `schema_newer`. + +Le bootstrap version `0` est spécial : lorsque la metadata est absente et l'auto-migration autorisée, le DDL embarqué est exécuté puis le sentinel est inséré dans la même transaction. Toute erreur ou expiration fait abandonner la transaction ; le rollback PostgreSQL implicite au drop protège DDL + history du run courant. Les preuves concurrentes et rollback injecté sur serveur réel restent à `pre.008`. + +Avec les quatre codes PostgreSQL ajoutés en `pre.005` puis les trois codes migration de `pre.006`, la façade atteint désormais 80 exports crate-root : 60 réexports `ksp-store-api` et 20 éléments runtime Store. ### `pre.007` — Composition end-to-end + health diff --git a/docs/validation/019-V0_3_2_STORE_POSTGRES_FOUNDATION.md b/docs/validation/019-V0_3_2_STORE_POSTGRES_FOUNDATION.md index a06f9c7..4a76081 100644 --- a/docs/validation/019-V0_3_2_STORE_POSTGRES_FOUNDATION.md +++ b/docs/validation/019-V0_3_2_STORE_POSTGRES_FOUNDATION.md @@ -1,5 +1,5 @@ - + # Validation `0.3.2` — Store/PostgreSQL runtime foundation @@ -60,7 +60,7 @@ Référence du gate : ```text PostgreSQL 18.6 tokio-postgres 0.7.18 -deadpool-postgres 0.14.1 +deadpool-postgres 0.14.2 tokio-postgres-rustls 0.14.0 sha2 0.11.0 refinery 0.9.2 audité/rejeté @@ -76,6 +76,8 @@ Le gate opérateur de `pre.003-fix.001`, fourni le 29 août 2026, est entièreme Le gate opérateur de `pre.004-fix.001`, après correction manuelle des commentaires `.env.example`, est entièrement vert : audits Rust/Markdown, workspace check/Clippy, 126 tests `ksp-config-lib`, ownership, `ksp-store-lib` avec et sans feature PostgreSQL, compilation `--no-default-features` et 63 tests Config Desk passent. Cette base est l'entrée effective de `pre.005`. +Le gate opérateur de `pre.005-fix.001`, fourni le 29 août 2026, est entièrement vert : audits Rust/Markdown, workspace check/Clippy, 5 tests runtime backend, canaris de dépendances/API, façade avec et sans feature PostgreSQL et compilation `--no-default-features` passent. Cette base est l'entrée effective de `pre.006`. + ## 3. Frontières Cargo ### V32-DEP-001 — Façade -> API @@ -138,7 +140,7 @@ consumer ordinaire -> ksp-store-postgres-lib Matérialisé par `pre.005` : les types physiques nécessaires sont publics uniquement dans la crate backend pour la frontière inter-crates et ne sont jamais réexportés par `ksp-store-lib`; `Pool/Client/Row/Statement` restent absents de sa crate-root. -Statut : `TODO gate opérateur pre.005 / TODO pre.009`. +Statut : `PASS pre.005-fix.001 opérateur / TODO pre.009`. ### V32-DEP-006 — `ksp-store-api` non régressé @@ -205,7 +207,7 @@ Drop best-effort seulement `pre.003` fixe les signatures `Store::open(settings).await` et `Store::close(self).await`. `pre.005` remplace le stop de staging PostgreSQL par l'ouverture physique : un succès exige `pool.get().await` après construction du pool, puis `close(self)` ferme et draine ce pool sous la deadline configurée. Aucun pool/client n'est exposé par la façade. -Statut : `PASS pre.003-fix.001 opérateur` pour les signatures / `TODO gate opérateur pre.005` pour l'ouverture et le shutdown physiques / `TODO pre.007` pour la composition health. +Statut : `PASS pre.003-fix.001 opérateur` pour les signatures / `PASS pre.005-fix.001 opérateur` pour l'ouverture et le shutdown physiques / `TODO pre.007` pour la composition health. ## 5. Config ownership @@ -298,7 +300,7 @@ aucune valeur pathologique Matérialisé par `pre.005` : `deadpool-postgres` possède un pool `max_size` explicite et applique les deadlines `wait/create/recycle`; `tokio-postgres::Config` reçoit aussi le `connect_timeout` typé après parsing de l'URI. -Statut : `TODO gate opérateur pre.005`. +Statut : `PASS pre.005-fix.001 opérateur`. ### V32-POOL-002 — Connection task ownership @@ -306,7 +308,7 @@ Critère : chaque connection future tokio-postgres est pilotée par le manager r `pre.005` délègue la création/recycle des connexions au `Manager` Deadpool retenu ; aucun `tokio::spawn` KSP n'est introduit dans Store. La preuve de non-régression détaillée reste au hardening. -Statut : `TODO gate opérateur pre.005 / TODO pre.009`. +Statut : `PASS pre.005-fix.001 opérateur / TODO pre.009`. ### V32-POOL-003 — Open failure safe @@ -314,7 +316,7 @@ Critère : DNS/connect/auth/server errors ne copient ni URI ni texte remote arbi Matérialisé par `pre.005` : parsing/connexion/pool/TLS sont ramenés à une classification backend locale sans conserver le texte des erreurs externes ni l'URI. La façade mappe vers des codes `store.postgres_*` stables avec contexte statique sûr. -Statut : `TODO gate opérateur pre.005`. +Statut : `PASS pre.005-fix.001 opérateur`. ### V32-POOL-004 — Close @@ -329,7 +331,7 @@ aucune tâche volontairement laissée orpheline `pre.005` matérialise déjà `Pool::close()` et un drain borné par `shutdown_timeout`; `Drop` ne fait qu'un `close()` best-effort. La preuve end-to-end avec backend réel reste réservée à `pre.007/pre.008`. -Statut : `TODO gate opérateur pre.005 / TODO pre.007/pre.008`. +Statut : `PASS pre.005-fix.001 opérateur / TODO pre.007/pre.008`. ## 7. TLS @@ -344,7 +346,7 @@ VerifyFull Matérialisé par `pre.005` : la surface physique accepte exactement `Disabled` et `VerifyFull`; l'URI parsée est ensuite normalisée vers `SslMode::Disable` ou `SslMode::Require`, donc elle ne peut pas modifier la policy typée. -Statut : `TODO gate opérateur pre.005`. +Statut : `PASS pre.005-fix.001 opérateur`. ### V32-TLS-002 — VerifyFull @@ -360,7 +362,7 @@ aucun fallback plaintext Matérialisé par `pre.005` : `VerifyFull` construit un `rustls::RootCertStore` à partir des roots système, utilise explicitement le provider AWS-LC, requiert un host TCP pour l'identité serveur, rejette `hostaddr` seul et les sockets Unix, et ne permet aucun fallback plaintext. Les erreurs de roots/certificats sont réduites à des compteurs sûrs. -Statut : `TODO gate opérateur pre.005`. +Statut : `PASS pre.005-fix.001 opérateur`. ### V32-TLS-003 — Pas de fichier TLS implicite @@ -380,7 +382,9 @@ ksp_store_schema_migrations et aucune table métier RAW/CORE/DECODE/SPECIALIZED. -Statut : `TODO pre.006`. +`pre.006` embarque exactement `migrations/V000__bootstrap.sql`, dont le seul DDL de production crée `ksp_store_schema_migrations`. Le canari source interdit les identifiants métier RAW/CORE/DECODE/SPECIALIZED dans le moteur et la ressource SQL. + +Statut : `TODO gate opérateur pre.006 / TODO pre.008 live`. ### V32-MIG-002 — Version/checksum @@ -394,25 +398,33 @@ sentinel bootstrap version 0 mismatch historique terminal ``` -Statut : `TODO pre.006`. +`pre.006` calcule explicitement SHA-256 sur les octets exacts du SQL embarqué puis encode les 32 octets en 64 caractères hex minuscules. Le sentinel `(0, bootstrap, checksum)` est inséré dans la même transaction que la création metadata. Les tests unitaires figent le checksum du SQL committed et couvrent sentinel valide, missing, nom/checksum divergents et historique plus récent. + +Statut : `TODO gate opérateur pre.006 / TODO pre.008 live`. ### V32-MIG-003 — Concurrence Critère : deux runners concurrents sont sérialisés par advisory transaction lock avec attente bornée. -Statut : `TODO pre.006/pre.008`. +`pre.006` utilise une clé KSP fixe et `pg_try_advisory_xact_lock($1)` dans une boucle bornée par `migration_lock_timeout`, avec polling de 25 ms maximum. Aucun lock bloquant illimité n'est utilisé. + +Statut : `TODO gate opérateur pre.006 / TODO pre.008 concurrence réelle`. ### V32-MIG-004 — Atomicité/recovery Critère : échec d'une migration du run courant rollback DDL + history de ce run ; un rerun depuis état précédent reste sûr. -Statut : `TODO pre.006/pre.008`. +`pre.006` place lock, metadata DDL, sentinel et validation dans une transaction unique ; toute sortie d'erreur avant `commit()` droppe la transaction et PostgreSQL rollback le run courant. Un timeout externe borne également l'ensemble du bootstrap. L'injection d'échec et la preuve physique du rollback restent au smoke réel. + +Statut : `TODO gate opérateur pre.006 / TODO pre.008 rollback réel`. ### V32-MIG-005 — Newer runtime guard Critère : migration appliquée inconnue/supérieure à la liste embarquée produit `STORE_POSTGRES_SCHEMA_NEWER`, sans down migration. -Statut : `TODO pre.006`. +`pre.006` connaît uniquement la version `0`; toute history `> 0` est classée `SchemaNewer` par le backend puis `store.postgres_schema_newer` par la façade. Aucun chemin de down migration n'existe. + +Statut : `TODO gate opérateur pre.006`. ### V32-MIG-006 — SQL injection @@ -424,7 +436,9 @@ values paramétrées aucun identifier physique user-configurable en 0.3.2 ``` -Statut : `TODO pre.006/pre.009`. +`pre.006` garde le DDL versionné sous `include_str!` et toutes les values variables des requêtes de contrôle (`lock key`, `statement_timeout`, history values) passent par paramètres. Les noms physiques sont des constantes KSP, jamais des settings. + +Statut : `TODO gate opérateur pre.006 / TODO pre.009 hardening`. ### V32-MIG-007 — No business capability @@ -539,9 +553,9 @@ Statut : `TODO pre.009`. Cas : zéro, inversion, dépassement bornes pour pool/connect/migration/close. -Les bornes backend-neutral décidées en `pre.001` sont matérialisées et couvertes par tests unitaires en `pre.003`. Le parsing PostgreSQL et les timeouts physiques restent à `pre.005`. +Les bornes backend-neutral décidées en `pre.001` sont matérialisées et couvertes par tests unitaires en `pre.003`. `pre.005` applique physiquement `connect/wait/create/recycle/shutdown`; `pre.006` applique physiquement `migration_timeout` et `migration_lock_timeout`. -Statut : `PASS pre.003-fix.001 opérateur` pour les bornes backend-neutral / `TODO pre.005 / TODO pre.009` pour les timeouts physiques. +Statut : `PASS pre.003-fix.001 opérateur` pour les bornes backend-neutral / `PASS pre.005-fix.001 opérateur` pour pool/connect/shutdown / `TODO gate opérateur pre.006` pour migration / `TODO pre.009` hardening. ### V32-SEC-004 — Feature mismatch avant I/O @@ -553,7 +567,7 @@ Statut : `PASS pre.003-fix.001 opérateur / TODO pre.009`. Un serveur/test double qui renvoie un message contenant un canary ne doit pas le faire traverser l'erreur publique. -Statut : `TODO pre.005/pre.009`. +Statut : bridge de sanitization matérialisé en `pre.005`; `TODO pre.009` pour le canary serveur hostile. ## 12. Gates Rust/workspace