diff --git a/Cargo.toml b/Cargo.toml index c1eccfd..d0a8465 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 451 +# version: 452 [workspace] resolver = "3" members = ["crates/ksp-app-backfill-desk", "crates/ksp-app-config-desk", "crates/ksp-app-solprices-desk", "crates/ksp-app-store-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-interface-lib", "crates/ksp-job-api", "crates/ksp-job-backfill-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.8-pre.4.fix.1" +version = "0.3.8-pre.5" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/crates/ksp-store-lib/src/lib.rs b/crates/ksp-store-lib/src/lib.rs index 485335f..ba7f9ac 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: 10 +// version: 11 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -12,7 +12,8 @@ //! `RawTransaction` capabilities. `0.3.4-pre.008` adds the four `RawAccount*` capabilities //! on both the physical backend and this common facade, completing the canonical RAW runtime inventory at //! ten capabilities without exposing physical types. The `0.3.8` inspection -//! contracts are reexported here before backend/facade dispatch is opened. +//! contracts are reexported here. `0.3.8-pre.004` dispatches transaction inspection +//! and `0.3.8-pre.005` completes inspection dispatch with RAW account states. //! //! The default `postgres` feature compiles the official PostgreSQL backend as //! an optional implementation dependency. No backend implementation type is diff --git a/crates/ksp-store-lib/src/store.rs b/crates/ksp-store-lib/src/store.rs index cf4d014..0ea8d0f 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: 8 +// version: 9 /// Opaque common Store runtime facade. /// @@ -228,6 +228,36 @@ impl ksp_store_api::RawAccountStateRead for Store { } } +impl ksp_store_api::RawAccountStateInspectionRead for Store { + fn inspect_raw_account_states<'a>( + &'a self, + query: &'a ksp_store_api::RawAccountStateInspectionQuery, + ) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result>> { + let network_check = validate_operation_network(&self.network, query.network(), self.backend_kind); + if let std::result::Result::Err(error) = network_check { + return std::boxed::Box::pin(async move { + return std::result::Result::Err(error); + }); + } + return std::boxed::Box::pin(async move { + #[cfg(feature = "postgres")] + { + return match &self.runtime { + StoreRuntime::Postgres(backend) => { + let result = backend.inspect_raw_account_states(query).await; + result.map_err(|error| return map_postgres_error(error, self.backend_kind, self.network.as_str())) + }, + }; + } + #[cfg(not(feature = "postgres"))] + { + let _ = query; + return std::result::Result::Err(unavailable_runtime_error(self.backend_kind)); + } + }); + } +} + impl ksp_store_api::RawAccountStateWrite for Store { fn persist_raw_account_acquisition<'a>( &'a self, diff --git a/crates/ksp-store-lib/tests/dependency_boundary.rs b/crates/ksp-store-lib/tests/dependency_boundary.rs index 8f891a8..f065ecf 100644 --- a/crates/ksp-store-lib/tests/dependency_boundary.rs +++ b/crates/ksp-store-lib/tests/dependency_boundary.rs @@ -1,5 +1,5 @@ // file: crates/ksp-store-lib/tests/dependency_boundary.rs -// version: 9 +// version: 10 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -60,11 +60,12 @@ fn pre_005_facade_exposes_no_physical_postgres_types_or_environment_bypass() { } #[test] -fn v0_3_8_pre_004_facade_dispatches_eleven_raw_capabilities_without_physical_leak() { +fn v0_3_8_pre_005_facade_dispatches_twelve_raw_capabilities_without_physical_leak() { let store = include_str!("../src/store.rs"); for required in [ "impl ksp_store_api::RawAccountObservationRead for Store", "impl ksp_store_api::RawAccountObservationWrite for Store", + "impl ksp_store_api::RawAccountStateInspectionRead for Store", "impl ksp_store_api::RawAccountStateRead for Store", "impl ksp_store_api::RawAccountStateWrite for Store", "impl ksp_store_api::RawTransactionInspectionRead for Store", @@ -80,7 +81,7 @@ fn v0_3_8_pre_004_facade_dispatches_eleven_raw_capabilities_without_physical_lea ] { assert!(store.contains(required), "missing pre.008 Store capability dispatch contract: {required}"); } - assert_eq!(store.matches("impl ksp_store_api::Raw").count(), 11); + assert_eq!(store.matches("impl ksp_store_api::Raw").count(), 12); for forbidden in ["tokio_postgres::", "deadpool_postgres::", "CREATE TABLE", "INSERT INTO", "UPDATE ksp_", "DELETE FROM"] { assert!(!store.contains(forbidden), "pre.008 facade leaked physical backend material: {forbidden}"); } diff --git a/crates/ksp-store-lib/tests/hardening_completeness.rs b/crates/ksp-store-lib/tests/hardening_completeness.rs index 9542211..6ef5023 100644 --- a/crates/ksp-store-lib/tests/hardening_completeness.rs +++ b/crates/ksp-store-lib/tests/hardening_completeness.rs @@ -1,5 +1,5 @@ // file: crates/ksp-store-lib/tests/hardening_completeness.rs -// version: 8 +// version: 9 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -292,11 +292,12 @@ fn pre_009_facade_production_sources_keep_config_env_physical_sql_and_backend_ha } #[test] -fn v0_3_8_pre_004_facade_raw_capability_inventory_is_exactly_eleven() { +fn v0_3_8_pre_005_facade_raw_capability_inventory_is_exactly_twelve() { let store = include_str!("../src/store.rs"); let capability_impls = [ "impl ksp_store_api::RawAccountObservationRead for Store", "impl ksp_store_api::RawAccountObservationWrite for Store", + "impl ksp_store_api::RawAccountStateInspectionRead for Store", "impl ksp_store_api::RawAccountStateRead for Store", "impl ksp_store_api::RawAccountStateWrite for Store", "impl ksp_store_api::RawTransactionInspectionRead for Store", @@ -310,22 +311,22 @@ fn v0_3_8_pre_004_facade_raw_capability_inventory_is_exactly_eleven() { for implementation in capability_impls { assert_eq!(store.matches(implementation).count(), 1, "unexpected Store capability implementation inventory: {implementation}"); } - assert_eq!(store.matches("impl ksp_store_api::Raw").count(), 11); - assert_eq!(store.matches("validate_operation_network(").count(), 15); + assert_eq!(store.matches("impl ksp_store_api::Raw").count(), 12); + assert_eq!(store.matches("validate_operation_network(").count(), 16); return; } #[test] -fn v0_3_8_pre_004_facade_and_backend_capability_sets_match_with_transaction_inspection_only() { +fn v0_3_8_pre_005_facade_and_backend_capability_sets_match_with_both_inspection_capabilities() { let store = include_str!("../src/store.rs"); let backend = include_str!("../../ksp-store-postgres-lib/src/runtime.rs"); let store_traits = raw_capability_trait_names(store, " for Store"); let backend_traits = raw_capability_trait_names(backend, " for PostgresBackend"); - assert_eq!(store_traits.len(), 11); - assert_eq!(backend_traits.len(), 11); + assert_eq!(store_traits.len(), 12); + assert_eq!(backend_traits.len(), 12); assert_eq!(store_traits, backend_traits); assert!(store_traits.contains(&"RawTransactionInspectionRead")); - assert!(!store_traits.contains(&"RawAccountStateInspectionRead")); + assert!(store_traits.contains(&"RawAccountStateInspectionRead")); for forbidden in ["RawAccountRetentionRead", "RawAccountRetentionWrite", "RawAccountDelete", "RawAccountCompaction"] { assert!(!store_traits.contains(&forbidden), "unexpected account capability added to Store: {forbidden}"); assert!(!backend_traits.contains(&forbidden), "unexpected account capability added to PostgreSQL backend: {forbidden}"); diff --git a/crates/ksp-store-lib/tests/public_api.rs b/crates/ksp-store-lib/tests/public_api.rs index 892fea7..13d647f 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: 10 +// version: 11 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -81,6 +81,7 @@ fn assert_raw_capabilities() where T: ksp_store_lib::RawAccountObservationRead + ksp_store_lib::RawAccountObservationWrite + + ksp_store_lib::RawAccountStateInspectionRead + ksp_store_lib::RawAccountStateRead + ksp_store_lib::RawAccountStateWrite + ksp_store_lib::RawTransactionInspectionRead @@ -96,7 +97,7 @@ where } #[test] -fn v0_3_8_pre_004_store_facade_implements_raw_transaction_inspection_as_capability_11_of_11() { +fn v0_3_8_pre_005_store_facade_implements_both_inspection_capabilities_as_12_of_12() { assert_raw_capabilities::(); return; } diff --git a/crates/ksp-store-postgres-lib/src/lib.rs b/crates/ksp-store-postgres-lib/src/lib.rs index a287f54..4fa3d92 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: 22 +// version: 23 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -33,8 +33,9 @@ //! deterministic account keyset pagination with the fixed `KSPA` cursor. `0.3.4-pre.008` //! implements the four `RawAccount*` capabilities directly on `PostgresBackend`, completing //! the backend RAW capability inventory at ten without exposing physical PostgreSQL types. -//! `0.3.8-pre.004` adds the payload-free random-access RawTransaction inspection -//! capability with exact counts while preserving keyset traversal unchanged. +//! `0.3.8-pre.004` adds payload-free random-access RawTransaction inspection; +//! `0.3.8-pre.005` adds the corresponding data-free RawAccountState inspection +//! with exact counts while preserving both keyset traversal families unchanged. //! //! 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 @@ -86,6 +87,8 @@ pub(crate) use self::raw_account::get_raw_account_observation; pub(crate) use self::raw_account::get_raw_account_state; /// Private RAW account-state list reader consumed by the physical backend runtime. pub(crate) use self::raw_account::list_raw_account_states; +/// Private data-free RAW account-state inspection reader consumed by the physical backend runtime. +pub(crate) use self::raw_account::inspect_raw_account_states; /// Private atomic RAW account acquisition writer consumed by the physical backend runtime. pub(crate) use self::raw_account::persist_raw_account_acquisition; /// Private additional RAW account observation writer consumed by the physical backend runtime. @@ -104,10 +107,10 @@ pub(crate) use self::raw_transaction::get_raw_transaction_observation; pub(crate) use self::raw_transaction::get_raw_transaction_retention_state; /// Private RAW transaction tombstone reader consumed by the physical backend runtime. pub(crate) use self::raw_transaction::get_raw_transaction_tombstone; -/// Private payload-free RAW transaction inspection reader consumed by the physical backend runtime. -pub(crate) use self::raw_transaction::inspect_raw_transactions; /// Private RAW transaction list reader consumed by the physical backend runtime. pub(crate) use self::raw_transaction::list_raw_transactions; +/// Private payload-free RAW transaction inspection reader consumed by the physical backend runtime. +pub(crate) use self::raw_transaction::inspect_raw_transactions; /// Private atomic RAW transaction acquisition writer consumed by the physical backend runtime. pub(crate) use self::raw_transaction::persist_raw_transaction_acquisition; /// Private additional RAW transaction observation writer consumed by the physical backend runtime. diff --git a/crates/ksp-store-postgres-lib/src/raw_account.rs b/crates/ksp-store-postgres-lib/src/raw_account.rs index c3f366d..acb9796 100644 --- a/crates/ksp-store-postgres-lib/src/raw_account.rs +++ b/crates/ksp-store-postgres-lib/src/raw_account.rs @@ -1,5 +1,5 @@ // file: crates/ksp-store-postgres-lib/src/raw_account.rs -// version: 5 +// version: 6 pub(crate) mod cursor; @@ -7,6 +7,8 @@ const GET_ACCOUNT_OBSERVATION_SQL: &str = "SELECT observation_key, account_pubke const GET_ACCOUNT_STATE_SQL: &str = "SELECT pubkey, slot::text AS slot_text, state_hash, lamports::text AS lamports_text, owner, executable, rent_epoch::text AS rent_epoch_text, data FROM ksp_raw_account_states WHERE pubkey = $1 AND slot = $2::TEXT::NUMERIC AND state_hash = $3"; const INSERT_ACCOUNT_OBSERVATION_SQL: &str = "INSERT INTO ksp_raw_account_observations (observation_key, account_pubkey, account_slot, account_state_hash, provider, protocol, acquisition_method, origin, received_at_unix_millis, capture_session_id, commitment, endpoint_id, filter_id, observed_at_unix_millis, source_payload_hash, source_payload_size_bytes, is_startup, transaction_signature, write_version) VALUES ($1, $2, $3::TEXT::NUMERIC, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19::TEXT::NUMERIC) ON CONFLICT (observation_key) DO NOTHING RETURNING observation_key"; const INSERT_ACCOUNT_STATE_SQL: &str = "INSERT INTO ksp_raw_account_states (pubkey, slot, state_hash, lamports, owner, executable, rent_epoch, data) VALUES ($1, $2::TEXT::NUMERIC, $3, $4::TEXT::NUMERIC, $5, $6, $7::TEXT::NUMERIC, $8) ON CONFLICT (pubkey, slot, state_hash) DO NOTHING RETURNING pubkey"; +const INSPECT_ACCOUNT_STATES_ASC_SQL: &str = "WITH filtered_count AS (SELECT COUNT(*)::TEXT AS filtered_count_text FROM ksp_raw_account_states WHERE ($1::BYTEA IS NULL OR pubkey = $1::BYTEA) AND ($2::TEXT IS NULL OR slot >= $2::TEXT::NUMERIC) AND ($3::TEXT IS NULL OR slot <= $3::TEXT::NUMERIC)), counts AS (SELECT filtered_count_text, CASE WHEN $1::BYTEA IS NULL AND $2::TEXT IS NULL AND $3::TEXT IS NULL THEN filtered_count_text ELSE (SELECT COUNT(*)::TEXT FROM ksp_raw_account_states) END AS total_count_text FROM filtered_count) SELECT counts.total_count_text, counts.filtered_count_text, page.pubkey IS NOT NULL AS page_present, page.pubkey, page.slot_text, page.state_hash, page.lamports_text, page.owner, page.executable, page.rent_epoch_text, page.data_length_bytes FROM counts LEFT JOIN LATERAL (SELECT account_row.pubkey, account_row.slot::TEXT AS slot_text, account_row.state_hash, account_row.lamports::TEXT AS lamports_text, account_row.owner, account_row.executable, account_row.rent_epoch::TEXT AS rent_epoch_text, OCTET_LENGTH(account_row.data)::BIGINT AS data_length_bytes FROM ksp_raw_account_states AS account_row WHERE ($1::BYTEA IS NULL OR account_row.pubkey = $1::BYTEA) AND ($2::TEXT IS NULL OR account_row.slot >= $2::TEXT::NUMERIC) AND ($3::TEXT IS NULL OR account_row.slot <= $3::TEXT::NUMERIC) ORDER BY account_row.slot ASC, account_row.pubkey ASC, account_row.state_hash ASC LIMIT $4 OFFSET $5) AS page ON TRUE"; +const INSPECT_ACCOUNT_STATES_DESC_SQL: &str = "WITH filtered_count AS (SELECT COUNT(*)::TEXT AS filtered_count_text FROM ksp_raw_account_states WHERE ($1::BYTEA IS NULL OR pubkey = $1::BYTEA) AND ($2::TEXT IS NULL OR slot >= $2::TEXT::NUMERIC) AND ($3::TEXT IS NULL OR slot <= $3::TEXT::NUMERIC)), counts AS (SELECT filtered_count_text, CASE WHEN $1::BYTEA IS NULL AND $2::TEXT IS NULL AND $3::TEXT IS NULL THEN filtered_count_text ELSE (SELECT COUNT(*)::TEXT FROM ksp_raw_account_states) END AS total_count_text FROM filtered_count) SELECT counts.total_count_text, counts.filtered_count_text, page.pubkey IS NOT NULL AS page_present, page.pubkey, page.slot_text, page.state_hash, page.lamports_text, page.owner, page.executable, page.rent_epoch_text, page.data_length_bytes FROM counts LEFT JOIN LATERAL (SELECT account_row.pubkey, account_row.slot::TEXT AS slot_text, account_row.state_hash, account_row.lamports::TEXT AS lamports_text, account_row.owner, account_row.executable, account_row.rent_epoch::TEXT AS rent_epoch_text, OCTET_LENGTH(account_row.data)::BIGINT AS data_length_bytes FROM ksp_raw_account_states AS account_row WHERE ($1::BYTEA IS NULL OR account_row.pubkey = $1::BYTEA) AND ($2::TEXT IS NULL OR account_row.slot >= $2::TEXT::NUMERIC) AND ($3::TEXT IS NULL OR account_row.slot <= $3::TEXT::NUMERIC) ORDER BY account_row.slot DESC, account_row.pubkey DESC, account_row.state_hash DESC LIMIT $4 OFFSET $5) AS page ON TRUE"; const LIST_ACCOUNT_STATES_ASC_SQL: &str = "SELECT pubkey, slot::text AS slot_text, state_hash FROM ksp_raw_account_states WHERE ($1::TEXT IS NULL OR slot >= $1::TEXT::NUMERIC) AND ($2::TEXT IS NULL OR slot <= $2::TEXT::NUMERIC) AND ($3::TEXT IS NULL OR (slot, pubkey, state_hash) > ($3::TEXT::NUMERIC, $4::BYTEA, $5::BYTEA)) ORDER BY slot ASC, pubkey ASC, state_hash ASC LIMIT $6"; const LIST_ACCOUNT_STATES_BY_PUBKEY_ASC_SQL: &str = "SELECT pubkey, slot::text AS slot_text, state_hash FROM ksp_raw_account_states WHERE pubkey = $1 AND ($2::TEXT IS NULL OR slot >= $2::TEXT::NUMERIC) AND ($3::TEXT IS NULL OR slot <= $3::TEXT::NUMERIC) AND ($4::TEXT IS NULL OR (slot, pubkey, state_hash) > ($4::TEXT::NUMERIC, $5::BYTEA, $6::BYTEA)) ORDER BY slot ASC, pubkey ASC, state_hash ASC LIMIT $7"; const LIST_ACCOUNT_STATES_BY_PUBKEY_DESC_SQL: &str = "SELECT pubkey, slot::text AS slot_text, state_hash FROM ksp_raw_account_states WHERE pubkey = $1 AND ($2::TEXT IS NULL OR slot >= $2::TEXT::NUMERIC) AND ($3::TEXT IS NULL OR slot <= $3::TEXT::NUMERIC) AND ($4::TEXT IS NULL OR (slot, pubkey, state_hash) < ($4::TEXT::NUMERIC, $5::BYTEA, $6::BYTEA)) ORDER BY slot DESC, pubkey DESC, state_hash DESC LIMIT $7"; @@ -22,6 +24,20 @@ struct RawAccountListDbRow { state_hash: std::vec::Vec, } +struct RawAccountInspectionDbRow { + data_length_bytes: std::option::Option, + executable: std::option::Option, + filtered_count_text: std::string::String, + lamports_text: std::option::Option, + owner: std::option::Option>, + page_present: bool, + pubkey: std::option::Option>, + rent_epoch_text: std::option::Option, + slot_text: std::option::Option, + state_hash: std::option::Option>, + total_count_text: std::string::String, +} + struct RawAccountObservationDbRow { account_pubkey: std::vec::Vec, account_slot_text: std::string::String, @@ -203,6 +219,107 @@ pub(crate) async fn list_raw_account_states( return std::result::Result::Ok(ksp_store_api::RawPage::new(items, next_cursor)); } +/// Inspects one data-free random-access RAW account-state window with exact logical counts. +pub(crate) async fn inspect_raw_account_states( + pool: &deadpool_postgres::Pool, + network: &ksp_store_api::RawNetworkId, + query: &ksp_store_api::RawAccountStateInspectionQuery, +) -> std::result::Result, crate::PostgresBackendError> { + if query.network() != network { + return std::result::Result::Err(crate::PostgresBackendError::new( + crate::PostgresBackendErrorKind::WrongNetwork, + "raw_account_inspection_network", + )); + } + let sql = match query.direction() { + ksp_store_api::RawSortDirection::Ascending => INSPECT_ACCOUNT_STATES_ASC_SQL, + ksp_store_api::RawSortDirection::Descending => INSPECT_ACCOUNT_STATES_DESC_SQL, + _ => { + return std::result::Result::Err(crate::PostgresBackendError::new( + crate::PostgresBackendErrorKind::QueryInvalid, + "raw_account_inspection_direction", + )); + }, + }; + let (sql_limit, sql_offset) = match raw_account_inspection_sql_window(query.page()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let client_result = pool.get().await; + let client = match client_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(crate::map_pool_error(error)), + }; + let pubkey_bytes = query.pubkey().map(|value| return value.to_bytes().to_vec()); + let slots = query.slots(); + let start_text = slots.start_inclusive().map(|value| return value.to_string()); + let end_text = slots.end_inclusive().map(|value| return value.to_string()); + let rows_result = client.query(sql, &[&pubkey_bytes, &start_text, &end_text, &sql_limit, &sql_offset]).await; + let rows = match rows_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::ReadFailed, "raw_account_inspection_query")); + }, + }; + if rows.is_empty() { + return std::result::Result::Err(data_invalid("raw_account_inspection_cardinality")); + } + let row_count = rows.len(); + let mut total_items = std::option::Option::None; + let mut filtered_items = std::option::Option::None; + let mut items = std::vec::Vec::new(); + for row in rows { + let physical = match raw_account_inspection_db_row(&row) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let row_total = match decode_u64_decimal(physical.total_count_text.as_str(), "raw_account_inspection_total") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let row_filtered = match decode_u64_decimal(physical.filtered_count_text.as_str(), "raw_account_inspection_filtered") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + match (total_items, filtered_items) { + (std::option::Option::None, std::option::Option::None) => { + total_items = std::option::Option::Some(row_total); + filtered_items = std::option::Option::Some(row_filtered); + }, + (std::option::Option::Some(total), std::option::Option::Some(filtered)) if total == row_total && filtered == row_filtered => {}, + _ => return std::result::Result::Err(data_invalid("raw_account_inspection_counts")), + } + if physical.page_present { + let summary = match decode_raw_account_inspection_summary(network, physical) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + items.push(summary); + } else if row_count != 1 || !raw_account_inspection_empty_page_is_clean(&physical) { + return std::result::Result::Err(data_invalid("raw_account_inspection_page")); + } + } + let total_items = match total_items { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(data_invalid("raw_account_inspection_total")), + }; + let filtered_items = match filtered_items { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(data_invalid("raw_account_inspection_filtered")), + }; + let item_count = match u64::try_from(items.len()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(data_invalid("raw_account_inspection_page")), + }; + if item_count > query.page().limit().get() { + return std::result::Result::Err(data_invalid("raw_account_inspection_page")); + } + return match ksp_store_api::RawInspectionPage::try_new(items, total_items, filtered_items) { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(_) => std::result::Result::Err(data_invalid("raw_account_inspection_page")), + }; +} + /// Reads one persisted RAW account observation by producer-owned idempotence key. pub(crate) async fn get_raw_account_observation( pool: &deadpool_postgres::Pool, @@ -913,6 +1030,182 @@ fn decode_timestamp_i64(value: i64, phase: &'static str) -> std::result::Result< }; } +fn raw_account_inspection_empty_page_is_clean(row: &RawAccountInspectionDbRow) -> bool { + return !row.page_present + && row.data_length_bytes.is_none() + && row.executable.is_none() + && row.lamports_text.is_none() + && row.owner.is_none() + && row.pubkey.is_none() + && row.rent_epoch_text.is_none() + && row.slot_text.is_none() + && row.state_hash.is_none(); +} + +fn raw_account_inspection_sql_window( + page: ksp_store_api::RawInspectionPageRequest, +) -> std::result::Result<(i64, i64), crate::PostgresBackendError> { + let sql_limit = match i64::try_from(page.limit().get()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err(crate::PostgresBackendError::new( + crate::PostgresBackendErrorKind::PageLimitUnsupported, + "raw_account_inspection_limit", + )); + }, + }; + let sql_offset = match i64::try_from(page.offset()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err(crate::PostgresBackendError::new( + crate::PostgresBackendErrorKind::QueryInvalid, + "raw_account_inspection_offset", + )); + }, + }; + return std::result::Result::Ok((sql_limit, sql_offset)); +} + +fn raw_account_inspection_db_row(row: &tokio_postgres::Row) -> std::result::Result { + let data_length_bytes = match row.try_get::<_, std::option::Option>("data_length_bytes") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(data_invalid("raw_account_inspection_decode")), + }; + let executable = match row.try_get::<_, std::option::Option>("executable") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(data_invalid("raw_account_inspection_decode")), + }; + let filtered_count_text = match row.try_get::<_, std::string::String>("filtered_count_text") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(data_invalid("raw_account_inspection_decode")), + }; + let lamports_text = match row.try_get::<_, std::option::Option>("lamports_text") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(data_invalid("raw_account_inspection_decode")), + }; + let owner = match row.try_get::<_, std::option::Option>>("owner") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(data_invalid("raw_account_inspection_decode")), + }; + let page_present = match row.try_get::<_, bool>("page_present") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(data_invalid("raw_account_inspection_decode")), + }; + let pubkey = match row.try_get::<_, std::option::Option>>("pubkey") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(data_invalid("raw_account_inspection_decode")), + }; + let rent_epoch_text = match row.try_get::<_, std::option::Option>("rent_epoch_text") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(data_invalid("raw_account_inspection_decode")), + }; + let slot_text = match row.try_get::<_, std::option::Option>("slot_text") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(data_invalid("raw_account_inspection_decode")), + }; + let state_hash = match row.try_get::<_, std::option::Option>>("state_hash") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(data_invalid("raw_account_inspection_decode")), + }; + let total_count_text = match row.try_get::<_, std::string::String>("total_count_text") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(data_invalid("raw_account_inspection_decode")), + }; + return std::result::Result::Ok(RawAccountInspectionDbRow { + data_length_bytes, + executable, + filtered_count_text, + lamports_text, + owner, + page_present, + pubkey, + rent_epoch_text, + slot_text, + state_hash, + total_count_text, + }); +} + +fn decode_raw_account_inspection_summary( + network: &ksp_store_api::RawNetworkId, + row: RawAccountInspectionDbRow, +) -> std::result::Result { + if !row.page_present { + return std::result::Result::Err(data_invalid("raw_account_inspection_page")); + } + let pubkey = match inspection_required(row.pubkey, "raw_account_inspection_pubkey") { + std::result::Result::Ok(value) => match fixed_bytes::<32>(value, "raw_account_inspection_pubkey") { + std::result::Result::Ok(bytes) => ksp_store_api::Pubkey::new_from_array(bytes), + std::result::Result::Err(error) => return std::result::Result::Err(error), + }, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let slot_text = match inspection_required(row.slot_text, "raw_account_inspection_slot") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let slot = match decode_u64_decimal(slot_text.as_str(), "raw_account_inspection_slot") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let state_hash_raw = match inspection_required(row.state_hash, "raw_account_inspection_state_hash") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let state_hash = match fixed_bytes::<32>(state_hash_raw, "raw_account_inspection_state_hash") { + std::result::Result::Ok(value) => ksp_store_api::RawContentHash::new(value), + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let lamports_text = match inspection_required(row.lamports_text, "raw_account_inspection_lamports") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let lamports = match decode_u64_decimal(lamports_text.as_str(), "raw_account_inspection_lamports") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let owner_raw = match inspection_required(row.owner, "raw_account_inspection_owner") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let owner = match fixed_bytes::<32>(owner_raw, "raw_account_inspection_owner") { + std::result::Result::Ok(value) => ksp_store_api::Pubkey::new_from_array(value), + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let executable = match inspection_required(row.executable, "raw_account_inspection_executable") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let rent_epoch_text = match inspection_required(row.rent_epoch_text, "raw_account_inspection_rent_epoch") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let rent_epoch = match decode_u64_decimal(rent_epoch_text.as_str(), "raw_account_inspection_rent_epoch") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let data_length_raw = match inspection_required(row.data_length_bytes, "raw_account_inspection_data_length") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let data_length_bytes = match u64::try_from(data_length_raw) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(data_invalid("raw_account_inspection_data_length")), + }; + let reference = ksp_store_api::RawAccountStateReference::new(network.clone(), pubkey, slot, state_hash); + return match ksp_store_api::RawAccountStateSummary::try_new(reference, lamports, owner, executable, rent_epoch, data_length_bytes) { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(_) => std::result::Result::Err(data_invalid("raw_account_inspection_summary")), + }; +} + +fn inspection_required(value: std::option::Option, phase: &'static str) -> std::result::Result { + return match value { + std::option::Option::Some(inner) => std::result::Result::Ok(inner), + std::option::Option::None => std::result::Result::Err(data_invalid(phase)), + }; +} + fn decode_u64_decimal(value: &str, phase: &'static str) -> std::result::Result { return match value.parse::() { std::result::Result::Ok(decoded) => std::result::Result::Ok(decoded), diff --git a/crates/ksp-store-postgres-lib/src/runtime.rs b/crates/ksp-store-postgres-lib/src/runtime.rs index db6697d..eb91952 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: 16 +// version: 17 const APPLICATION_NAME: &str = "ksp-store"; const MAX_CONNECTION_URI_BYTES: usize = 4_096; @@ -348,6 +348,14 @@ impl PostgresBackend { return crate::list_raw_account_states(&self.pool, &self.network, query).await; } + /// Inspects one data-free random-access RAW account-state window with exact logical counts. + pub async fn inspect_raw_account_states( + &self, + query: &ksp_store_api::RawAccountStateInspectionQuery, + ) -> std::result::Result, crate::PostgresBackendError> { + return crate::inspect_raw_account_states(&self.pool, &self.network, query).await; + } + /// Reads one persisted RAW account observation by producer-owned idempotence key. pub async fn get_raw_account_observation( &self, @@ -523,6 +531,18 @@ impl ksp_store_api::RawAccountStateRead for PostgresBackend { } } +impl ksp_store_api::RawAccountStateInspectionRead for PostgresBackend { + fn inspect_raw_account_states<'a>( + &'a self, + query: &'a ksp_store_api::RawAccountStateInspectionQuery, + ) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result>> { + return std::boxed::Box::pin(async move { + let result = PostgresBackend::inspect_raw_account_states(self, query).await; + return result.map_err(map_capability_error); + }); + } +} + impl ksp_store_api::RawAccountStateWrite for PostgresBackend { fn persist_raw_account_acquisition<'a>( &'a self, diff --git a/crates/ksp-store-postgres-lib/tests/dependency_boundary.rs b/crates/ksp-store-postgres-lib/tests/dependency_boundary.rs index 1780f4b..3be0220 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: 26 +// version: 27 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -184,6 +184,7 @@ fn pre_005_raw_account_acquisition_is_atomic_idempotent_and_keeps_trait_impls_ou assert!(!raw.contains(forbidden), "pre.005 account module contains later/destructive scope: {forbidden}"); } for implementation in [ + "impl ksp_store_api::RawAccountStateInspectionRead for PostgresBackend", "impl ksp_store_api::RawAccountStateRead for PostgresBackend", "impl ksp_store_api::RawAccountStateWrite for PostgresBackend", "impl ksp_store_api::RawAccountObservationRead for PostgresBackend", @@ -218,6 +219,7 @@ fn pre_006_raw_account_additional_observation_is_reference_guarded_cancellation_ assert!(!raw.contains(forbidden), "pre.006 account module contains later/destructive scope: {forbidden}"); } for implementation in [ + "impl ksp_store_api::RawAccountStateInspectionRead for PostgresBackend", "impl ksp_store_api::RawAccountStateRead for PostgresBackend", "impl ksp_store_api::RawAccountStateWrite for PostgresBackend", "impl ksp_store_api::RawAccountObservationRead for PostgresBackend", @@ -374,11 +376,36 @@ fn pre_007_raw_account_pagination_is_keyset_cursor_bound_and_policy_free() { assert!(cursor.contains(required), "missing pre.007 account cursor/binding contract: {required}"); } assert!(index.contains("ON ksp_raw_account_states (slot, pubkey, state_hash)")); - for forbidden in [" OFFSET ", "limit.min(", "clamp(", "500", "1000"] { + let ascending = raw.lines().find(|line| return line.starts_with("const LIST_ACCOUNT_STATES_ASC_SQL")); + let ascending = match ascending { + std::option::Option::Some(value) => value, + std::option::Option::None => panic!("missing canonical account ascending keyset SQL"), + }; + let descending = raw.lines().find(|line| return line.starts_with("const LIST_ACCOUNT_STATES_DESC_SQL")); + let descending = match descending { + std::option::Option::Some(value) => value, + std::option::Option::None => panic!("missing canonical account descending keyset SQL"), + }; + let by_pubkey_ascending = raw.lines().find(|line| return line.starts_with("const LIST_ACCOUNT_STATES_BY_PUBKEY_ASC_SQL")); + let by_pubkey_ascending = match by_pubkey_ascending { + std::option::Option::Some(value) => value, + std::option::Option::None => panic!("missing canonical account pubkey ascending keyset SQL"), + }; + let by_pubkey_descending = raw.lines().find(|line| return line.starts_with("const LIST_ACCOUNT_STATES_BY_PUBKEY_DESC_SQL")); + let by_pubkey_descending = match by_pubkey_descending { + std::option::Option::Some(value) => value, + std::option::Option::None => panic!("missing canonical account pubkey descending keyset SQL"), + }; + for statement in [ascending, descending, by_pubkey_ascending, by_pubkey_descending] { + assert!(!statement.contains(" OFFSET "), "pre.007 canonical account keyset SQL must remain OFFSET-free"); + } + assert!(!cursor.contains(" OFFSET "), "pre.007 account cursor contains forbidden OFFSET material"); + for forbidden in ["limit.min(", "clamp(", "500", "1000"] { assert!(!raw.contains(forbidden), "pre.007 account source contains forbidden pagination/policy material: {forbidden}"); assert!(!cursor.contains(forbidden), "pre.007 account cursor contains forbidden pagination/policy material: {forbidden}"); } for forbidden in [ + "impl ksp_store_api::RawAccountStateInspectionRead for PostgresBackend", "impl ksp_store_api::RawAccountStateRead for PostgresBackend", "impl ksp_store_api::RawAccountStateWrite for PostgresBackend", "impl ksp_store_api::RawAccountObservationRead for PostgresBackend", @@ -422,11 +449,12 @@ fn pre_007_raw_retention_is_atomic_compare_and_transition_without_fake_compactio } #[test] -fn v0_3_8_pre_004_backend_trait_implementations_cover_exact_eleven_raw_capabilities_in_runtime_bridge() { +fn v0_3_8_pre_005_backend_trait_implementations_cover_exact_twelve_raw_capabilities_in_runtime_bridge() { let runtime = include_str!("../src/runtime.rs"); for implementation in [ "impl ksp_store_api::RawAccountObservationRead for PostgresBackend", "impl ksp_store_api::RawAccountObservationWrite for PostgresBackend", + "impl ksp_store_api::RawAccountStateInspectionRead for PostgresBackend", "impl ksp_store_api::RawAccountStateRead for PostgresBackend", "impl ksp_store_api::RawAccountStateWrite for PostgresBackend", "impl ksp_store_api::RawTransactionInspectionRead for PostgresBackend", @@ -439,6 +467,6 @@ fn v0_3_8_pre_004_backend_trait_implementations_cover_exact_eleven_raw_capabilit ] { assert_eq!(runtime.matches(implementation).count(), 1, "unexpected PostgreSQL RAW capability inventory: {implementation}"); } - assert_eq!(runtime.matches("impl ksp_store_api::Raw").count(), 11); + assert_eq!(runtime.matches("impl ksp_store_api::Raw").count(), 12); return; } diff --git a/crates/ksp-store-postgres-lib/tests/hardening_completeness.rs b/crates/ksp-store-postgres-lib/tests/hardening_completeness.rs index 944e608..c9a1eec 100644 --- a/crates/ksp-store-postgres-lib/tests/hardening_completeness.rs +++ b/crates/ksp-store-postgres-lib/tests/hardening_completeness.rs @@ -1,5 +1,5 @@ // file: crates/ksp-store-postgres-lib/tests/hardening_completeness.rs -// version: 19 +// version: 20 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -250,6 +250,9 @@ fn pre_009_live_raw_account_proof_is_opt_in_isolated_secret_safe_and_cross_famil "prove_additional_observations", "ReferenceNotFound", "prove_pagination_and_cursors", + "prove_account_inspection", + "inspect_raw_account_states", + "RawAccountStateInspectionQuery", "KSPT", "prove_cross_family_coexistence", "persist_raw_transaction_acquisition", @@ -299,11 +302,12 @@ fn pre_009_live_raw_transaction_proof_is_opt_in_isolated_and_secret_safe() { } #[test] -fn v0_3_8_pre_004_raw_capability_implementation_inventory_is_exactly_eleven() { +fn v0_3_8_pre_005_raw_capability_implementation_inventory_is_exactly_twelve() { let runtime = include_str!("../src/runtime.rs"); let capability_impls = [ "impl ksp_store_api::RawAccountObservationRead for PostgresBackend", "impl ksp_store_api::RawAccountObservationWrite for PostgresBackend", + "impl ksp_store_api::RawAccountStateInspectionRead for PostgresBackend", "impl ksp_store_api::RawAccountStateRead for PostgresBackend", "impl ksp_store_api::RawAccountStateWrite for PostgresBackend", "impl ksp_store_api::RawTransactionInspectionRead for PostgresBackend", @@ -317,7 +321,7 @@ fn v0_3_8_pre_004_raw_capability_implementation_inventory_is_exactly_eleven() { for implementation in capability_impls { assert_eq!(runtime.matches(implementation).count(), 1, "unexpected PostgreSQL capability implementation inventory: {implementation}"); } - assert_eq!(runtime.matches("impl ksp_store_api::Raw").count(), 11); + assert_eq!(runtime.matches("impl ksp_store_api::Raw").count(), 12); let migration = include_str!("../src/migration.rs"); assert!(migration.contains("raw_account_state")); assert!(migration.contains("crate::V002_RESOURCES")); @@ -466,3 +470,38 @@ fn pre_010_v002_schema_inventory_is_exact_family_local_and_query_justified() { } return; } + +#[test] +fn v0_3_8_pre_005_raw_account_keyset_sql_remains_offset_free_while_inspection_is_random_access() { + let source = include_str!("../src/raw_account.rs"); + for name in [ + "const LIST_ACCOUNT_STATES_ASC_SQL", + "const LIST_ACCOUNT_STATES_DESC_SQL", + "const LIST_ACCOUNT_STATES_BY_PUBKEY_ASC_SQL", + "const LIST_ACCOUNT_STATES_BY_PUBKEY_DESC_SQL", + ] { + let statement = source.lines().find(|line| return line.starts_with(name)); + let statement = match statement { + std::option::Option::Some(value) => value, + std::option::Option::None => panic!("missing canonical account keyset SQL: {name}"), + }; + assert!(!statement.contains(" OFFSET ")); + } + for name in ["const INSPECT_ACCOUNT_STATES_ASC_SQL", "const INSPECT_ACCOUNT_STATES_DESC_SQL"] { + let statement = source.lines().find(|line| return line.starts_with(name)); + let statement = match statement { + std::option::Option::Some(value) => value, + std::option::Option::None => panic!("missing account inspection SQL: {name}"), + }; + for required in ["COUNT(*)", "LEFT JOIN LATERAL", "OCTET_LENGTH(account_row.data)", "LIMIT $4 OFFSET $5"] { + assert!(statement.contains(required), "missing account inspection SQL contract: {required}"); + } + assert!(!statement.contains("account_row.data,")); + assert!(!statement.contains("SELECT account_row.data")); + assert!(statement.contains("ORDER BY account_row.slot")); + assert!(statement.contains("account_row.pubkey")); + assert!(statement.contains("account_row.state_hash")); + } + assert_eq!(source.matches(" OFFSET ").count(), 2); + return; +} diff --git a/crates/ksp-store-postgres-lib/tests/postgres_raw_account_live.rs b/crates/ksp-store-postgres-lib/tests/postgres_raw_account_live.rs index 9f62af8..fbac2ff 100644 --- a/crates/ksp-store-postgres-lib/tests/postgres_raw_account_live.rs +++ b/crates/ksp-store-postgres-lib/tests/postgres_raw_account_live.rs @@ -1,5 +1,5 @@ // file: crates/ksp-store-postgres-lib/tests/postgres_raw_account_live.rs -// version: 1 +// version: 2 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -68,7 +68,10 @@ enum LiveAccountPersistResult { #[test] #[ignore = "opt-in real PostgreSQL RawAccountState proof; reads one dedicated URI from stdin"] fn pre_009_real_postgres_raw_account_vertical_slice_is_atomic_concurrent_and_recoverable() { - eprintln!("KSP Store RawAccountState live proof: reading one dedicated URI from stdin without echoing it from the test."); + eprintln!( + "KSP Store RawAccountState live proof: waiting for one dedicated PostgreSQL URI line on stdin; the scenario does not start until Enter is pressed." + ); + eprintln!("KSP Store RawAccountState live proof: the test itself never echoes the URI."); let uri_result = read_uri_from_stdin(); let uri = match uri_result { std::result::Result::Ok(value) => value, @@ -179,6 +182,7 @@ async fn run_raw_account_scenario(admin: &mut tokio_postgres::Client, uri: &str, prove_atomic_idempotence_and_conflicts(&backend).await, prove_additional_observations(&backend).await, prove_pagination_and_cursors(&backend).await, + prove_account_inspection(&backend).await, prove_cross_family_coexistence(&backend, admin).await, ] { if let std::result::Result::Err(error) = proof { @@ -561,6 +565,80 @@ async fn prove_pagination_and_cursors(backend: &ksp_store_postgres_lib::Postgres return std::result::Result::Ok(()); } +async fn prove_account_inspection(backend: &ksp_store_postgres_lib::PostgresBackend) -> std::result::Result<(), LiveFailure> { + let filtered_query = match account_inspection_query( + std::option::Option::Some(ksp_store_api::Pubkey::new_from_array([50; 32])), + 200, + 202, + ksp_store_api::RawSortDirection::Ascending, + 1, + 2, + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let filtered = match backend.inspect_raw_account_states(&filtered_query).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(LiveFailure::new("inspection_filtered_query")), + }; + if filtered.filtered_items() != 3 || filtered.total_items() < 5 || filtered.items().len() != 2 { + return std::result::Result::Err(LiveFailure::new("inspection_filtered_counts")); + } + let first = &filtered.items()[0]; + let second = &filtered.items()[1]; + if first.reference().slot() != 200 + || first.reference().pubkey() != &ksp_store_api::Pubkey::new_from_array([50; 32]) + || first.reference().state_hash() != ksp_store_api::RawContentHash::new([51; 32]) + || first.data_length_bytes() != 2 + || second.reference().slot() != 201 + || second.reference().state_hash() != ksp_store_api::RawContentHash::new([52; 32]) + || second.data_length_bytes() != 2 + { + return std::result::Result::Err(LiveFailure::new("inspection_filtered_order")); + } + let descending_query = match account_inspection_query(std::option::Option::None, 200, 202, ksp_store_api::RawSortDirection::Descending, 1, 2) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let descending = match backend.inspect_raw_account_states(&descending_query).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(LiveFailure::new("inspection_descending_query")), + }; + if descending.filtered_items() != 5 || descending.items().len() != 2 { + return std::result::Result::Err(LiveFailure::new("inspection_descending_counts")); + } + let descending_first = &descending.items()[0]; + let descending_second = &descending.items()[1]; + if descending_first.reference().slot() != 201 + || descending_first.reference().pubkey() != &ksp_store_api::Pubkey::new_from_array([51; 32]) + || descending_first.reference().state_hash() != ksp_store_api::RawContentHash::new([53; 32]) + || descending_second.reference().slot() != 201 + || descending_second.reference().pubkey() != &ksp_store_api::Pubkey::new_from_array([50; 32]) + || descending_second.reference().state_hash() != ksp_store_api::RawContentHash::new([52; 32]) + { + return std::result::Result::Err(LiveFailure::new("inspection_descending_order")); + } + let deep_query = match account_inspection_query( + std::option::Option::Some(ksp_store_api::Pubkey::new_from_array([50; 32])), + 200, + 202, + ksp_store_api::RawSortDirection::Ascending, + 10_000, + 2, + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let deep = match backend.inspect_raw_account_states(&deep_query).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(LiveFailure::new("inspection_deep_query")), + }; + if deep.filtered_items() != 3 || deep.total_items() != filtered.total_items() || !deep.items().is_empty() { + return std::result::Result::Err(LiveFailure::new("inspection_deep_page")); + } + return std::result::Result::Ok(()); +} + async fn prove_cross_family_coexistence( backend: &ksp_store_postgres_lib::PostgresBackend, admin: &tokio_postgres::Client, @@ -850,6 +928,30 @@ fn account_query( return std::result::Result::Ok(ksp_store_api::RawAccountStateQuery::new(network, pubkey, slots, direction, page)); } +fn account_inspection_query( + pubkey: std::option::Option, + start: u64, + end: u64, + direction: ksp_store_api::RawSortDirection, + offset: u64, + limit: u64, +) -> std::result::Result { + let network = match network("devnet") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let slots = match ksp_store_api::RawSlotRange::new(std::option::Option::Some(start), std::option::Option::Some(end)) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(LiveFailure::new("inspection_model_slot_range")), + }; + let limit = match ksp_store_api::RawPageLimit::new(limit) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(LiveFailure::new("inspection_model_page_limit")), + }; + let page = ksp_store_api::RawInspectionPageRequest::new(offset, limit); + return std::result::Result::Ok(ksp_store_api::RawAccountStateInspectionQuery::new(network, pubkey, slots, direction, page)); +} + fn account_reference(pubkey_seed: u8, slot: u64, hash_seed: u8) -> std::result::Result { let network = match network("devnet") { std::result::Result::Ok(value) => value, diff --git a/crates/ksp-store-postgres-lib/tests/public_api.rs b/crates/ksp-store-postgres-lib/tests/public_api.rs index edb7bf3..0fec7e0 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: 15 +// version: 16 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -117,9 +117,11 @@ fn pre_005_raw_write_bridge_uses_only_backend_independent_models_and_outcomes() } #[test] -fn v0_3_8_pre_004_raw_list_and_inspection_bridges_use_only_backend_independent_models() { - let _list = ksp_store_postgres_lib::PostgresBackend::list_raw_transactions; - let _inspection = ksp_store_postgres_lib::PostgresBackend::inspect_raw_transactions; +fn v0_3_8_pre_005_raw_list_and_inspection_bridges_use_only_backend_independent_models() { + let _account_list = ksp_store_postgres_lib::PostgresBackend::list_raw_account_states; + let _account_inspection = ksp_store_postgres_lib::PostgresBackend::inspect_raw_account_states; + let _transaction_list = ksp_store_postgres_lib::PostgresBackend::list_raw_transactions; + let _transaction_inspection = ksp_store_postgres_lib::PostgresBackend::inspect_raw_transactions; return; } @@ -133,6 +135,7 @@ fn assert_raw_capabilities() where T: ksp_store_api::RawAccountObservationRead + ksp_store_api::RawAccountObservationWrite + + ksp_store_api::RawAccountStateInspectionRead + ksp_store_api::RawAccountStateRead + ksp_store_api::RawAccountStateWrite + ksp_store_api::RawTransactionInspectionRead @@ -148,7 +151,7 @@ where } #[test] -fn v0_3_8_pre_004_postgres_backend_implements_transaction_inspection_as_capability_11_of_11() { +fn v0_3_8_pre_005_postgres_backend_implements_both_inspection_capabilities_as_12_of_12() { assert_raw_capabilities::(); return; } diff --git a/crates/ksp-store-postgres-lib/unit_tests/raw_account.rs b/crates/ksp-store-postgres-lib/unit_tests/raw_account.rs index d77f446..f0d9e3e 100644 --- a/crates/ksp-store-postgres-lib/unit_tests/raw_account.rs +++ b/crates/ksp-store-postgres-lib/unit_tests/raw_account.rs @@ -1,5 +1,5 @@ // file: crates/ksp-store-postgres-lib/unit_tests/raw_account.rs -// version: 5 +// version: 6 fn network() -> ksp_store_api::RawNetworkId { return match ksp_store_api::RawNetworkId::new("devnet") { @@ -67,6 +67,95 @@ fn observation_row() -> super::RawAccountObservationDbRow { }; } + + return super::RawAccountInspectionDbRow { + data_length_bytes: std::option::Option::Some(data_length_bytes), + executable: std::option::Option::Some(true), + filtered_count_text: "3".to_owned(), + lamports_text: std::option::Option::Some(u64::MAX.to_string()), + owner: std::option::Option::Some(vec![2; 32]), + page_present: true, + pubkey: std::option::Option::Some(vec![1; 32]), + rent_epoch_text: std::option::Option::Some(u64::MAX.to_string()), + slot_text: std::option::Option::Some(u64::MAX.to_string()), + state_hash: std::option::Option::Some(vec![3; 32]), + total_count_text: "4".to_owned(), + }; +} + +#[test] +fn v0_3_8_pre_005_inspection_window_rejects_unrepresentable_limit_and_offset_before_io() { + let normal_limit = match ksp_store_api::RawPageLimit::new(100) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("valid account inspection page limit rejected: {error:?}"), + }; + let normal = super::raw_account_inspection_sql_window(ksp_store_api::RawInspectionPageRequest::new(25, normal_limit)); + assert_eq!(normal, std::result::Result::Ok((100, 25))); + let huge_limit = match ksp_store_api::RawPageLimit::new(u64::MAX) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("API unexpectedly rejected backend-physical account limit fixture: {error:?}"), + }; + let limit_error = super::raw_account_inspection_sql_window(ksp_store_api::RawInspectionPageRequest::new(0, huge_limit)); + assert_eq!( + limit_error.err().map(|value| return (value.kind(), value.phase())), + std::option::Option::Some((crate::PostgresBackendErrorKind::PageLimitUnsupported, "raw_account_inspection_limit")), + ); + let offset_error = super::raw_account_inspection_sql_window(ksp_store_api::RawInspectionPageRequest::new(u64::MAX, normal_limit)); + assert_eq!( + offset_error.err().map(|value| return (value.kind(), value.phase())), + std::option::Option::Some((crate::PostgresBackendErrorKind::QueryInvalid, "raw_account_inspection_offset")), + ); + return; +} + +#[test] +fn v0_3_8_pre_005_inspection_summary_is_data_free_and_preserves_complete_u64_domain() { + let network = network(); + let decoded = super::decode_raw_account_inspection_summary(&network, inspection_row(4)); + let summary = match decoded { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("valid account inspection summary rejected: {error:?}"), + }; + assert_eq!(summary.reference().network().as_str(), "devnet"); + assert_eq!(summary.reference().pubkey(), &ksp_store_api::Pubkey::new_from_array([1; 32])); + assert_eq!(summary.reference().slot(), u64::MAX); + assert_eq!(summary.reference().state_hash(), ksp_store_api::RawContentHash::new([3; 32])); + assert_eq!(summary.lamports(), u64::MAX); + assert_eq!(summary.owner(), &ksp_store_api::Pubkey::new_from_array([2; 32])); + assert!(summary.executable()); + assert_eq!(summary.rent_epoch(), u64::MAX); + assert_eq!(summary.data_length_bytes(), 4); + return; +} + +#[test] +fn v0_3_8_pre_005_inspection_summary_rejects_hostile_width_decimal_and_data_length() { + let network = network(); + let mut bad_owner = inspection_row(4); + bad_owner.owner = std::option::Option::Some(vec![2; 31]); + assert_eq!( + super::decode_raw_account_inspection_summary(&network, bad_owner).err().map(|value| return value.kind()), + std::option::Option::Some(crate::PostgresBackendErrorKind::DataInvalid) + ); + let mut bad_slot = inspection_row(4); + bad_slot.slot_text = std::option::Option::Some("18446744073709551616-HOSTILE".to_owned()); + let error = match super::decode_raw_account_inspection_summary(&network, bad_slot) { + std::result::Result::Err(value) => value, + std::result::Result::Ok(_) => panic!("hostile account inspection slot unexpectedly decoded"), + }; + assert_eq!(error.kind(), crate::PostgresBackendErrorKind::DataInvalid); + assert!(!std::format!("{error:?}").contains("HOSTILE")); + let maximum = match i64::try_from(ksp_store_api::MAX_RAW_ACCOUNT_DATA_BYTES) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => panic!("account data bound must fit PostgreSQL BIGINT in test fixture"), + }; + for length in [-1_i64, maximum.saturating_add(1)] { + let result = super::decode_raw_account_inspection_summary(&network, inspection_row(length)); + assert_eq!(result.err().map(|value| return value.kind()), std::option::Option::Some(crate::PostgresBackendErrorKind::DataInvalid)); + } + return; +} + #[test] fn pre_004_account_state_row_round_trips_complete_u64_domain_and_bytes() { let network = network(); diff --git a/deltas/0.3.8/pre.005.md b/deltas/0.3.8/pre.005.md new file mode 100644 index 0000000..a183970 --- /dev/null +++ b/deltas/0.3.8/pre.005.md @@ -0,0 +1,192 @@ + + + +# Delta `0.3.8-pre.005` — inspection PostgreSQL RawAccountState et dispatch Store + +## Base requise + +Base directe attendue : + +```text +0.3.8-pre.004-fix.001 +workspace.package.version = 0.3.8-pre.4.fix.1 +``` + +Le gate opérateur de cette base est entièrement vert et le live proof RawTransaction a réellement passé sur PostgreSQL 17. + +La livraison est : + +```text +0.3.8-pre.005 +workspace.package.version = 0.3.8-pre.5 +commit = v0.3.8-pre.005 +tag = aucun +``` + +## Objectif + +Compléter le second vertical slice physique de l'API d'inspection backend-neutral : + +```text +RawAccountStateInspectionRead + ksp-store-postgres-lib + -> ksp-store-lib::Store +``` + +Les deux familles RAW disposent alors de leur capability d'inspection random-access. Aucun branchement UI/DataTables, aucune migration et aucune capability destructive Account ne sont ajoutés. + +## SQL d'inspection Account + +Deux statements privés existent, un par direction canonique. Chaque appel exécute une seule instruction SQL comprenant : + +```text +filtered_count +conditional total_count +LATERAL page +ORDER BY slot, pubkey, state_hash +LIMIT/OFFSET +``` + +`filtered_items` applique le filtre exact `pubkey` optionnel et le range slot inclusif. `total_items` reste le nombre exact de lignes `ksp_raw_account_states`. Lorsqu'aucun filtre n'est actif, le count filtré est réutilisé comme total. + +Counts et page proviennent donc du même snapshot de statement PostgreSQL. + +## Aucun transfert de data bytes + +Le statement ne sélectionne jamais : + +```text +account_row.data +``` + +Il projette uniquement : + +```text +OCTET_LENGTH(account_row.data)::BIGINT AS data_length_bytes +``` + +Le summary reconstruit uniquement l'identité canonique, lamports, owner, executable, rent epoch et longueur des données. Aucun N+1 n'est introduit. + +## Pagination et limites physiques + +Le chemin inspection convertit avant I/O : + +```text +limit -> i64 PostgreSQL +offset -> i64 PostgreSQL +``` + +Un limit non représentable renvoie `PageLimitUnsupported`; un offset non représentable renvoie `QueryInvalid`. + +Les quatre statements Account historiques de pagination cursor/keyset restent OFFSET-free : + +```text +LIST_ACCOUNT_STATES_ASC_SQL +LIST_ACCOUNT_STATES_DESC_SQL +LIST_ACCOUNT_STATES_BY_PUBKEY_ASC_SQL +LIST_ACCOUNT_STATES_BY_PUBKEY_DESC_SQL +``` + +L'OFFSET reste donc strictement local à l'inspection random-access. + +## Façade Store et inventaire + +`PostgresBackend` et `Store` implémentent désormais `RawAccountStateInspectionRead`, avec guard network côté façade avant dispatch. + +L'inventaire RAW passe de 11 à 12 capabilities sur les deux couches : + +```text +11 historiques incluant RawTransactionInspectionRead ++ RawAccountStateInspectionRead += 12 +``` + +Les inventaires Store/PostgreSQL restent exactement symétriques. + +## Tests et live proof + +Le delta ajoute ou renforce les preuves suivantes : + +- conversion hostile limit/offset avant I/O ; +- summary data-free avec domaine `u64` complet ; +- rejet widths/décimaux/data length hostiles sans echo ; +- séparation explicite keyset OFFSET-free / inspection avec OFFSET ; +- SQL counted, single-statement, `OCTET_LENGTH(data)` et aucun data bytes ; +- inventaires exacts 12/12 ; +- live proof Account opt-in : filtre pubkey, counts, ordre asc/desc, offset, data length et page vide profonde. + +## Fichiers ajoutés + +```text +deltas/0.3.8/pre.005.md +``` + +## Fichiers modifiés + +```text +Cargo.toml +crates/ksp-store-lib/src/lib.rs +crates/ksp-store-lib/src/store.rs +crates/ksp-store-lib/tests/dependency_boundary.rs +crates/ksp-store-lib/tests/hardening_completeness.rs +crates/ksp-store-lib/tests/public_api.rs +crates/ksp-store-postgres-lib/src/lib.rs +crates/ksp-store-postgres-lib/src/raw_account.rs +crates/ksp-store-postgres-lib/src/runtime.rs +crates/ksp-store-postgres-lib/tests/dependency_boundary.rs +crates/ksp-store-postgres-lib/tests/hardening_completeness.rs +crates/ksp-store-postgres-lib/tests/postgres_raw_account_live.rs +crates/ksp-store-postgres-lib/tests/public_api.rs +crates/ksp-store-postgres-lib/unit_tests/raw_account.rs +docs/plans/029-V0_3_8_STORE_DESK_PLAN.md +docs/validation/025-V0_3_8_STORE_DESK.md +``` + +## Fichiers supprimés + +```text +aucun +``` + +## Validations d'assemblage + +Contrôles exécutés avant emballage : + +```text +General Rust rule audit: clean +Rust export completeness audit: 0 candidate(s) +KSP workspace Rust rule audit: clean +Markdown table audit: clean (314 table(s), 698 file(s)) +pre.005 structural contract audit: clean +workspace.package.version: 0.3.8-pre.5 +Store RAW capability implementations: 12 +PostgreSQL RAW capability implementations: 12 +RawAccount keyset OFFSET occurrences: 0 +RawAccount inspection OFFSET occurrences: 2 +delta scope: 1 ajout / 16 modifications / 0 suppression +``` + +Le sandbox d'assemblage ne dispose pas de `cargo`/`rustfmt`; aucun gate Cargo de `pre.005` n'est donc revendiqué localement. + +## Gate requis + +```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 +cargo check --workspace +cargo clippy --workspace --all-targets +cargo test -p ksp-store-postgres-lib +cargo test -p ksp-store-lib +cargo check -p ksp-store-lib --no-default-features +cargo test -p ksp-store-api +``` + +Live proof opt-in, uniquement sur base PostgreSQL dédiée vide : + +```bash +read -rsp 'Dedicated PostgreSQL URI: ' KSP_LIVE_PG_URI +printf '\n' +printf '%s\n' "$KSP_LIVE_PG_URI" | cargo test -p ksp-store-postgres-lib --test postgres_raw_account_live -- --ignored --nocapture +unset KSP_LIVE_PG_URI +``` diff --git a/docs/plans/029-V0_3_8_STORE_DESK_PLAN.md b/docs/plans/029-V0_3_8_STORE_DESK_PLAN.md index 5bb3630..a73e4fd 100644 --- a/docs/plans/029-V0_3_8_STORE_DESK_PLAN.md +++ b/docs/plans/029-V0_3_8_STORE_DESK_PLAN.md @@ -1,5 +1,5 @@ - + # Plan v0.3.8 — Store Desk V1 RAW @@ -572,7 +572,7 @@ Implémenter summary query, counts exacts, offset/limit checked et trait Tx dans ### pre.005 — inspection PostgreSQL RawAccountState -Même vertical slice pour Account : pubkey/slot filters, ordre canonique, data length sans data bytes, counts exacts, offset/limit checked, façade et canaris cross-family. +Même vertical slice pour Account : filtre exact `pubkey`, range slot inclusif, ordre canonique `(slot, pubkey, state_hash)`, `data_length_bytes` via `OCTET_LENGTH(data)` sans sélectionner `data`, counts exacts et page `LIMIT/OFFSET` dans une seule instruction SQL par direction. `RawAccountStateInspectionRead` est implémenté sur PostgreSQL puis dispatché par `Store`, ce qui porte l'inventaire symétrique à 12 capabilities RAW. Les quatre statements cursor/keyset Account restent strictement OFFSET-free et ne sont jamais réécrits. Le live proof opt-in couvre filtre pubkey, counts, asc/desc, offset et page vide profonde. ### pre.006 — composite Config + lifecycle Store + Overview diff --git a/docs/validation/025-V0_3_8_STORE_DESK.md b/docs/validation/025-V0_3_8_STORE_DESK.md index 3e8f880..9a89c99 100644 --- a/docs/validation/025-V0_3_8_STORE_DESK.md +++ b/docs/validation/025-V0_3_8_STORE_DESK.md @@ -1,5 +1,5 @@ - + # Validation v0.3.8 — Store Desk V1 RAW @@ -358,4 +358,49 @@ unset KSP_LIVE_PG_URI ``` Aucun SQL, contrat Store, runtime PostgreSQL ou comportement d'inspection de production n'est modifié par ce fix. +## 20. Gate opérateur `pre.004-fix.001` et live proof PostgreSQL + +Le rejeu opérateur sur `0.3.8-pre.4.fix.1` est entièrement vert : + +```text +General Rust rule audit: clean +Rust export completeness audit: 0 candidate(s) +KSP workspace Rust rule audit: clean +Markdown table audit: clean (314 table(s), 697 file(s)) +cargo check --workspace: PASS +cargo clippy --workspace --all-targets: PASS +cargo test -p ksp-store-postgres-lib: PASS +cargo test -p ksp-store-lib: PASS +cargo check -p ksp-store-lib --no-default-features: PASS +cargo test -p ksp-store-api: PASS +``` + +Le live proof `postgres_raw_transaction_live` a ensuite été exécuté sur une base PostgreSQL dédiée vide. Il atteint réellement PostgreSQL 17 et passe : + +```text +KSP Store RawTransaction live proof: server major 17 +test pre_009_real_postgres_raw_transaction_vertical_slice_is_atomic_concurrent_and_recoverable ... ok +``` + +`pre.004` est donc fermé sans réserve et `pre.005` peut ouvrir uniquement le vertical slice `RawAccountStateInspectionRead`. + +## 21. `pre.005` — inspection PostgreSQL RawAccountState + façade Store + +- [X] `workspace.package.version = 0.3.8-pre.5` ; +- [X] `PostgresBackend` implémente `RawAccountStateInspectionRead` ; +- [X] `Store` implémente le même trait avec guard network avant dispatch ; +- [X] les inventaires RAW Store/PostgreSQL passent de 11 à 12 et restent exactement symétriques ; +- [X] chaque direction d'inspection Account utilise une seule instruction SQL regroupant `filtered_count`, `total_count` conditionnel et page LATERAL ; +- [X] `filtered_items` applique le filtre exact `pubkey` optionnel et le range slot inclusif ; +- [X] `total_items` compte toute la famille `ksp_raw_account_states` et réutilise `filtered_count` lorsqu'aucun filtre n'est actif ; +- [X] l'ordre inspection est canonique `(slot, pubkey, state_hash)` en ascendant ou descendant ; +- [X] la page random-access utilise `LIMIT/OFFSET` uniquement dans le chemin inspection ; +- [X] limit/offset non représentables par PostgreSQL sont rejetés avant acquisition du pool ; +- [X] les quatre statements cursor/keyset Account historiques restent sans `OFFSET` ; +- [X] le SQL d'inspection ne sélectionne jamais `account_row.data` et projette seulement `OCTET_LENGTH(account_row.data)` ; +- [X] `RawAccountStateSummary` est reconstruit avec reference, lamports, owner, executable, rent epoch et data length uniquement ; +- [X] longueurs, widths et décimaux hostiles sont rejetés sans écho de valeur ; +- [X] une page vide obtenue par offset profond conserve les counts exacts ; +- [X] le live proof Account opt-in couvre filtre pubkey, counts, ordre asc/desc, offset, data length et page vide profonde ; +- [X] aucune migration, table, index, capability destructive Account, UI Desk ou protocole DataTables n'est ajouté.