v0.3.4-pre.007

This commit is contained in:
2026-08-30 22:47:33 +02:00
parent fccb7d876c
commit 902b5fba99
12 changed files with 904 additions and 16 deletions

View File

@@ -1,12 +1,12 @@
# file: Cargo.toml # file: Cargo.toml
# version: 372 # version: 373
[workspace] [workspace]
resolver = "3" 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"] 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] [workspace.package]
version = "0.3.4-pre.6" version = "0.3.4-pre.7"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-store-postgres-lib/src/lib.rs // file: crates/ksp-store-postgres-lib/src/lib.rs
// version: 19 // version: 20
#![warn(missing_docs)] #![warn(missing_docs)]
#![deny(unreachable_pub)] #![deny(unreachable_pub)]
@@ -70,10 +70,18 @@ pub(crate) use self::health::probe_health;
pub(crate) use self::migration::bootstrap; pub(crate) use self::migration::bootstrap;
/// Current embedded migration version consumed by the private health probe. /// Current embedded migration version consumed by the private health probe.
pub(crate) use self::migration::current_migration_version; 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. /// Private RAW account observation reader consumed by the physical backend runtime.
pub(crate) use self::raw_account::get_raw_account_observation; pub(crate) use self::raw_account::get_raw_account_observation;
/// Private RAW account state reader consumed by the physical backend runtime. /// Private RAW account state reader consumed by the physical backend runtime.
pub(crate) use self::raw_account::get_raw_account_state; 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. /// Private atomic RAW account acquisition writer consumed by the physical backend runtime.
pub(crate) use self::raw_account::persist_raw_account_acquisition; pub(crate) use self::raw_account::persist_raw_account_acquisition;
/// Private additional RAW account observation writer consumed by the physical backend runtime. /// Private additional RAW account observation writer consumed by the physical backend runtime.

View File

@@ -1,15 +1,27 @@
// file: crates/ksp-store-postgres-lib/src/raw_account.rs // 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_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 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_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 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_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 = 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"; "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"; 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<u8>,
slot_text: std::string::String,
state_hash: std::vec::Vec<u8>,
}
struct RawAccountObservationDbRow { struct RawAccountObservationDbRow {
account_pubkey: std::vec::Vec<u8>, account_pubkey: std::vec::Vec<u8>,
account_slot_text: std::string::String, 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)); 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<ksp_store_api::RawPage<ksp_store_api::RawAccountStateReference>, 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. /// Reads one persisted RAW account observation by producer-owned idempotence key.
pub(crate) async fn get_raw_account_observation( pub(crate) async fn get_raw_account_observation(
pool: &deadpool_postgres::Pool, pool: &deadpool_postgres::Pool,
@@ -235,6 +345,42 @@ pub(crate) async fn record_raw_account_observation(
return std::result::Result::Ok(outcome); return std::result::Result::Ok(outcome);
} }
fn raw_account_list_db_row(row: &tokio_postgres::Row) -> std::result::Result<RawAccountListDbRow, crate::PostgresBackendError> {
let pubkey = match row.try_get::<_, std::vec::Vec<u8>>("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<u8>>("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<RawAccountObservationDbRow, crate::PostgresBackendError> { fn raw_account_observation_db_row(row: &tokio_postgres::Row) -> std::result::Result<RawAccountObservationDbRow, crate::PostgresBackendError> {
let account_pubkey: std::vec::Vec<u8> = match row.try_get("account_pubkey") { let account_pubkey: std::vec::Vec<u8> = match row.try_get("account_pubkey") {
std::result::Result::Ok(value) => value, std::result::Result::Ok(value) => value,

View File

@@ -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<RawAccountDecodedCursor, crate::PostgresBackendError> {
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<ksp_store_api::RawPageCursor, crate::PostgresBackendError> {
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<u64>) {
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(());
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-store-postgres-lib/src/runtime.rs // file: crates/ksp-store-postgres-lib/src/runtime.rs
// version: 13 // version: 14
const APPLICATION_NAME: &str = "ksp-store"; const APPLICATION_NAME: &str = "ksp-store";
const MAX_CONNECTION_URI_BYTES: usize = 4_096; 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; 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<ksp_store_api::RawPage<ksp_store_api::RawAccountStateReference>, 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. /// Reads one persisted RAW account observation by producer-owned idempotence key.
pub async fn get_raw_account_observation( pub async fn get_raw_account_observation(
&self, &self,

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-store-postgres-lib/tests/dependency_boundary.rs // file: crates/ksp-store-postgres-lib/tests/dependency_boundary.rs
// version: 20 // version: 21
#![warn(missing_docs)] #![warn(missing_docs)]
#![deny(unreachable_pub)] #![deny(unreachable_pub)]
@@ -320,6 +320,59 @@ fn pre_006_raw_pagination_is_keyset_cursor_bound_and_policy_free() {
return; 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] #[test]
fn pre_007_raw_retention_is_atomic_compare_and_transition_without_fake_compaction() { fn pre_007_raw_retention_is_atomic_compare_and_transition_without_fake_compaction() {
let raw = include_str!("../src/raw_transaction.rs"); let raw = include_str!("../src/raw_transaction.rs");

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-store-postgres-lib/tests/hardening_completeness.rs // file: crates/ksp-store-postgres-lib/tests/hardening_completeness.rs
// version: 13 // version: 14
#![warn(missing_docs)] #![warn(missing_docs)]
#![deny(unreachable_pub)] #![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/migration.rs"),
include_str!("../src/health.rs"), include_str!("../src/health.rs"),
include_str!("../src/raw_account.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.rs"),
include_str!("../src/raw_transaction/cursor.rs"), include_str!("../src/raw_transaction/cursor.rs"),
] { ] {

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-store-postgres-lib/tests/public_api.rs // file: crates/ksp-store-postgres-lib/tests/public_api.rs
// version: 12 // version: 13
#![warn(missing_docs)] #![warn(missing_docs)]
#![deny(unreachable_pub)] #![deny(unreachable_pub)]
@@ -94,6 +94,12 @@ fn pre_006_raw_account_observation_write_bridge_uses_only_backend_independent_mo
return; 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] #[test]
fn pre_004_raw_read_bridge_uses_only_backend_independent_models() { fn pre_004_raw_read_bridge_uses_only_backend_independent_models() {
let _get = ksp_store_postgres_lib::PostgresBackend::get_raw_transaction; let _get = ksp_store_postgres_lib::PostgresBackend::get_raw_transaction;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-store-postgres-lib/unit_tests/raw_account.rs // file: crates/ksp-store-postgres-lib/unit_tests/raw_account.rs
// version: 3 // version: 4
fn network() -> ksp_store_api::RawNetworkId { fn network() -> ksp_store_api::RawNetworkId {
return match ksp_store_api::RawNetworkId::new("devnet") { 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<ksp_store_api::Pubkey>,
start: std::option::Option<u64>,
end: std::option::Option<u64>,
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 { fn state_row() -> super::RawAccountStateDbRow {
return super::RawAccountStateDbRow { return super::RawAccountStateDbRow {
data: vec![1, 2, 3, 4], 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")); assert!(!function.contains("ON CONFLICT DO UPDATE"));
return; 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;
}

248
deltas/0.3.4/pre.007.md Normal file
View File

@@ -0,0 +1,248 @@
<!-- file: deltas/0.3.4/pre.007.md -->
<!-- version: 1 -->
# 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<RawAccountStateReference>
```
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<RawAccountStateReference>
```
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`.

View File

@@ -1,5 +1,5 @@
<!-- file: docs/plans/025-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT_PLAN.md --> <!-- file: docs/plans/025-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT_PLAN.md -->
<!-- version: 10 --> <!-- version: 11 -->
# Plan `0.3.4` — Store/PostgreSQL `RawAccountState` + complétude RAW # 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 ### `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<RawAccountStateReference>`. 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 ### `pre.008` — Implémentations backend, dispatch Store et conformance 10/10

View File

@@ -1,5 +1,5 @@
<!-- file: docs/validation/021-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT.md --> <!-- file: docs/validation/021-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT.md -->
<!-- version: 9 --> <!-- version: 10 -->
# Validation `0.3.4` — Store/PostgreSQL `RawAccountState` + complétude RAW # 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.003 | contraintes complètes, index, schema compatibility, checksum V002 | PASS |
| pre.004 | mapping privé state/observation + get reads + hostile rows | PASS | | pre.004 | mapping privé state/observation + get reads + hostile rows | PASS |
| pre.005 | acquisition atomique state+observation + idempotence/conflict | PASS | | pre.005 | acquisition atomique state+observation + idempotence/conflict | PASS |
| pre.006 | observation supplémentaire + races/cancellation unitaires | RECHECK | | pre.006 | observation supplémentaire + races/cancellation unitaires | PASS |
| pre.007 | list RawAccountStateQuery + keyset cursor V1 account | PLANNED | | pre.007 | list RawAccountStateQuery + keyset cursor V1 account | RECHECK |
| pre.008 | 4 impl backend + 4 dispatch Store + conformance 10/10 | PLANNED | | pre.008 | 4 impl backend + 4 dispatch Store + conformance 10/10 | PLANNED |
| pre.009 | preuve PostgreSQL live account + coexistence RawTransaction | PLANNED | | pre.009 | preuve PostgreSQL live account + coexistence RawTransaction | PLANNED |
| pre.010 | hardening/completeness cross-family + canaries ownership | 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` ## 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 : 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`. 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`. 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<RawAccountStateReference>
```
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`.