From 902b5fba998029495b4ec88e5aefea2d360dff5e Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Sun, 30 Aug 2026 22:47:33 +0200 Subject: [PATCH] v0.3.4-pre.007 --- Cargo.toml | 4 +- crates/ksp-store-postgres-lib/src/lib.rs | 10 +- .../ksp-store-postgres-lib/src/raw_account.rs | 148 ++++++++++- .../src/raw_account/cursor.rs | 230 ++++++++++++++++ crates/ksp-store-postgres-lib/src/runtime.rs | 10 +- .../tests/dependency_boundary.rs | 55 +++- .../tests/hardening_completeness.rs | 3 +- .../tests/public_api.rs | 8 +- .../unit_tests/raw_account.rs | 149 ++++++++++- deltas/0.3.4/pre.007.md | 248 ++++++++++++++++++ ...-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT_PLAN.md | 10 +- .../021-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT.md | 45 +++- 12 files changed, 904 insertions(+), 16 deletions(-) create mode 100644 crates/ksp-store-postgres-lib/src/raw_account/cursor.rs create mode 100644 deltas/0.3.4/pre.007.md diff --git a/Cargo.toml b/Cargo.toml index 7e27285..4a845ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 372 +# version: 373 [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.4-pre.6" +version = "0.3.4-pre.7" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/crates/ksp-store-postgres-lib/src/lib.rs b/crates/ksp-store-postgres-lib/src/lib.rs index 3f2662a..a516d71 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: 19 +// version: 20 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -70,10 +70,18 @@ pub(crate) use self::health::probe_health; pub(crate) use self::migration::bootstrap; /// Current embedded migration version consumed by the private health probe. pub(crate) use self::migration::current_migration_version; +/// Private RAW account cursor decoder consumed by the physical RAW account module. +pub(crate) use self::raw_account::cursor::decode_raw_account_cursor; +/// Private RAW account cursor encoder consumed by the physical RAW account module. +pub(crate) use self::raw_account::cursor::encode_raw_account_cursor; +/// Private physical account page-limit converter consumed by the physical RAW account module. +pub(crate) use self::raw_account::cursor::raw_account_physical_page_limit; /// Private RAW account observation reader consumed by the physical backend runtime. pub(crate) use self::raw_account::get_raw_account_observation; /// Private RAW account state reader consumed by the physical backend runtime. 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 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. diff --git a/crates/ksp-store-postgres-lib/src/raw_account.rs b/crates/ksp-store-postgres-lib/src/raw_account.rs index 130665c..c3f366d 100644 --- a/crates/ksp-store-postgres-lib/src/raw_account.rs +++ b/crates/ksp-store-postgres-lib/src/raw_account.rs @@ -1,15 +1,27 @@ // file: crates/ksp-store-postgres-lib/src/raw_account.rs -// version: 4 +// version: 5 + +pub(crate) mod cursor; const GET_ACCOUNT_OBSERVATION_SQL: &str = "SELECT observation_key, account_pubkey, account_slot::text AS account_slot_text, 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::text AS write_version_text FROM ksp_raw_account_observations WHERE observation_key = $1"; 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 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"; +const LIST_ACCOUNT_STATES_DESC_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 DESC, pubkey DESC, state_hash DESC LIMIT $6"; const LOCK_ACCOUNT_OBSERVATION_SQL: &str = "SELECT observation_key, account_pubkey, account_slot::text AS account_slot_text, 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::text AS write_version_text FROM ksp_raw_account_observations WHERE observation_key = $1 FOR UPDATE"; const LOCK_ACCOUNT_REFERENCE_SQL: &str = "SELECT 1 FROM ksp_raw_account_states WHERE pubkey = $1 AND slot = $2::TEXT::NUMERIC AND state_hash = $3 FOR KEY SHARE"; const LOCK_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 FOR UPDATE"; +struct RawAccountListDbRow { + pubkey: std::vec::Vec, + slot_text: std::string::String, + state_hash: std::vec::Vec, +} + struct RawAccountObservationDbRow { account_pubkey: std::vec::Vec, account_slot_text: std::string::String, @@ -93,6 +105,104 @@ pub(crate) async fn get_raw_account_state( return std::result::Result::Ok(std::option::Option::Some(decoded)); } +/// Lists deterministic canonical RAW account-state references using PostgreSQL keyset pagination. +pub(crate) async fn list_raw_account_states( + pool: &deadpool_postgres::Pool, + network: &ksp_store_api::RawNetworkId, + query: &ksp_store_api::RawAccountStateQuery, +) -> std::result::Result, crate::PostgresBackendError> { + if query.network() != network { + return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::WrongNetwork, "raw_account_list_network")); + } + let (requested_usize, sql_limit) = match crate::raw_account_physical_page_limit(query.page().limit().get()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let decoded_cursor = match query.page().cursor() { + std::option::Option::Some(value) => match crate::decode_raw_account_cursor(query, value) { + std::result::Result::Ok(decoded) => std::option::Option::Some(decoded), + std::result::Result::Err(error) => return std::result::Result::Err(error), + }, + std::option::Option::None => std::option::Option::None, + }; + 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 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 cursor_slot_text = decoded_cursor.as_ref().map(|value| return value.last_slot.to_string()); + let cursor_pubkey = decoded_cursor.as_ref().map(|value| return value.last_pubkey.to_vec()); + let cursor_state_hash = decoded_cursor.as_ref().map(|value| return value.last_state_hash.to_vec()); + let rows_result = match query.pubkey() { + std::option::Option::Some(pubkey) => { + let pubkey_bytes: &[u8] = pubkey.as_ref(); + let sql = match query.direction() { + ksp_store_api::RawSortDirection::Ascending => LIST_ACCOUNT_STATES_BY_PUBKEY_ASC_SQL, + ksp_store_api::RawSortDirection::Descending => LIST_ACCOUNT_STATES_BY_PUBKEY_DESC_SQL, + _ => { + return std::result::Result::Err(crate::PostgresBackendError::new( + crate::PostgresBackendErrorKind::QueryInvalid, + "raw_account_list_direction", + )); + }, + }; + client.query(sql, &[&pubkey_bytes, &start_text, &end_text, &cursor_slot_text, &cursor_pubkey, &cursor_state_hash, &sql_limit]).await + }, + std::option::Option::None => { + let sql = match query.direction() { + ksp_store_api::RawSortDirection::Ascending => LIST_ACCOUNT_STATES_ASC_SQL, + ksp_store_api::RawSortDirection::Descending => LIST_ACCOUNT_STATES_DESC_SQL, + _ => { + return std::result::Result::Err(crate::PostgresBackendError::new( + crate::PostgresBackendErrorKind::QueryInvalid, + "raw_account_list_direction", + )); + }, + }; + client.query(sql, &[&start_text, &end_text, &cursor_slot_text, &cursor_pubkey, &cursor_state_hash, &sql_limit]).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_list_query")); + }, + }; + let mut decoded = std::vec::Vec::with_capacity(rows.len()); + for row in rows { + let physical = match raw_account_list_db_row(&row) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let item = match decode_raw_account_list_row(network, physical) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + decoded.push(item); + } + let has_more = decoded.len() > requested_usize; + if has_more { + decoded.truncate(requested_usize); + } + let next_cursor = if has_more { + let last = match decoded.last() { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(data_invalid("raw_account_list_page")), + }; + match crate::encode_raw_account_cursor(query, last.0, last.1.pubkey(), &last.1.state_hash()) { + std::result::Result::Ok(value) => std::option::Option::Some(value), + std::result::Result::Err(error) => return std::result::Result::Err(error), + } + } else { + std::option::Option::None + }; + let items = decoded.into_iter().map(|value| return value.1).collect(); + return std::result::Result::Ok(ksp_store_api::RawPage::new(items, next_cursor)); +} + /// Reads one persisted RAW account observation by producer-owned idempotence key. pub(crate) async fn get_raw_account_observation( pool: &deadpool_postgres::Pool, @@ -235,6 +345,42 @@ pub(crate) async fn record_raw_account_observation( return std::result::Result::Ok(outcome); } +fn raw_account_list_db_row(row: &tokio_postgres::Row) -> std::result::Result { + let pubkey = match row.try_get::<_, std::vec::Vec>("pubkey") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(data_invalid("raw_account_list_decode")), + }; + let slot_text = match row.try_get::<_, std::string::String>("slot_text") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(data_invalid("raw_account_list_decode")), + }; + let state_hash = match row.try_get::<_, std::vec::Vec>("state_hash") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(data_invalid("raw_account_list_decode")), + }; + return std::result::Result::Ok(RawAccountListDbRow { pubkey, slot_text, state_hash }); +} + +fn decode_raw_account_list_row( + network: &ksp_store_api::RawNetworkId, + row: RawAccountListDbRow, +) -> std::result::Result<(u64, ksp_store_api::RawAccountStateReference), crate::PostgresBackendError> { + let pubkey = match fixed_bytes::<32>(row.pubkey, "raw_account_list_pubkey") { + 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 slot = match decode_u64_decimal(row.slot_text.as_str(), "raw_account_list_slot") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let state_hash = match fixed_bytes::<32>(row.state_hash, "raw_account_list_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 reference = ksp_store_api::RawAccountStateReference::new(network.clone(), pubkey, slot, state_hash); + return std::result::Result::Ok((slot, reference)); +} + fn raw_account_observation_db_row(row: &tokio_postgres::Row) -> std::result::Result { let account_pubkey: std::vec::Vec = match row.try_get("account_pubkey") { std::result::Result::Ok(value) => value, diff --git a/crates/ksp-store-postgres-lib/src/raw_account/cursor.rs b/crates/ksp-store-postgres-lib/src/raw_account/cursor.rs new file mode 100644 index 0000000..681c3f2 --- /dev/null +++ b/crates/ksp-store-postgres-lib/src/raw_account/cursor.rs @@ -0,0 +1,230 @@ +// file: crates/ksp-store-postgres-lib/src/raw_account/cursor.rs +// version: 1 + +use sha2::Digest; // rust-rules: trait-import + +const CURSOR_BYTES: usize = 109; +const CURSOR_DIGEST_OFFSET: usize = 77; +const CURSOR_DOMAIN: &[u8] = b"KSP/raw-account-state-cursor/v1"; +const CURSOR_MAGIC: &[u8; 4] = b"KSPA"; +const CURSOR_VERSION: u8 = 1; +const MAX_POSTGRES_PAGE_LIMIT: u64 = 9_223_372_036_854_775_806; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +/// Decoded backend-private continuation key extracted from one validated RAW account cursor. +pub(crate) struct RawAccountDecodedCursor { + /// Last canonical account pubkey returned by the previous page. + pub(crate) last_pubkey: [u8; 32], + /// Last canonical account-state hash returned by the previous page. + pub(crate) last_state_hash: [u8; 32], + /// Last canonical account slot returned by the previous page. + pub(crate) last_slot: u64, +} + +/// Decodes and validates one backend-private RAW account cursor against its query binding. +pub(crate) fn decode_raw_account_cursor( + query: &ksp_store_api::RawAccountStateQuery, + cursor: &ksp_store_api::RawPageCursor, +) -> std::result::Result { + let bytes = cursor.as_bytes(); + if bytes.len() != CURSOR_BYTES { + return std::result::Result::Err(query_invalid("raw_account_cursor_size")); + } + if bytes.get(0..4) != std::option::Option::Some(CURSOR_MAGIC.as_ref()) { + return std::result::Result::Err(query_invalid("raw_account_cursor_magic")); + } + if bytes.get(4).copied() != std::option::Option::Some(CURSOR_VERSION) { + return std::result::Result::Err(query_invalid("raw_account_cursor_version")); + } + let slot_bytes = match bytes.get(5..13) { + std::option::Option::Some(value) => match <[u8; 8]>::try_from(value) { + std::result::Result::Ok(decoded) => decoded, + std::result::Result::Err(_) => return std::result::Result::Err(query_invalid("raw_account_cursor_slot")), + }, + std::option::Option::None => return std::result::Result::Err(query_invalid("raw_account_cursor_slot")), + }; + let last_slot = u64::from_be_bytes(slot_bytes); + let last_pubkey = match bytes.get(13..45) { + std::option::Option::Some(value) => match <[u8; 32]>::try_from(value) { + std::result::Result::Ok(decoded) => decoded, + std::result::Result::Err(_) => return std::result::Result::Err(query_invalid("raw_account_cursor_pubkey")), + }, + std::option::Option::None => return std::result::Result::Err(query_invalid("raw_account_cursor_pubkey")), + }; + let last_state_hash = match bytes.get(45..CURSOR_DIGEST_OFFSET) { + std::option::Option::Some(value) => match <[u8; 32]>::try_from(value) { + std::result::Result::Ok(decoded) => decoded, + std::result::Result::Err(_) => return std::result::Result::Err(query_invalid("raw_account_cursor_state_hash")), + }, + std::option::Option::None => return std::result::Result::Err(query_invalid("raw_account_cursor_state_hash")), + }; + let range_result = validate_slot_in_range(query.slots(), last_slot); + if let std::result::Result::Err(error) = range_result { + return std::result::Result::Err(error); + } + let pubkey_result = validate_pubkey_filter(query.pubkey(), &last_pubkey); + if let std::result::Result::Err(error) = pubkey_result { + return std::result::Result::Err(error); + } + let expected_digest = match binding_digest(query, last_slot, &last_pubkey, &last_state_hash) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let stored_digest = match bytes.get(CURSOR_DIGEST_OFFSET..CURSOR_BYTES) { + std::option::Option::Some(value) => match <[u8; 32]>::try_from(value) { + std::result::Result::Ok(decoded) => decoded, + std::result::Result::Err(_) => return std::result::Result::Err(query_invalid("raw_account_cursor_digest")), + }, + std::option::Option::None => return std::result::Result::Err(query_invalid("raw_account_cursor_digest")), + }; + if stored_digest != expected_digest { + return std::result::Result::Err(query_invalid("raw_account_cursor_binding")); + } + return std::result::Result::Ok(RawAccountDecodedCursor { last_pubkey, last_state_hash, last_slot }); +} + +/// Encodes one backend-private RAW account cursor bound to the supplied query context. +pub(crate) fn encode_raw_account_cursor( + query: &ksp_store_api::RawAccountStateQuery, + last_slot: u64, + last_pubkey: &ksp_store_api::Pubkey, + last_state_hash: &ksp_store_api::RawContentHash, +) -> std::result::Result { + let range_result = validate_slot_in_range(query.slots(), last_slot); + if let std::result::Result::Err(error) = range_result { + return std::result::Result::Err(error); + } + let pubkey_bytes = match <[u8; 32]>::try_from(last_pubkey.as_ref()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(query_invalid("raw_account_cursor_pubkey")), + }; + let pubkey_result = validate_pubkey_filter(query.pubkey(), &pubkey_bytes); + if let std::result::Result::Err(error) = pubkey_result { + return std::result::Result::Err(error); + } + let digest = match binding_digest(query, last_slot, &pubkey_bytes, last_state_hash.as_bytes()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let mut bytes = std::vec::Vec::with_capacity(CURSOR_BYTES); + bytes.extend_from_slice(CURSOR_MAGIC); + bytes.push(CURSOR_VERSION); + bytes.extend_from_slice(&last_slot.to_be_bytes()); + bytes.extend_from_slice(&pubkey_bytes); + bytes.extend_from_slice(last_state_hash.as_bytes()); + bytes.extend_from_slice(&digest); + if bytes.len() != CURSOR_BYTES { + return std::result::Result::Err(query_invalid("raw_account_cursor_encode_size")); + } + return match ksp_store_api::RawPageCursor::try_new(bytes.into_boxed_slice()) { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(_) => std::result::Result::Err(query_invalid("raw_account_cursor_encode")), + }; +} + +/// Converts one caller page size into the exact PostgreSQL LIMIT+1 representation. +pub(crate) fn raw_account_physical_page_limit(requested: u64) -> std::result::Result<(usize, i64), crate::PostgresBackendError> { + if requested > MAX_POSTGRES_PAGE_LIMIT { + return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::PageLimitUnsupported, "raw_account_page_limit")); + } + let requested_usize = match usize::try_from(requested) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::PageLimitUnsupported, "raw_account_page_limit")); + }, + }; + let requested_plus_one = match requested.checked_add(1) { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::PageLimitUnsupported, "raw_account_page_limit")); + }, + }; + let sql_limit = match i64::try_from(requested_plus_one) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::PageLimitUnsupported, "raw_account_page_limit")); + }, + }; + return std::result::Result::Ok((requested_usize, sql_limit)); +} + +fn binding_digest( + query: &ksp_store_api::RawAccountStateQuery, + last_slot: u64, + last_pubkey: &[u8; 32], + last_state_hash: &[u8; 32], +) -> std::result::Result<[u8; 32], crate::PostgresBackendError> { + let direction = match query.direction() { + ksp_store_api::RawSortDirection::Ascending => 0_u8, + ksp_store_api::RawSortDirection::Descending => 1_u8, + _ => return std::result::Result::Err(query_invalid("raw_account_cursor_direction")), + }; + let network_len = match u64::try_from(query.network().as_str().len()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(query_invalid("raw_account_cursor_network")), + }; + let mut hasher = sha2::Sha256::new(); + hasher.update(CURSOR_DOMAIN); + hasher.update(network_len.to_be_bytes()); + hasher.update(query.network().as_str().as_bytes()); + hash_optional_pubkey(&mut hasher, query.pubkey()); + hasher.update([direction]); + hash_optional_slot(&mut hasher, query.slots().start_inclusive()); + hash_optional_slot(&mut hasher, query.slots().end_inclusive()); + hasher.update(last_slot.to_be_bytes()); + hasher.update(last_pubkey); + hasher.update(last_state_hash); + let finalized = hasher.finalize(); + let mut digest = [0_u8; 32]; + digest.copy_from_slice(finalized.as_ref()); + return std::result::Result::Ok(digest); +} + +fn hash_optional_pubkey(hasher: &mut sha2::Sha256, value: std::option::Option<&ksp_store_api::Pubkey>) { + match value { + std::option::Option::Some(pubkey) => { + hasher.update([1_u8]); + hasher.update(pubkey.as_ref()); + }, + std::option::Option::None => hasher.update([0_u8]), + } + return; +} + +fn hash_optional_slot(hasher: &mut sha2::Sha256, value: std::option::Option) { + match value { + std::option::Option::Some(slot) => { + hasher.update([1_u8]); + hasher.update(slot.to_be_bytes()); + }, + std::option::Option::None => hasher.update([0_u8]), + } + return; +} + +fn query_invalid(phase: &'static str) -> crate::PostgresBackendError { + return crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::QueryInvalid, phase); +} + +fn validate_pubkey_filter(filter: std::option::Option<&ksp_store_api::Pubkey>, last_pubkey: &[u8; 32]) -> std::result::Result<(), crate::PostgresBackendError> { + if let std::option::Option::Some(pubkey) = filter + && pubkey.as_ref() != last_pubkey.as_slice() + { + return std::result::Result::Err(query_invalid("raw_account_cursor_pubkey_filter")); + } + return std::result::Result::Ok(()); +} + +fn validate_slot_in_range(range: ksp_store_api::RawSlotRange, slot: u64) -> std::result::Result<(), crate::PostgresBackendError> { + if let std::option::Option::Some(start) = range.start_inclusive() + && slot < start + { + return std::result::Result::Err(query_invalid("raw_account_cursor_slot_range")); + } + if let std::option::Option::Some(end) = range.end_inclusive() + && slot > end + { + return std::result::Result::Err(query_invalid("raw_account_cursor_slot_range")); + } + return std::result::Result::Ok(()); +} diff --git a/crates/ksp-store-postgres-lib/src/runtime.rs b/crates/ksp-store-postgres-lib/src/runtime.rs index 622d890..b05fa0f 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: 13 +// version: 14 const APPLICATION_NAME: &str = "ksp-store"; const MAX_CONNECTION_URI_BYTES: usize = 4_096; @@ -340,6 +340,14 @@ impl PostgresBackend { return crate::get_raw_account_state(&self.pool, &self.network, reference).await; } + /// Lists deterministic canonical RAW account-state references with a backend-owned opaque continuation cursor. + pub async fn list_raw_account_states( + &self, + query: &ksp_store_api::RawAccountStateQuery, + ) -> std::result::Result, crate::PostgresBackendError> { + return crate::list_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, diff --git a/crates/ksp-store-postgres-lib/tests/dependency_boundary.rs b/crates/ksp-store-postgres-lib/tests/dependency_boundary.rs index aae0597..6d5fcf0 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: 20 +// version: 21 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -320,6 +320,59 @@ fn pre_006_raw_pagination_is_keyset_cursor_bound_and_policy_free() { return; } +#[test] +fn pre_007_raw_account_pagination_is_keyset_cursor_bound_and_policy_free() { + let raw = include_str!("../src/raw_account.rs"); + let cursor = include_str!("../src/raw_account/cursor.rs"); + let index = include_str!("../migrations/v002_raw_account_state/indexes/001_ix_ksp_raw_account_states_slot_pubkey_state_hash.sql"); + for required in [ + "LIST_ACCOUNT_STATES_ASC_SQL", + "LIST_ACCOUNT_STATES_DESC_SQL", + "LIST_ACCOUNT_STATES_BY_PUBKEY_ASC_SQL", + "LIST_ACCOUNT_STATES_BY_PUBKEY_DESC_SQL", + "(slot, pubkey, state_hash) >", + "(slot, pubkey, state_hash) <", + "ORDER BY slot ASC, pubkey ASC, state_hash ASC", + "ORDER BY slot DESC, pubkey DESC, state_hash DESC", + "list_raw_account_states", + ] { + assert!(raw.contains(required), "missing pre.007 account keyset pagination contract: {required}"); + } + for required in [ + "CURSOR_BYTES: usize = 109", + "b\"KSPA\"", + "CURSOR_VERSION: u8 = 1", + "KSP/raw-account-state-cursor/v1", + "sha2::Sha256", + "query.network().as_str()", + "query.pubkey()", + "query.direction()", + "query.slots().start_inclusive()", + "query.slots().end_inclusive()", + "last_slot", + "last_pubkey", + "last_state_hash", + "PageLimitUnsupported", + "9_223_372_036_854_775_806", + ] { + 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"] { + 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::RawAccountStateRead for PostgresBackend", + "impl ksp_store_api::RawAccountStateWrite for PostgresBackend", + "impl ksp_store_api::RawAccountObservationRead for PostgresBackend", + "impl ksp_store_api::RawAccountObservationWrite for PostgresBackend", + ] { + assert!(!raw.contains(forbidden), "pre.007 opened account trait scope prematurely: {forbidden}"); + } + return; +} + #[test] fn pre_007_raw_retention_is_atomic_compare_and_transition_without_fake_compaction() { let raw = include_str!("../src/raw_transaction.rs"); diff --git a/crates/ksp-store-postgres-lib/tests/hardening_completeness.rs b/crates/ksp-store-postgres-lib/tests/hardening_completeness.rs index 7553cb3..3f3de3e 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: 13 +// version: 14 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -174,6 +174,7 @@ fn pre_009_backend_error_bridge_cannot_retain_external_error_or_secret_text() { include_str!("../src/migration.rs"), include_str!("../src/health.rs"), include_str!("../src/raw_account.rs"), + include_str!("../src/raw_account/cursor.rs"), include_str!("../src/raw_transaction.rs"), include_str!("../src/raw_transaction/cursor.rs"), ] { diff --git a/crates/ksp-store-postgres-lib/tests/public_api.rs b/crates/ksp-store-postgres-lib/tests/public_api.rs index 9ef19f1..ae1710a 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: 12 +// version: 13 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -94,6 +94,12 @@ fn pre_006_raw_account_observation_write_bridge_uses_only_backend_independent_mo return; } +#[test] +fn pre_007_raw_account_list_bridge_uses_backend_independent_query_page_and_reference_models() { + let _list = ksp_store_postgres_lib::PostgresBackend::list_raw_account_states; + return; +} + #[test] fn pre_004_raw_read_bridge_uses_only_backend_independent_models() { let _get = ksp_store_postgres_lib::PostgresBackend::get_raw_transaction; 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 50fcd0f..f096fbb 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: 3 +// version: 4 fn network() -> ksp_store_api::RawNetworkId { return match ksp_store_api::RawNetworkId::new("devnet") { @@ -8,6 +8,28 @@ fn network() -> ksp_store_api::RawNetworkId { }; } +fn page_query( + network_name: &str, + pubkey: std::option::Option, + start: std::option::Option, + end: std::option::Option, + direction: ksp_store_api::RawSortDirection, +) -> ksp_store_api::RawAccountStateQuery { + let network = match ksp_store_api::RawNetworkId::new(network_name) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("valid test network rejected: {error:?}"), + }; + let slots = match ksp_store_api::RawSlotRange::new(start, end) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("valid slot range rejected: {error:?}"), + }; + let limit = match ksp_store_api::RawPageLimit::new(2) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("valid page limit rejected: {error:?}"), + }; + return ksp_store_api::RawAccountStateQuery::new(network, pubkey, slots, direction, ksp_store_api::RawPageRequest::first(limit)); +} + fn state_row() -> super::RawAccountStateDbRow { return super::RawAccountStateDbRow { data: vec![1, 2, 3, 4], @@ -376,3 +398,128 @@ fn pre_006_additional_observation_race_and_cancellation_contract_is_transaction_ assert!(!function.contains("ON CONFLICT DO UPDATE")); return; } + +#[test] +fn pre_007_account_cursor_v1_round_trip_is_fixed_109_bytes_and_exact() { + let pubkey = ksp_store_api::Pubkey::new_from_array([7_u8; 32]); + let query = page_query( + "devnet", + std::option::Option::Some(pubkey.clone()), + std::option::Option::Some(10), + std::option::Option::Some(20), + ksp_store_api::RawSortDirection::Ascending, + ); + let state_hash = ksp_store_api::RawContentHash::new([9_u8; 32]); + let cursor = match crate::encode_raw_account_cursor(&query, 15, &pubkey, &state_hash) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("valid account cursor encoding rejected: {error:?}"), + }; + assert_eq!(cursor.as_bytes().len(), 109); + assert_eq!(cursor.as_bytes().get(0..4), std::option::Option::Some(b"KSPA".as_ref())); + assert_eq!(cursor.as_bytes().get(4).copied(), std::option::Option::Some(1)); + let decoded = match crate::decode_raw_account_cursor(&query, &cursor) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("valid account cursor decoding rejected: {error:?}"), + }; + assert_eq!(decoded.last_slot, 15); + assert_eq!(decoded.last_pubkey, [7_u8; 32]); + assert_eq!(decoded.last_state_hash, [9_u8; 32]); + assert_eq!(std::format!("{cursor:?}"), "RawPageCursor { len: 109 }"); + return; +} + +#[test] +fn pre_007_account_cursor_rejects_replay_across_query_context_and_transaction_family() { + let pubkey = ksp_store_api::Pubkey::new_from_array([4_u8; 32]); + let query = page_query( + "devnet", + std::option::Option::Some(pubkey.clone()), + std::option::Option::Some(10), + std::option::Option::Some(20), + ksp_store_api::RawSortDirection::Ascending, + ); + let state_hash = ksp_store_api::RawContentHash::new([5_u8; 32]); + let cursor = match crate::encode_raw_account_cursor(&query, 15, &pubkey, &state_hash) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("valid account cursor encoding rejected: {error:?}"), + }; + let other_network = page_query("mainnet-beta", Some(pubkey.clone()), Some(10), Some(20), ksp_store_api::RawSortDirection::Ascending); + let other_pubkey = + page_query("devnet", Some(ksp_store_api::Pubkey::new_from_array([6_u8; 32])), Some(10), Some(20), ksp_store_api::RawSortDirection::Ascending); + let other_direction = page_query("devnet", Some(pubkey.clone()), Some(10), Some(20), ksp_store_api::RawSortDirection::Descending); + let other_range = page_query("devnet", Some(pubkey.clone()), Some(11), Some(20), ksp_store_api::RawSortDirection::Ascending); + for candidate in [&other_network, &other_pubkey, &other_direction, &other_range] { + let error = crate::decode_raw_account_cursor(candidate, &cursor).err(); + assert_eq!(error.map(|value| return value.kind()), Some(crate::PostgresBackendErrorKind::QueryInvalid)); + } + let transaction_query = match ksp_store_api::RawSlotRange::new(Some(10), Some(20)) { + std::result::Result::Ok(slots) => { + let limit = match ksp_store_api::RawPageLimit::new(2) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("valid transaction page limit rejected: {error:?}"), + }; + ksp_store_api::RawTransactionQuery::new(network(), slots, ksp_store_api::RawSortDirection::Ascending, ksp_store_api::RawPageRequest::first(limit)) + }, + std::result::Result::Err(error) => panic!("valid transaction slot range rejected: {error:?}"), + }; + let signature = ksp_store_api::RawTransactionSignature::new([8_u8; 64]); + let transaction_cursor = match crate::encode_raw_transaction_cursor(&transaction_query, 15, &signature) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("valid transaction cursor encoding rejected: {error:?}"), + }; + let error = crate::decode_raw_account_cursor(&query, &transaction_cursor).err(); + assert_eq!(error.map(|value| return value.phase()), Some("raw_account_cursor_magic")); + return; +} + +#[test] +fn pre_007_account_cursor_hostile_size_magic_version_and_digest_are_rejected() { + let query = page_query("devnet", None, None, None, ksp_store_api::RawSortDirection::Ascending); + for size in [1_usize, 108, 109, 110, 4_096] { + let raw = match ksp_store_api::RawPageCursor::try_new(vec![0_u8; size].into_boxed_slice()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("bounded hostile cursor rejected by API before backend test: {error:?}"), + }; + assert!(crate::decode_raw_account_cursor(&query, &raw).is_err()); + } + let pubkey = ksp_store_api::Pubkey::new_from_array([3_u8; 32]); + let state_hash = ksp_store_api::RawContentHash::new([2_u8; 32]); + let valid = match crate::encode_raw_account_cursor(&query, 42, &pubkey, &state_hash) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("valid account cursor encoding rejected: {error:?}"), + }; + for index in [0_usize, 4, 108] { + let mut bytes = valid.as_bytes().to_vec(); + bytes[index] ^= 0xff; + let hostile = match ksp_store_api::RawPageCursor::try_new(bytes.into_boxed_slice()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("fixed-size hostile cursor rejected by API before backend test: {error:?}"), + }; + let error = crate::decode_raw_account_cursor(&query, &hostile).err(); + assert_eq!(error.map(|value| return value.kind()), Some(crate::PostgresBackendErrorKind::QueryInvalid)); + } + return; +} + +#[test] +fn pre_007_account_page_limit_exposes_only_the_real_postgres_limit_plus_one_boundary() { + let maximum = crate::raw_account_physical_page_limit(9_223_372_036_854_775_806); + assert_eq!(maximum, Ok((9_223_372_036_854_775_806_usize, i64::MAX))); + let rejected = crate::raw_account_physical_page_limit(9_223_372_036_854_775_807).err(); + assert_eq!(rejected.map(|value| return value.kind()), Some(crate::PostgresBackendErrorKind::PageLimitUnsupported)); + return; +} + +#[test] +fn pre_007_account_list_row_decodes_full_reference_without_integer_narrowing() { + let row = super::RawAccountListDbRow { pubkey: vec![1_u8; 32], slot_text: u64::MAX.to_string(), state_hash: vec![2_u8; 32] }; + let decoded = match super::decode_raw_account_list_row(&network(), row) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("valid account list row rejected: {error:?}"), + }; + assert_eq!(decoded.0, u64::MAX); + assert_eq!(decoded.1.pubkey(), &ksp_store_api::Pubkey::new_from_array([1_u8; 32])); + assert_eq!(decoded.1.slot(), u64::MAX); + assert_eq!(decoded.1.state_hash(), ksp_store_api::RawContentHash::new([2_u8; 32])); + return; +} diff --git a/deltas/0.3.4/pre.007.md b/deltas/0.3.4/pre.007.md new file mode 100644 index 0000000..4aceb7f --- /dev/null +++ b/deltas/0.3.4/pre.007.md @@ -0,0 +1,248 @@ + + + +# Delta `0.3.4-pre.007` — pagination account keyset + cursor `KSPA` + +## 1. Base requise + +```text +0.3.4-pre.6 +``` + +Le gate opérateur fourni le 2026-08-30 pour `pre.006` est entièrement vert : + +```text +cargo fmt --all PASS +audit Rust général / exports / workspace PASS +audit Markdown PASS — 239 tables / 143 files +cargo check --workspace PASS +cargo clippy --workspace --all-targets PASS +cargo test -p ksp-store-api PASS +cargo test -p ksp-store-lib PASS +cargo test -p ksp-store-postgres-lib PASS — 58 unit tests + canaris, live ignored +cargo test -p ksp-config-lib PASS — 128 unit tests + ownership/public API +cargo check -p ksp-store-lib --no-default-features PASS +``` + +L'observation supplémentaire account et son contrat transactionnel/cancellation sont donc considérés acquis. + +## 2. Objectif + +Implémenter uniquement la navigation `RawAccountStateQuery` : + +```text +ordre total (slot, pubkey, state_hash) +ASC / DESC complètement inversés +filtre pubkey optionnel +bornes de slot inclusives +keyset pagination uniquement +cursor account KSPA V1 +LIMIT + 1 sans policy KSP +``` + +Les quatre capabilities account et leur dispatch `Store` restent hors tranche jusqu'à `pre.008`. + +## 3. Version + +Le workspace passe à : + +```text +0.3.4-pre.7 +``` + +## 4. Requêtes physiques + +`raw_account.rs` ajoute quatre SELECT privés : + +```text +sans pubkey : ASC / DESC +avec pubkey : ASC / DESC +``` + +L'ordre total est : + +```text +ASC (slot ASC, pubkey ASC, state_hash ASC) +DESC (slot DESC, pubkey DESC, state_hash DESC) +``` + +Les continuations utilisent exclusivement : + +```text +(slot, pubkey, state_hash) > (last_slot, last_pubkey, last_state_hash) +(slot, pubkey, state_hash) < (last_slot, last_pubkey, last_state_hash) +``` + +Sans filtre, l'index V002 `(slot, pubkey, state_hash)` porte la navigation. Avec filtre pubkey, la PK `(pubkey, slot, state_hash)` porte le préfixe account et le même ordre déterministe. + +## 5. Cursor account V1 + +Nouveau module privé : + +```text +crates/ksp-store-postgres-lib/src/raw_account/cursor.rs +``` + +Format fixe : + +```text +0..4 magic KSPA +4 version 1 +5..13 last_slot u64 big-endian +13..45 last_pubkey 32 bytes +45..77 last_state_hash 32 bytes +77..109 SHA-256 binding digest +``` + +Domaine : + +```text +KSP/raw-account-state-cursor/v1 +``` + +Le digest lie : + +```text +network +pubkey filter presence/value +direction +slot.start_inclusive +slot.end_inclusive +last_slot +last_pubkey +last_state_hash +``` + +Un cursor transaction `KSPT` ne peut donc pas être rejoué comme cursor account et échoue dès le magic. + +## 6. Limite physique de page + +Comme la verticale transaction, le backend applique `LIMIT + 1` uniquement pour détecter une page suivante. Aucun maximum fonctionnel KSP n'est ajouté. + +La limite physique exacte reste : + +```text +9_223_372_036_854_775_806 +``` + +car `requested + 1` doit être représentable par un `i64` PostgreSQL. + +## 7. Mapping de liste + +Chaque row de liste revalide : + +```text +pubkey exact 32 bytes +slot NUMERIC(20,0)::text -> u64 exact +state_hash exact 32 bytes +``` + +Le résultat public du bridge est exclusivement : + +```text +RawPage +``` + +Aucun row PostgreSQL, SQL ou cursor décodé ne traverse la frontière backend. + +## 8. Bridge backend + +`PostgresBackend` expose : + +```text +list_raw_account_states( + &RawAccountStateQuery, +) -> RawPage +``` + +Ce bridge reste une méthode étroite. `RawAccountStateRead`, `RawAccountStateWrite`, `RawAccountObservationRead` et `RawAccountObservationWrite` ne sont toujours pas implémentés sur `PostgresBackend`. + +## 9. Scope négatif + +Restent absents : + +```text +OFFSET +batch max 500/1000 +priority / worker policy +clamp/min artificiel +UPDATE / DELETE account +ON CONFLICT DO UPDATE +impl RawAccountStateRead for PostgresBackend +impl RawAccountStateWrite for PostgresBackend +impl RawAccountObservationRead for PostgresBackend +impl RawAccountObservationWrite for PostgresBackend +Store dispatch account +``` + +Toutes les migrations V000/V001/V002 restent byte-inchangées. Le checksum V002 final reste : + +```text +ff21605ed45f7ab4c0f92bbb692700b4118a9488b04d50a31d259ac59bdb550e +``` + +## 10. Fichiers ajoutés + +```text +crates/ksp-store-postgres-lib/src/raw_account/cursor.rs +deltas/0.3.4/pre.007.md +``` + +## 11. Fichiers modifiés + +```text +Cargo.toml +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/public_api.rs +crates/ksp-store-postgres-lib/unit_tests/raw_account.rs +docs/plans/025-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT_PLAN.md +docs/validation/021-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT.md +``` + +## 12. Fichiers supprimés + +```text +aucun +``` + +## 13. Validations exécutées à l'assemblage + +```text +python3 scripts/audit_rust_workspace_rules.py PASS +python3 scripts/audit_markdown_tables.py ... deltas/0.3.4 PASS +migrations V000/V001/V002 byte-identiques à pre.006 PASS +scope pre.008 absent PASS +``` + +Le gate Cargo/Clippy/tests de `pre.007` doit être rejoué par l'opérateur. + +## 14. Validations non exécutées + +```text +cargo fmt --all +cargo check --workspace +cargo clippy --workspace --all-targets +cargo test -p ksp-store-api +cargo test -p ksp-store-lib +cargo test -p ksp-store-postgres-lib +cargo test -p ksp-config-lib +cargo check -p ksp-store-lib --no-default-features +``` + +`cargo` n'est pas disponible dans l'environnement d'assemblage. + +## 15. Décisions prises + +- le cursor account reste distinct de `KSPT` par magic et domaine SHA-256 ; +- le filtre pubkey fait partie du binding du cursor ; +- les deux chemins SQL avec/sans pubkey sont séparés pour conserver une surface physique compatible avec les indexes possédés ; +- aucune policy d'exécution n'est ajoutée à la pagination Store ; +- les quatre capabilities account restent groupées pour `pre.008`. + +## 16. Questions ouvertes + +Aucune question bloquante pour `pre.008`. diff --git a/docs/plans/025-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT_PLAN.md b/docs/plans/025-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT_PLAN.md index b7f262d..16a4b65 100644 --- a/docs/plans/025-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT_PLAN.md +++ b/docs/plans/025-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT_PLAN.md @@ -1,5 +1,5 @@ - + # Plan `0.3.4` — Store/PostgreSQL `RawAccountState` + complétude RAW @@ -647,9 +647,13 @@ Les canaris unitaires figent l'ordre transactionnel `BEGIN -> FOR KEY SHARE -> i ### `pre.007` — Liste account et cursor keyset V1 -**Statut : planifié.** +**Statut : réalisé ; gate opérateur complet à rejouer.** -Budget cible : **15-20 min**. Implémenter `RawAccountStateQuery`, la navigation keyset et le cursor V1 account `KSPA`, sans policy de batch/priorité. +Budget cible : **15-20 min**. `RawAccountStateQuery` est matérialisée dans le backend PostgreSQL avec navigation keyset sur l'ordre total `(slot, pubkey, state_hash)`, entièrement inversé en DESC. Les requêtes sans filtre utilisent l'index V002 `(slot, pubkey, state_hash)` ; les requêtes filtrées par pubkey conservent le même ordre total et sont compatibles avec la PK `(pubkey, slot, state_hash)`. Aucun `OFFSET` ni plafond fonctionnel KSP n'est ajouté. + +Le cursor V1 account est fixe à **109 bytes** : magic `KSPA`, version `1`, `last_slot` u64 big-endian, `last_pubkey` 32 bytes, `last_state_hash` 32 bytes et digest SHA-256. Le domaine `KSP/raw-account-state-cursor/v1` lie le réseau, la présence/valeur du filtre pubkey, les bornes de slot, la direction et la dernière clé complète ; les replays cross-query et cross-family `KSPT` sont rejetés. La seule limite de page est la limite physique PostgreSQL nécessaire à `LIMIT + 1`, soit `9_223_372_036_854_775_806`. + +`PostgresBackend::list_raw_account_states` expose uniquement `RawAccountStateQuery -> RawPage`. Les quatre implémentations `RawAccount*` et le dispatch `Store` restent réservés à `pre.008`. ### `pre.008` — Implémentations backend, dispatch Store et conformance 10/10 diff --git a/docs/validation/021-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT.md b/docs/validation/021-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT.md index ade440f..5468f5c 100644 --- a/docs/validation/021-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT.md +++ b/docs/validation/021-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT.md @@ -1,5 +1,5 @@ - + # Validation `0.3.4` — Store/PostgreSQL `RawAccountState` + complétude RAW @@ -276,8 +276,8 @@ Le test live account devra être opt-in/ignored, URI stdin, sans environnement n | pre.003 | contraintes complètes, index, schema compatibility, checksum V002 | PASS | | pre.004 | mapping privé state/observation + get reads + hostile rows | PASS | | pre.005 | acquisition atomique state+observation + idempotence/conflict | PASS | -| pre.006 | observation supplémentaire + races/cancellation unitaires | RECHECK | -| pre.007 | list RawAccountStateQuery + keyset cursor V1 account | PLANNED | +| pre.006 | observation supplémentaire + races/cancellation unitaires | PASS | +| pre.007 | list RawAccountStateQuery + keyset cursor V1 account | RECHECK | | pre.008 | 4 impl backend + 4 dispatch Store + conformance 10/10 | PLANNED | | pre.009 | preuve PostgreSQL live account + coexistence RawTransaction | PLANNED | | pre.010 | hardening/completeness cross-family + canaries ownership | PLANNED | @@ -480,7 +480,7 @@ Reste après `pre.005` : observation supplémentaire/concurrence ciblée (`pre.0 ## 23. Verdict `pre.006` -Observation supplémentaire account : **PASS statique ; gate opérateur complet à rejouer**. +Observation supplémentaire account : **PASS complet opérateur**. Surface ajoutée : @@ -501,4 +501,41 @@ L'idempotence et le conflit réutilisent le chemin `persist_account_observation_ La preuve de race/cancellation de cette tranche est volontairement unitaire/structurelle : les canaris imposent l'ordre `transaction -> FOR KEY SHARE -> persist/compare -> commit`, l'absence de commit anticipé et l'absence de tâche détachée ou de fuite volontaire du handle transactionnel. Une cancellation avant commit droppe donc le `Transaction` local et laisse le rollback à PostgreSQL. Les scénarios de vraies tâches concurrentes et `JoinHandle::abort()` restent au test live `pre.009`. Restent fermés : `list_raw_account_states`, cursor `KSPA`, `OFFSET`, `UPDATE`/`DELETE`, `ON CONFLICT DO UPDATE`, les quatre implémentations `RawAccount*` sur `PostgresBackend` et tout dispatch account dans `Store`. Les migrations V000/V001/V002 sont inchangées ; le checksum V002 final reste `ff21605ed45f7ab4c0f92bbb692700b4118a9488b04d50a31d259ac59bdb550e`. +## 24. Verdict `pre.007` + +Pagination `RawAccountStateQuery` et cursor `KSPA` : **PASS statique ; gate opérateur complet à rejouer**. + +Ordre physique figé : + +```text +ASC (slot ASC, pubkey ASC, state_hash ASC) +DESC (slot DESC, pubkey DESC, state_hash DESC) +``` + +Deux familles de SELECT sont utilisées pour chaque direction : sans filtre pubkey, l'index V002 `(slot, pubkey, state_hash)` porte la navigation ; avec filtre pubkey, la PK `(pubkey, slot, state_hash)` porte le préfixe account puis le même ordre total. Les predicates de continuation utilisent exclusivement les comparaisons tuple `>` / `<`. Aucun `OFFSET`, batch maximum, priorité, clamp ou policy worker n'est introduit. + +Le cursor account V1 est fixe à 109 bytes : + +```text +0..4 magic KSPA +4 version 1 +5..13 last_slot u64 big-endian +13..45 last_pubkey 32 bytes +45..77 last_state_hash 32 bytes +77..109 SHA-256 binding digest +``` + +Le digest utilise le domaine `KSP/raw-account-state-cursor/v1` et lie le réseau, le filtre pubkey optionnel, la direction, les bornes de slot et la dernière clé complète. Les canaris couvrent round-trip, taille/magic/version/digest hostiles, replay réseau/pubkey/direction/plage et replay cross-family depuis `KSPT`. + +La page conserve la sémantique Store existante : `LIMIT + 1` détecte la continuation, le backend n'impose aucun maximum fonctionnel KSP et expose seulement la borne PostgreSQL `9_223_372_036_854_775_806` nécessaire pour convertir `requested + 1` en `i64`. Les rows de liste revalident pubkey/hash à 32 bytes et `slot` via decimal exact `u64`, sans narrowing. + +Surface runtime ajoutée : + +```text +PostgresBackend::list_raw_account_states( + &RawAccountStateQuery, +) -> RawPage +``` + +Les quatre `impl RawAccount* for PostgresBackend` et tout dispatch account dans `ksp-store-lib::Store` restent absents jusqu'à `pre.008`. Les migrations V000/V001/V002 restent byte-inchangées et le checksum V002 demeure `ff21605ed45f7ab4c0f92bbb692700b4118a9488b04d50a31d259ac59bdb550e`.