v0.3.3-pre.002
This commit is contained in:
@@ -1,12 +1,12 @@
|
|||||||
# file: Cargo.toml
|
# file: Cargo.toml
|
||||||
# version: 348
|
# version: 349
|
||||||
|
|
||||||
[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.3-pre.1"
|
version = "0.3.3-pre.2"
|
||||||
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"
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
// file: crates/ksp-store-postgres-lib/src/migration.rs
|
// file: crates/ksp-store-postgres-lib/src/migration.rs
|
||||||
// version: 2
|
// version: 3
|
||||||
|
|
||||||
use sha2::Digest; // rust-rules: trait-import
|
use sha2::Digest; // rust-rules: trait-import
|
||||||
|
|
||||||
const ADVISORY_LOCK_KEY: i64 = 0x4b53_5053_544f_5245;
|
const ADVISORY_LOCK_KEY: i64 = 0x4b53_5053_544f_5245;
|
||||||
const BOOTSTRAP_MIGRATION_NAME: &str = "bootstrap";
|
const EMBEDDED_MIGRATIONS: &[EmbeddedMigration] = &[EmbeddedMigration {
|
||||||
const BOOTSTRAP_MIGRATION_SQL: &str = include_str!("../migrations/V000__bootstrap.sql");
|
hook: MigrationHook::None,
|
||||||
const BOOTSTRAP_MIGRATION_VERSION: i64 = 0;
|
name: "bootstrap",
|
||||||
|
sql: include_str!("../migrations/V000__bootstrap.sql"),
|
||||||
|
version: 0,
|
||||||
|
}];
|
||||||
const HEX_LOWER: &[u8; 16] = b"0123456789abcdef";
|
const HEX_LOWER: &[u8; 16] = b"0123456789abcdef";
|
||||||
const HISTORY_INSERT_SQL: &str = "INSERT INTO ksp_store_schema_migrations (version, name, checksum, applied_at) VALUES ($1, $2, $3, CURRENT_TIMESTAMP)";
|
const HISTORY_INSERT_SQL: &str = "INSERT INTO ksp_store_schema_migrations (version, name, checksum, applied_at) VALUES ($1, $2, $3, CURRENT_TIMESTAMP)";
|
||||||
const HISTORY_LOAD_SQL: &str = "SELECT version, name, checksum FROM ksp_store_schema_migrations ORDER BY version";
|
const HISTORY_LOAD_SQL: &str = "SELECT version, name, checksum FROM ksp_store_schema_migrations ORDER BY version";
|
||||||
@@ -40,20 +43,44 @@ struct AppliedMigration {
|
|||||||
version: i64,
|
version: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct EmbeddedMigration {
|
||||||
|
hook: MigrationHook,
|
||||||
|
name: &'static str,
|
||||||
|
sql: &'static str,
|
||||||
|
version: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
enum MigrationHook {
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
enum MigrationHookContext {
|
||||||
|
AppliedNow,
|
||||||
|
Existing,
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the latest migration version embedded by this backend runtime.
|
/// Returns the latest migration version embedded by this backend runtime.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub(crate) const fn current_migration_version() -> i64 {
|
pub(crate) const fn current_migration_version() -> i64 {
|
||||||
return BOOTSTRAP_MIGRATION_VERSION;
|
return EMBEDDED_MIGRATIONS[EMBEDDED_MIGRATIONS.len() - 1].version;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs the private bounded PostgreSQL schema bootstrap on one dedicated pooled client.
|
/// Runs the private bounded PostgreSQL schema bootstrap on one dedicated pooled client.
|
||||||
pub(crate) async fn bootstrap(
|
pub(crate) async fn bootstrap(
|
||||||
client: &mut deadpool_postgres::Client,
|
client: &mut deadpool_postgres::Client,
|
||||||
|
network: &ksp_store_api::RawNetworkId,
|
||||||
auto_migrate: bool,
|
auto_migrate: bool,
|
||||||
migration_timeout: std::time::Duration,
|
migration_timeout: std::time::Duration,
|
||||||
migration_lock_timeout: std::time::Duration,
|
migration_lock_timeout: std::time::Duration,
|
||||||
) -> std::result::Result<(), crate::PostgresBackendError> {
|
) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||||
let bounded = tokio::time::timeout(migration_timeout, bootstrap_inner(client, auto_migrate, migration_timeout, migration_lock_timeout)).await;
|
let registry_result = validate_embedded_registry(EMBEDDED_MIGRATIONS);
|
||||||
|
if let std::result::Result::Err(error) = registry_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let bounded = tokio::time::timeout(migration_timeout, bootstrap_inner(client, network, auto_migrate, migration_timeout, migration_lock_timeout)).await;
|
||||||
return match bounded {
|
return match bounded {
|
||||||
std::result::Result::Ok(result) => result,
|
std::result::Result::Ok(result) => result,
|
||||||
std::result::Result::Err(_) => {
|
std::result::Result::Err(_) => {
|
||||||
@@ -64,6 +91,7 @@ pub(crate) async fn bootstrap(
|
|||||||
|
|
||||||
async fn bootstrap_inner(
|
async fn bootstrap_inner(
|
||||||
client: &mut deadpool_postgres::Client,
|
client: &mut deadpool_postgres::Client,
|
||||||
|
network: &ksp_store_api::RawNetworkId,
|
||||||
auto_migrate: bool,
|
auto_migrate: bool,
|
||||||
migration_timeout: std::time::Duration,
|
migration_timeout: std::time::Duration,
|
||||||
migration_lock_timeout: std::time::Duration,
|
migration_lock_timeout: std::time::Duration,
|
||||||
@@ -88,27 +116,7 @@ async fn bootstrap_inner(
|
|||||||
std::result::Result::Ok(value) => value,
|
std::result::Result::Ok(value) => value,
|
||||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
};
|
};
|
||||||
let checksum = bootstrap_checksum();
|
let next_index = if metadata_exists {
|
||||||
if !metadata_exists {
|
|
||||||
if !auto_migrate {
|
|
||||||
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "migration_pending"));
|
|
||||||
}
|
|
||||||
let create_result = transaction.batch_execute(BOOTSTRAP_MIGRATION_SQL).await;
|
|
||||||
if create_result.is_err() {
|
|
||||||
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "metadata_create"));
|
|
||||||
}
|
|
||||||
let shape_result = verify_metadata_shape(&transaction).await;
|
|
||||||
if let std::result::Result::Err(error) = shape_result {
|
|
||||||
return std::result::Result::Err(error);
|
|
||||||
}
|
|
||||||
let insert_result = transaction.execute(HISTORY_INSERT_SQL, &[&BOOTSTRAP_MIGRATION_VERSION, &BOOTSTRAP_MIGRATION_NAME, &checksum]).await;
|
|
||||||
match insert_result {
|
|
||||||
std::result::Result::Ok(1) => {},
|
|
||||||
std::result::Result::Ok(_) | std::result::Result::Err(_) => {
|
|
||||||
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "history_insert"));
|
|
||||||
},
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let shape_result = verify_metadata_shape(&transaction).await;
|
let shape_result = verify_metadata_shape(&transaction).await;
|
||||||
if let std::result::Result::Err(error) = shape_result {
|
if let std::result::Result::Err(error) = shape_result {
|
||||||
return std::result::Result::Err(error);
|
return std::result::Result::Err(error);
|
||||||
@@ -118,10 +126,24 @@ async fn bootstrap_inner(
|
|||||||
std::result::Result::Ok(value) => value,
|
std::result::Result::Ok(value) => value,
|
||||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
};
|
};
|
||||||
let validation_result = validate_history(history.as_slice(), checksum.as_str());
|
let validation_result = validate_history(history.as_slice(), EMBEDDED_MIGRATIONS);
|
||||||
if let std::result::Result::Err(error) = validation_result {
|
match validation_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let existing_hook_result = run_applied_migration_hooks(&transaction, network, next_index).await;
|
||||||
|
if let std::result::Result::Err(error) = existing_hook_result {
|
||||||
return std::result::Result::Err(error);
|
return std::result::Result::Err(error);
|
||||||
}
|
}
|
||||||
|
if next_index < EMBEDDED_MIGRATIONS.len() && !auto_migrate {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "migration_pending"));
|
||||||
|
}
|
||||||
|
let apply_result = apply_pending_migrations(&transaction, network, next_index).await;
|
||||||
|
if let std::result::Result::Err(error) = apply_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
}
|
}
|
||||||
let commit_result = transaction.commit().await;
|
let commit_result = transaction.commit().await;
|
||||||
return match commit_result {
|
return match commit_result {
|
||||||
@@ -171,20 +193,75 @@ async fn acquire_advisory_lock(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn set_statement_timeout(
|
async fn apply_migration(
|
||||||
transaction: &deadpool_postgres::Transaction<'_>,
|
transaction: &deadpool_postgres::Transaction<'_>,
|
||||||
timeout: std::time::Duration,
|
network: &ksp_store_api::RawNetworkId,
|
||||||
|
migration: &EmbeddedMigration,
|
||||||
) -> std::result::Result<(), crate::PostgresBackendError> {
|
) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||||
let timeout_value = format!("{}ms", timeout.as_millis());
|
let execute_result = transaction.batch_execute(migration.sql).await;
|
||||||
let result = transaction.query_one(SET_STATEMENT_TIMEOUT_SQL, &[&timeout_value]).await;
|
if execute_result.is_err() {
|
||||||
return match result {
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "migration_apply"));
|
||||||
std::result::Result::Ok(_) => std::result::Result::Ok(()),
|
}
|
||||||
std::result::Result::Err(_) => {
|
let shape_result = verify_metadata_shape(transaction).await;
|
||||||
std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "statement_timeout"))
|
if let std::result::Result::Err(error) = shape_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let hook_result = run_migration_hook(transaction, network, migration.hook, MigrationHookContext::AppliedNow).await;
|
||||||
|
if let std::result::Result::Err(error) = hook_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let checksum = migration_checksum(migration.sql);
|
||||||
|
let insert_result = transaction.execute(HISTORY_INSERT_SQL, &[&migration.version, &migration.name, &checksum]).await;
|
||||||
|
return match insert_result {
|
||||||
|
std::result::Result::Ok(1) => std::result::Result::Ok(()),
|
||||||
|
std::result::Result::Ok(_) | std::result::Result::Err(_) => {
|
||||||
|
std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "history_insert"))
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn apply_pending_migrations(
|
||||||
|
transaction: &deadpool_postgres::Transaction<'_>,
|
||||||
|
network: &ksp_store_api::RawNetworkId,
|
||||||
|
next_index: usize,
|
||||||
|
) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||||
|
let mut index = next_index;
|
||||||
|
while index < EMBEDDED_MIGRATIONS.len() {
|
||||||
|
let migration = &EMBEDDED_MIGRATIONS[index];
|
||||||
|
let result = apply_migration(transaction, network, migration).await;
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_history(transaction: &deadpool_postgres::Transaction<'_>) -> std::result::Result<std::vec::Vec<AppliedMigration>, crate::PostgresBackendError> {
|
||||||
|
let result = transaction.query(HISTORY_LOAD_SQL, &[]).await;
|
||||||
|
let rows = match result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "history_load"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let mut history = std::vec::Vec::with_capacity(rows.len());
|
||||||
|
for row in rows {
|
||||||
|
let version_result = row.try_get::<usize, i64>(0);
|
||||||
|
let name_result = row.try_get::<usize, std::string::String>(1);
|
||||||
|
let checksum_result = row.try_get::<usize, std::string::String>(2);
|
||||||
|
match (version_result, name_result, checksum_result) {
|
||||||
|
(std::result::Result::Ok(version), std::result::Result::Ok(name), std::result::Result::Ok(checksum)) => {
|
||||||
|
history.push(AppliedMigration { checksum, name, version });
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "history_decode"));
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(history);
|
||||||
|
}
|
||||||
|
|
||||||
async fn metadata_exists(transaction: &deadpool_postgres::Transaction<'_>) -> std::result::Result<bool, crate::PostgresBackendError> {
|
async fn metadata_exists(transaction: &deadpool_postgres::Transaction<'_>) -> std::result::Result<bool, crate::PostgresBackendError> {
|
||||||
let result = transaction.query_one(METADATA_EXISTS_SQL, &[]).await;
|
let result = transaction.query_one(METADATA_EXISTS_SQL, &[]).await;
|
||||||
let row = match result {
|
let row = match result {
|
||||||
@@ -201,6 +278,104 @@ async fn metadata_exists(transaction: &deadpool_postgres::Transaction<'_>) -> st
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn migration_checksum(sql: &str) -> std::string::String {
|
||||||
|
let mut hasher = sha2::Sha256::new();
|
||||||
|
hasher.update(sql.as_bytes());
|
||||||
|
let digest = hasher.finalize();
|
||||||
|
let bytes = digest.as_slice();
|
||||||
|
let mut encoded = std::string::String::with_capacity(bytes.len() * 2);
|
||||||
|
for byte in bytes {
|
||||||
|
let value = *byte;
|
||||||
|
encoded.push(char::from(HEX_LOWER[(value >> 4) as usize]));
|
||||||
|
encoded.push(char::from(HEX_LOWER[(value & 0x0f) as usize]));
|
||||||
|
}
|
||||||
|
return encoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_applied_migration_hooks(
|
||||||
|
transaction: &deadpool_postgres::Transaction<'_>,
|
||||||
|
network: &ksp_store_api::RawNetworkId,
|
||||||
|
applied_count: usize,
|
||||||
|
) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||||
|
let mut index = 0_usize;
|
||||||
|
while index < applied_count {
|
||||||
|
let migration = &EMBEDDED_MIGRATIONS[index];
|
||||||
|
let result = run_migration_hook(transaction, network, migration.hook, MigrationHookContext::Existing).await;
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_migration_hook(
|
||||||
|
_transaction: &deadpool_postgres::Transaction<'_>,
|
||||||
|
_network: &ksp_store_api::RawNetworkId,
|
||||||
|
hook: MigrationHook,
|
||||||
|
_context: MigrationHookContext,
|
||||||
|
) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||||
|
return match hook {
|
||||||
|
MigrationHook::None => std::result::Result::Ok(()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_statement_timeout(
|
||||||
|
transaction: &deadpool_postgres::Transaction<'_>,
|
||||||
|
timeout: std::time::Duration,
|
||||||
|
) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||||
|
let timeout_value = format!("{}ms", timeout.as_millis());
|
||||||
|
let result = transaction.query_one(SET_STATEMENT_TIMEOUT_SQL, &[&timeout_value]).await;
|
||||||
|
return match result {
|
||||||
|
std::result::Result::Ok(_) => std::result::Result::Ok(()),
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "statement_timeout"))
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_embedded_registry(migrations: &[EmbeddedMigration]) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||||
|
if migrations.is_empty() {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "registry_empty"));
|
||||||
|
}
|
||||||
|
let mut expected_version = 0_i64;
|
||||||
|
for migration in migrations {
|
||||||
|
if migration.version != expected_version || migration.name.is_empty() || migration.sql.is_empty() {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "registry_invalid"));
|
||||||
|
}
|
||||||
|
expected_version = match expected_version.checked_add(1) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "registry_invalid"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_history(history: &[AppliedMigration], migrations: &[EmbeddedMigration]) -> std::result::Result<usize, crate::PostgresBackendError> {
|
||||||
|
if history.is_empty() {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "history_missing"));
|
||||||
|
}
|
||||||
|
let latest_version = migrations[migrations.len() - 1].version;
|
||||||
|
let mut index = 0_usize;
|
||||||
|
for applied in history {
|
||||||
|
if applied.version > latest_version {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::SchemaNewer, "history_newer"));
|
||||||
|
}
|
||||||
|
if index >= migrations.len() {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::SchemaNewer, "history_newer"));
|
||||||
|
}
|
||||||
|
let expected = &migrations[index];
|
||||||
|
let expected_checksum = migration_checksum(expected.sql);
|
||||||
|
if applied.version != expected.version || applied.name != expected.name || applied.checksum != expected_checksum {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "history_diverged"));
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(index);
|
||||||
|
}
|
||||||
|
|
||||||
async fn verify_metadata_shape(transaction: &deadpool_postgres::Transaction<'_>) -> std::result::Result<(), crate::PostgresBackendError> {
|
async fn verify_metadata_shape(transaction: &deadpool_postgres::Transaction<'_>) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||||
let result = transaction.query(METADATA_SHAPE_SQL, &[]).await;
|
let result = transaction.query(METADATA_SHAPE_SQL, &[]).await;
|
||||||
let rows = match result {
|
let rows = match result {
|
||||||
@@ -209,23 +384,21 @@ async fn verify_metadata_shape(transaction: &deadpool_postgres::Transaction<'_>)
|
|||||||
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "metadata_shape"));
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "metadata_shape"));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let expected = [("version", "bigint", "NO"), ("name", "text", "NO"), ("checksum", "text", "NO"), ("applied_at", "timestamp with time zone", "NO")];
|
const REQUIRED: [(&str, &str, &str); 4] =
|
||||||
let mut found = [false; 4];
|
[("version", "bigint", "NO"), ("name", "text", "NO"), ("checksum", "text", "NO"), ("applied_at", "timestamp with time zone", "NO")];
|
||||||
|
let mut found = [false; REQUIRED.len()];
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let column_result = row.try_get::<usize, std::string::String>(0);
|
let name_result = row.try_get::<usize, std::string::String>(0);
|
||||||
let data_type_result = row.try_get::<usize, std::string::String>(1);
|
let data_type_result = row.try_get::<usize, std::string::String>(1);
|
||||||
let nullable_result = row.try_get::<usize, std::string::String>(2);
|
let nullable_result = row.try_get::<usize, std::string::String>(2);
|
||||||
let (column, data_type, nullable) = match (column_result, data_type_result, nullable_result) {
|
let (name, data_type, nullable) = match (name_result, data_type_result, nullable_result) {
|
||||||
(std::result::Result::Ok(column), std::result::Result::Ok(data_type), std::result::Result::Ok(nullable)) => (column, data_type, nullable),
|
(std::result::Result::Ok(name), std::result::Result::Ok(data_type), std::result::Result::Ok(nullable)) => (name, data_type, nullable),
|
||||||
_ => {
|
_ => {
|
||||||
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "metadata_shape_decode"));
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "metadata_shape_decode"));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
for (index, expected_row) in expected.iter().enumerate() {
|
for (index, required) in REQUIRED.iter().enumerate() {
|
||||||
if column == expected_row.0 {
|
if name == required.0 && data_type == required.1 && nullable == required.2 {
|
||||||
if found[index] || data_type != expected_row.1 || nullable != expected_row.2 {
|
|
||||||
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "metadata_shape"));
|
|
||||||
}
|
|
||||||
found[index] = true;
|
found[index] = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -254,65 +427,6 @@ async fn verify_metadata_shape(transaction: &deadpool_postgres::Transaction<'_>)
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn load_history(transaction: &deadpool_postgres::Transaction<'_>) -> std::result::Result<std::vec::Vec<AppliedMigration>, crate::PostgresBackendError> {
|
|
||||||
let result = transaction.query(HISTORY_LOAD_SQL, &[]).await;
|
|
||||||
let rows = match result {
|
|
||||||
std::result::Result::Ok(value) => value,
|
|
||||||
std::result::Result::Err(_) => {
|
|
||||||
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "history_load"));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
let mut history = std::vec::Vec::with_capacity(rows.len());
|
|
||||||
for row in rows {
|
|
||||||
let version_result = row.try_get::<usize, i64>(0);
|
|
||||||
let name_result = row.try_get::<usize, std::string::String>(1);
|
|
||||||
let checksum_result = row.try_get::<usize, std::string::String>(2);
|
|
||||||
match (version_result, name_result, checksum_result) {
|
|
||||||
(std::result::Result::Ok(version), std::result::Result::Ok(name), std::result::Result::Ok(checksum)) => {
|
|
||||||
history.push(AppliedMigration { checksum, name, version });
|
|
||||||
},
|
|
||||||
_ => {
|
|
||||||
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "history_decode"));
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return std::result::Result::Ok(history);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_history(history: &[AppliedMigration], expected_checksum: &str) -> std::result::Result<(), crate::PostgresBackendError> {
|
|
||||||
let mut sentinel_found = false;
|
|
||||||
for applied in history {
|
|
||||||
if applied.version > BOOTSTRAP_MIGRATION_VERSION {
|
|
||||||
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::SchemaNewer, "history_newer"));
|
|
||||||
}
|
|
||||||
if applied.version < BOOTSTRAP_MIGRATION_VERSION {
|
|
||||||
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "history_unknown"));
|
|
||||||
}
|
|
||||||
if applied.name != BOOTSTRAP_MIGRATION_NAME || applied.checksum != expected_checksum {
|
|
||||||
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "history_diverged"));
|
|
||||||
}
|
|
||||||
sentinel_found = true;
|
|
||||||
}
|
|
||||||
if !sentinel_found {
|
|
||||||
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "history_missing"));
|
|
||||||
}
|
|
||||||
return std::result::Result::Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bootstrap_checksum() -> std::string::String {
|
|
||||||
let mut hasher = sha2::Sha256::new();
|
|
||||||
hasher.update(BOOTSTRAP_MIGRATION_SQL.as_bytes());
|
|
||||||
let digest = hasher.finalize();
|
|
||||||
let bytes = digest.as_slice();
|
|
||||||
let mut encoded = std::string::String::with_capacity(bytes.len() * 2);
|
|
||||||
for byte in bytes {
|
|
||||||
let value = *byte;
|
|
||||||
encoded.push(char::from(HEX_LOWER[(value >> 4) as usize]));
|
|
||||||
encoded.push(char::from(HEX_LOWER[(value & 0x0f) as usize]));
|
|
||||||
}
|
|
||||||
return encoded;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "../unit_tests/migration.rs"]
|
#[path = "../unit_tests/migration.rs"]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-store-postgres-lib/src/runtime.rs
|
// file: crates/ksp-store-postgres-lib/src/runtime.rs
|
||||||
// version: 3
|
// version: 4
|
||||||
|
|
||||||
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;
|
||||||
@@ -263,7 +263,8 @@ impl PostgresBackend {
|
|||||||
tls_mode = settings.tls_mode().code(),
|
tls_mode = settings.tls_mode().code(),
|
||||||
"PostgreSQL Store backend established initial physical connection"
|
"PostgreSQL Store backend established initial physical connection"
|
||||||
);
|
);
|
||||||
let bootstrap_result = crate::bootstrap(&mut client, settings.auto_migrate, settings.migration_timeout, settings.migration_lock_timeout).await;
|
let bootstrap_result =
|
||||||
|
crate::bootstrap(&mut client, settings.network(), settings.auto_migrate, settings.migration_timeout, settings.migration_lock_timeout).await;
|
||||||
if let std::result::Result::Err(error) = bootstrap_result {
|
if let std::result::Result::Err(error) = bootstrap_result {
|
||||||
return std::result::Result::Err(error);
|
return std::result::Result::Err(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: 5
|
// version: 6
|
||||||
|
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
#![deny(unreachable_pub)]
|
#![deny(unreachable_pub)]
|
||||||
@@ -82,3 +82,19 @@ fn pre_007_health_probe_remains_foundation_only_and_private_sql() {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_002_migration_engine_is_registry_driven_and_network_hook_ready_without_v001_schema() {
|
||||||
|
let migration = include_str!("../src/migration.rs");
|
||||||
|
assert!(migration.contains("const EMBEDDED_MIGRATIONS: &[EmbeddedMigration]"));
|
||||||
|
assert!(migration.contains("MigrationHook::None"));
|
||||||
|
assert!(migration.contains("run_migration_hook(transaction, network, migration.hook, MigrationHookContext::AppliedNow).await"));
|
||||||
|
assert!(migration.contains("network: &ksp_store_api::RawNetworkId"));
|
||||||
|
assert!(migration.contains("MigrationHookContext::Existing"));
|
||||||
|
assert!(migration.contains("run_applied_migration_hooks(&transaction, network, next_index).await"));
|
||||||
|
assert!(migration.contains("validate_history(history.as_slice(), EMBEDDED_MIGRATIONS)"));
|
||||||
|
assert!(migration.contains("apply_pending_migrations(&transaction, network, next_index).await"));
|
||||||
|
assert!(!migration.contains("V001__raw_transaction.sql"));
|
||||||
|
assert!(!migration.contains("ksp_store_identity"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,50 +1,89 @@
|
|||||||
// file: crates/ksp-store-postgres-lib/unit_tests/migration.rs
|
// file: crates/ksp-store-postgres-lib/unit_tests/migration.rs
|
||||||
// version: 1
|
// version: 2
|
||||||
|
|
||||||
fn applied(version: i64, name: &str, checksum: &str) -> super::AppliedMigration {
|
fn applied(version: i64, name: &str, checksum: &str) -> super::AppliedMigration {
|
||||||
return super::AppliedMigration { checksum: checksum.to_owned(), name: name.to_owned(), version };
|
return super::AppliedMigration { checksum: checksum.to_owned(), name: name.to_owned(), version };
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
fn embedded(version: i64, name: &'static str, sql: &'static str) -> super::EmbeddedMigration {
|
||||||
fn bootstrap_migration_is_static_metadata_only_and_checksum_is_stable_sha256() {
|
return super::EmbeddedMigration { hook: super::MigrationHook::None, name, sql, version };
|
||||||
assert_eq!(super::BOOTSTRAP_MIGRATION_VERSION, 0);
|
|
||||||
assert_eq!(super::BOOTSTRAP_MIGRATION_NAME, "bootstrap");
|
|
||||||
assert!(super::BOOTSTRAP_MIGRATION_SQL.contains("CREATE TABLE ksp_store_schema_migrations"));
|
|
||||||
for forbidden in ["RawTransaction", "RawAccountState", "raw_transaction", "raw_account", "CORE", "DECODE", "SPECIALIZED"] {
|
|
||||||
assert!(!super::BOOTSTRAP_MIGRATION_SQL.contains(forbidden), "business schema leaked into bootstrap SQL: {forbidden}");
|
|
||||||
}
|
}
|
||||||
let checksum = super::bootstrap_checksum();
|
|
||||||
|
#[test]
|
||||||
|
fn pre_002_embedded_registry_keeps_v000_immutable_and_current_version_registry_driven() {
|
||||||
|
assert_eq!(super::EMBEDDED_MIGRATIONS.len(), 1);
|
||||||
|
let migration = &super::EMBEDDED_MIGRATIONS[0];
|
||||||
|
assert_eq!(migration.version, 0);
|
||||||
|
assert_eq!(migration.name, "bootstrap");
|
||||||
|
assert_eq!(migration.hook, super::MigrationHook::None);
|
||||||
|
assert!(migration.sql.contains("CREATE TABLE ksp_store_schema_migrations"));
|
||||||
|
for forbidden in ["RawTransaction", "RawAccountState", "raw_transaction", "raw_account", "CORE", "DECODE", "SPECIALIZED"] {
|
||||||
|
assert!(!migration.sql.contains(forbidden), "business schema leaked into bootstrap SQL: {forbidden}");
|
||||||
|
}
|
||||||
|
assert!(super::validate_embedded_registry(super::EMBEDDED_MIGRATIONS).is_ok());
|
||||||
|
assert_eq!(crate::current_migration_version(), 0);
|
||||||
|
let checksum = super::migration_checksum(migration.sql);
|
||||||
assert_eq!(checksum.len(), 64);
|
assert_eq!(checksum.len(), 64);
|
||||||
assert_eq!(checksum, "d29068b8c13b9dc0cc9ef6aaadd0fa12d41e0fe4c56541a1118c4bfc846a1450");
|
assert_eq!(checksum, "d29068b8c13b9dc0cc9ef6aaadd0fa12d41e0fe4c56541a1118c4bfc846a1450");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn matching_sentinel_history_is_accepted() {
|
fn pre_002_ordered_registry_accepts_exact_history_prefix_and_full_history() {
|
||||||
let checksum = super::bootstrap_checksum();
|
let v000 = super::EMBEDDED_MIGRATIONS[0];
|
||||||
let history = [applied(0, "bootstrap", checksum.as_str())];
|
let v001 = embedded(1, "synthetic", "SELECT 1;");
|
||||||
assert!(super::validate_history(&history, checksum.as_str()).is_ok());
|
let registry = [v000, v001];
|
||||||
|
assert!(super::validate_embedded_registry(®istry).is_ok());
|
||||||
|
let v000_checksum = super::migration_checksum(v000.sql);
|
||||||
|
let prefix = [applied(0, v000.name, v000_checksum.as_str())];
|
||||||
|
assert_eq!(super::validate_history(&prefix, ®istry).ok(), std::option::Option::Some(1));
|
||||||
|
let v001_checksum = super::migration_checksum(v001.sql);
|
||||||
|
let full = [applied(0, v000.name, v000_checksum.as_str()), applied(1, v001.name, v001_checksum.as_str())];
|
||||||
|
assert_eq!(super::validate_history(&full, ®istry).ok(), std::option::Option::Some(2));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn divergent_or_missing_sentinel_is_terminal_mismatch() {
|
fn pre_002_registry_rejects_empty_nonzero_gap_and_empty_metadata_entries() {
|
||||||
let checksum = super::bootstrap_checksum();
|
let empty: [super::EmbeddedMigration; 0] = [];
|
||||||
let wrong_name = [applied(0, "changed", checksum.as_str())];
|
let starts_at_one = [embedded(1, "future", "SELECT 1;")];
|
||||||
let wrong_checksum = [applied(0, "bootstrap", "00")];
|
let gap = [embedded(0, "bootstrap", "SELECT 0;"), embedded(2, "future", "SELECT 2;")];
|
||||||
let missing: [super::AppliedMigration; 0] = [];
|
let empty_name = [embedded(0, "", "SELECT 0;")];
|
||||||
for history in [&wrong_name[..], &wrong_checksum[..], &missing[..]] {
|
let empty_sql = [embedded(0, "bootstrap", "")];
|
||||||
let result = super::validate_history(history, checksum.as_str());
|
for registry in [&empty[..], &starts_at_one[..], &gap[..], &empty_name[..], &empty_sql[..]] {
|
||||||
|
let result = super::validate_embedded_registry(registry);
|
||||||
assert_eq!(result.err().map(|value| return value.kind()), std::option::Option::Some(crate::PostgresBackendErrorKind::MigrationMismatch));
|
assert_eq!(result.err().map(|value| return value.kind()), std::option::Option::Some(crate::PostgresBackendErrorKind::MigrationMismatch));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn newer_history_is_rejected_without_down_migration() {
|
fn pre_002_divergent_missing_or_gapped_history_is_terminal_mismatch() {
|
||||||
let checksum = super::bootstrap_checksum();
|
let v000 = super::EMBEDDED_MIGRATIONS[0];
|
||||||
let history = [applied(0, "bootstrap", checksum.as_str()), applied(1, "future", "future-checksum")];
|
let v001 = embedded(1, "synthetic", "SELECT 1;");
|
||||||
let result = super::validate_history(&history, checksum.as_str());
|
let registry = [v000, v001];
|
||||||
|
let v000_checksum = super::migration_checksum(v000.sql);
|
||||||
|
let v001_checksum = super::migration_checksum(v001.sql);
|
||||||
|
let wrong_name = [applied(0, "changed", v000_checksum.as_str())];
|
||||||
|
let wrong_checksum = [applied(0, v000.name, "00")];
|
||||||
|
let missing: [super::AppliedMigration; 0] = [];
|
||||||
|
let missing_v000 = [applied(1, v001.name, v001_checksum.as_str())];
|
||||||
|
for history in [&wrong_name[..], &wrong_checksum[..], &missing[..], &missing_v000[..]] {
|
||||||
|
let result = super::validate_history(history, ®istry);
|
||||||
|
assert_eq!(result.err().map(|value| return value.kind()), std::option::Option::Some(crate::PostgresBackendErrorKind::MigrationMismatch));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_002_newer_history_is_rejected_without_down_migration() {
|
||||||
|
let v000 = super::EMBEDDED_MIGRATIONS[0];
|
||||||
|
let v001 = embedded(1, "synthetic", "SELECT 1;");
|
||||||
|
let registry = [v000, v001];
|
||||||
|
let v000_checksum = super::migration_checksum(v000.sql);
|
||||||
|
let v001_checksum = super::migration_checksum(v001.sql);
|
||||||
|
let history = [applied(0, v000.name, v000_checksum.as_str()), applied(1, v001.name, v001_checksum.as_str()), applied(2, "future", "future-checksum")];
|
||||||
|
let result = super::validate_history(&history, ®istry);
|
||||||
assert_eq!(result.err().map(|value| return value.kind()), std::option::Option::Some(crate::PostgresBackendErrorKind::SchemaNewer));
|
assert_eq!(result.err().map(|value| return value.kind()), std::option::Option::Some(crate::PostgresBackendErrorKind::SchemaNewer));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
286
deltas/0.3.3/pre.002.md
Normal file
286
deltas/0.3.3/pre.002.md
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
<!-- file: deltas/0.3.3/pre.002.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.3.3-pre.002` — moteur de migrations PostgreSQL multi-version
|
||||||
|
|
||||||
|
## 1. Base requise
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.3.3-pre.001
|
||||||
|
```
|
||||||
|
|
||||||
|
Le gate opérateur fourni pour `pre.001` est entièrement vert :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all PASS
|
||||||
|
audit Rust général / exports / workspace PASS
|
||||||
|
audit Markdown PASS — 214 tables / 131 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
|
||||||
|
cargo test -p ksp-config-lib PASS
|
||||||
|
cargo check -p ksp-store-lib --no-default-features PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
Les tests live `#[ignore]` restent volontairement hors de ce gate ; `pre.001` était documentaire et `pre.002` ne crée encore aucun SQL métier V001.
|
||||||
|
|
||||||
|
## 2. Objectif
|
||||||
|
|
||||||
|
Généraliser le bootstrap PostgreSQL V000 acquis en `0.3.2` vers un moteur embedded multi-version avant d'introduire la première migration métier.
|
||||||
|
|
||||||
|
La tranche doit préserver :
|
||||||
|
|
||||||
|
```text
|
||||||
|
checksum immuable
|
||||||
|
mismatch terminal
|
||||||
|
schema newer terminal
|
||||||
|
aucun down automatique
|
||||||
|
advisory transaction lock
|
||||||
|
migration timeout
|
||||||
|
statement timeout
|
||||||
|
transaction unique
|
||||||
|
metadata shape validation
|
||||||
|
erreurs externes non rendues
|
||||||
|
```
|
||||||
|
|
||||||
|
Elle prépare également la frontière transactionnelle du futur binding `RawNetworkId`, sans créer V001 ni `ksp_store_identity`.
|
||||||
|
|
||||||
|
## 3. Version
|
||||||
|
|
||||||
|
Le workspace passe à :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.3.3-pre.2
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Registre embedded
|
||||||
|
|
||||||
|
Le moteur ne possède plus une série de constantes spécialisées V000. Il utilise désormais :
|
||||||
|
|
||||||
|
```text
|
||||||
|
EMBEDDED_MIGRATIONS: &[EmbeddedMigration]
|
||||||
|
```
|
||||||
|
|
||||||
|
Le registre réel de cette tranche contient exactement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
V000 bootstrap
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune V001 n'est anticipée dans les migrations physiques.
|
||||||
|
|
||||||
|
Chaque entrée possède :
|
||||||
|
|
||||||
|
```text
|
||||||
|
version
|
||||||
|
name
|
||||||
|
sql embedded
|
||||||
|
migration hook
|
||||||
|
```
|
||||||
|
|
||||||
|
Le registre est validé avant I/O :
|
||||||
|
|
||||||
|
```text
|
||||||
|
non vide
|
||||||
|
commence à 0
|
||||||
|
versions contiguës strictement ordonnées
|
||||||
|
name non vide
|
||||||
|
SQL non vide
|
||||||
|
```
|
||||||
|
|
||||||
|
La version courante est dérivée du dernier élément du registre.
|
||||||
|
|
||||||
|
## 5. Validation d'historique multi-version
|
||||||
|
|
||||||
|
L'historique PostgreSQL est maintenant validé comme **préfixe exact** du registre embedded.
|
||||||
|
|
||||||
|
Cas :
|
||||||
|
|
||||||
|
```text
|
||||||
|
historique exact complet -> prêt
|
||||||
|
préfixe exact -> migrations pending
|
||||||
|
nom divergent -> MigrationMismatch
|
||||||
|
checksum divergent -> MigrationMismatch
|
||||||
|
version/trou inattendu connu -> MigrationMismatch
|
||||||
|
version > runtime courant -> SchemaNewer
|
||||||
|
metadata existante sans V000 -> MigrationMismatch
|
||||||
|
```
|
||||||
|
|
||||||
|
Un préfixe exact n'est donc plus confondu avec une divergence : il représente précisément le cas normal d'upgrade V000 -> V001 attendu en `pre.003`.
|
||||||
|
|
||||||
|
## 6. Application des migrations pending
|
||||||
|
|
||||||
|
Sous la transaction PostgreSQL et l'advisory lock déjà acquis :
|
||||||
|
|
||||||
|
```text
|
||||||
|
load/validate history
|
||||||
|
-> déterminer next_index
|
||||||
|
-> refuser si pending && auto_migrate=false
|
||||||
|
-> appliquer chaque migration dans l'ordre
|
||||||
|
-> vérifier metadata shape
|
||||||
|
-> exécuter migration hook
|
||||||
|
-> insérer checksum/name/version dans history
|
||||||
|
-> migration suivante
|
||||||
|
-> commit unique
|
||||||
|
```
|
||||||
|
|
||||||
|
Une erreur à n'importe quelle étape provoque le rollback transactionnel normal ; aucune migration partiellement enregistrée n'est considérée appliquée.
|
||||||
|
|
||||||
|
## 7. Hook atomique de binding réseau
|
||||||
|
|
||||||
|
`bootstrap` reçoit désormais le `RawNetworkId` du backend et le transmet jusqu'à chaque migration.
|
||||||
|
|
||||||
|
Le moteur distingue deux contextes privés :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Existing migration déjà présente dans history à la réouverture
|
||||||
|
AppliedNow migration exécutée pendant le bootstrap courant
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour les migrations déjà appliquées, le hook est rejoué sous la transaction/advisory lock avant que le backend puisse être déclaré prêt. Pour une migration nouvellement appliquée, l'ordre est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
batch_execute(migration.sql)
|
||||||
|
-> metadata shape check
|
||||||
|
-> migration hook AppliedNow
|
||||||
|
-> INSERT history
|
||||||
|
-> commit final
|
||||||
|
```
|
||||||
|
|
||||||
|
V000 utilise :
|
||||||
|
|
||||||
|
```text
|
||||||
|
MigrationHook::None
|
||||||
|
```
|
||||||
|
|
||||||
|
`pre.003` pourra donc ajouter le hook V001 `ksp_store_identity` avec deux comportements sûrs : création/validation en `AppliedNow`, validation stricte sans recréation en `Existing`. La frontière reste transactionnelle et ne crée aucune seconde phase de bootstrap race-prone.
|
||||||
|
|
||||||
|
Aucune identité réseau n'est écrite dans cette tranche.
|
||||||
|
|
||||||
|
## 8. Compatibilité V000
|
||||||
|
|
||||||
|
Le fichier reste inchangé :
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-store-postgres-lib/migrations/V000__bootstrap.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Checksum conservé :
|
||||||
|
|
||||||
|
```text
|
||||||
|
d29068b8c13b9dc0cc9ef6aaadd0fa12d41e0fe4c56541a1118c4bfc846a1450
|
||||||
|
```
|
||||||
|
|
||||||
|
La metadata historique reste :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp_store_schema_migrations
|
||||||
|
version BIGINT PRIMARY KEY
|
||||||
|
name TEXT NOT NULL
|
||||||
|
checksum TEXT NOT NULL
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Tests du moteur
|
||||||
|
|
||||||
|
Les unit tests `migration.rs` couvrent maintenant :
|
||||||
|
|
||||||
|
```text
|
||||||
|
registre réel V000 immutable
|
||||||
|
checksum V000 stable
|
||||||
|
current version dérivée du registre
|
||||||
|
registre synthétique V000/V001 ordonné
|
||||||
|
historique V000 comme préfixe pending valide
|
||||||
|
historique synthétique V000/V001 complet
|
||||||
|
registre vide/non-zero/gap/name vide/SQL vide rejeté
|
||||||
|
nom/checksum/history missing/gap rejetés
|
||||||
|
version future -> SchemaNewer
|
||||||
|
```
|
||||||
|
|
||||||
|
Un canari d'intégration source vérifie aussi :
|
||||||
|
|
||||||
|
```text
|
||||||
|
registre embedded présent
|
||||||
|
hook de migration + contextes `AppliedNow`/`Existing` présents
|
||||||
|
RawNetworkId transmis au hook
|
||||||
|
validation préfixe utilisée
|
||||||
|
application pending utilisée
|
||||||
|
aucun V001__raw_transaction.sql
|
||||||
|
aucun ksp_store_identity
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
crates/ksp-store-postgres-lib/src/migration.rs
|
||||||
|
crates/ksp-store-postgres-lib/src/runtime.rs
|
||||||
|
crates/ksp-store-postgres-lib/unit_tests/migration.rs
|
||||||
|
crates/ksp-store-postgres-lib/tests/dependency_boundary.rs
|
||||||
|
docs/plans/024-V0_3_3_STORE_POSTGRES_RAW_TRANSACTION_PLAN.md
|
||||||
|
docs/validation/020-V0_3_3_STORE_POSTGRES_RAW_TRANSACTION.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. Fichier ajouté
|
||||||
|
|
||||||
|
```text
|
||||||
|
deltas/0.3.3/pre.002.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12. Fichiers supprimés
|
||||||
|
|
||||||
|
Aucun.
|
||||||
|
|
||||||
|
## 13. Hors scope confirmé
|
||||||
|
|
||||||
|
Aucun changement n'est apporté à :
|
||||||
|
|
||||||
|
```text
|
||||||
|
V001 physique
|
||||||
|
ksp_store_identity
|
||||||
|
tables/indexes RawTransaction
|
||||||
|
mapping Row/SQL métier
|
||||||
|
six capabilities RawTransaction
|
||||||
|
ksp-store-lib dispatch
|
||||||
|
RawAccountState
|
||||||
|
Config std.store
|
||||||
|
workers/jobs/apps
|
||||||
|
codecs wire
|
||||||
|
```
|
||||||
|
|
||||||
|
## 14. Validations exécutées dans l'environnement de génération
|
||||||
|
|
||||||
|
```text
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py
|
||||||
|
General Rust rule audit: clean
|
||||||
|
Rust export completeness audit: 0 candidate(s)
|
||||||
|
KSP workspace Rust rule audit: clean
|
||||||
|
|
||||||
|
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.3.3
|
||||||
|
Markdown table audit: clean
|
||||||
|
```
|
||||||
|
|
||||||
|
`cargo`, `rustc` et `rustfmt` ne sont pas disponibles dans l'environnement d'assemblage. Les validations Cargo ci-dessous restent donc opérateur et ne sont jamais déclarées PASS ici.
|
||||||
|
|
||||||
|
## 15. Gate opérateur demandé
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py
|
||||||
|
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.3.3
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun test PostgreSQL live n'est requis : le SQL physique reste strictement V000 et aucun schéma métier n'est introduit.
|
||||||
|
|
||||||
|
## 16. Suite
|
||||||
|
|
||||||
|
Après gate vert, `0.3.3-pre.003` créera la vraie migration V001, les tables/constraints/indexes décidés en `pre.001` et le binding atomique `ksp_store_identity` via le hook préparé ici.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: docs/plans/024-V0_3_3_STORE_POSTGRES_RAW_TRANSACTION_PLAN.md -->
|
<!-- file: docs/plans/024-V0_3_3_STORE_POSTGRES_RAW_TRANSACTION_PLAN.md -->
|
||||||
<!-- version: 1 -->
|
<!-- version: 2 -->
|
||||||
|
|
||||||
# Plan `0.3.3` — Store/PostgreSQL RawTransaction vertical slice
|
# Plan `0.3.3` — Store/PostgreSQL RawTransaction vertical slice
|
||||||
|
|
||||||
@@ -17,13 +17,19 @@ Base canonique auditée :
|
|||||||
v0.3.2
|
v0.3.2
|
||||||
```
|
```
|
||||||
|
|
||||||
Première tranche :
|
Première tranche validée :
|
||||||
|
|
||||||
```text
|
```text
|
||||||
0.3.3-pre.001 — audit, threat model, physical design, sizing et planning
|
0.3.3-pre.001 — audit, threat model, physical design, sizing et planning
|
||||||
```
|
```
|
||||||
|
|
||||||
`pre.001` est volontairement une tranche de conception. Elle ne crée ni migration métier `V001`, ni repository PostgreSQL métier, ni dispatch RAW dans `ksp-store-lib`.
|
Tranche technique courante :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.3.3-pre.002 — moteur de migrations multi-version
|
||||||
|
```
|
||||||
|
|
||||||
|
`pre.001` est restée volontairement une tranche de conception. Son gate opérateur est vert. `pre.002` généralise uniquement le moteur de migrations PostgreSQL ; elle ne crée toujours ni migration métier `V001`, ni repository PostgreSQL métier, ni dispatch RAW dans `ksp-store-lib`.
|
||||||
|
|
||||||
## 2. Sources et autorité
|
## 2. Sources et autorité
|
||||||
|
|
||||||
@@ -1007,6 +1013,18 @@ L'audit montre que le moteur de migration `0.3.2` doit être généralisé avant
|
|||||||
- préparer le hook atomique de binding réseau ;
|
- préparer le hook atomique de binding réseau ;
|
||||||
- ajouter les tests du moteur sans créer encore de repository métier.
|
- ajouter les tests du moteur sans créer encore de repository métier.
|
||||||
|
|
||||||
|
Statut matérialisé en `pre.002` :
|
||||||
|
|
||||||
|
- registre embedded privé ordonné, actuellement limité à V000 ;
|
||||||
|
- version courante dérivée du dernier élément du registre plutôt que d'une constante V000 dédiée ;
|
||||||
|
- historique validé comme préfixe exact et immuable du registre ;
|
||||||
|
- migrations pending appliquées séquentiellement dans la transaction déjà protégée par advisory lock ;
|
||||||
|
- `auto_migrate = false` refuse tout préfixe incomplet par `migration_pending` ;
|
||||||
|
- hook de migration exécuté pour les versions déjà appliquées à chaque ouverture et, pour une nouvelle version, après son SQL mais avant insertion de l'historique et commit ;
|
||||||
|
- `RawNetworkId` est déjà transmis jusqu'au hook afin que `pre.003` puisse binder V001 atomiquement sans déplacer la frontière transactionnelle ;
|
||||||
|
- V000 conserve strictement son SQL et son checksum historique ;
|
||||||
|
- aucun fichier V001 ni objet `ksp_store_identity` n'est encore créé.
|
||||||
|
|
||||||
### `0.3.3-pre.003` — V001 physique + binding réseau
|
### `0.3.3-pre.003` — V001 physique + binding réseau
|
||||||
|
|
||||||
- créer V001 ;
|
- créer V001 ;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: docs/validation/020-V0_3_3_STORE_POSTGRES_RAW_TRANSACTION.md -->
|
<!-- file: docs/validation/020-V0_3_3_STORE_POSTGRES_RAW_TRANSACTION.md -->
|
||||||
<!-- version: 1 -->
|
<!-- version: 2 -->
|
||||||
|
|
||||||
# Validation `0.3.3` — Store/PostgreSQL RawTransaction vertical slice
|
# Validation `0.3.3` — Store/PostgreSQL RawTransaction vertical slice
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ Cette validation accompagne :
|
|||||||
0.3.3 — Store/PostgreSQL RawTransaction vertical slice
|
0.3.3 — Store/PostgreSQL RawTransaction vertical slice
|
||||||
```
|
```
|
||||||
|
|
||||||
Elle démarre en `0.3.3-pre.001` comme matrice de preuve. Les lignes non encore implémentées restent explicitement `À FAIRE`; elles ne sont pas présentées comme acquises.
|
Elle démarre en `0.3.3-pre.001` comme matrice de preuve. Le gate opérateur de `pre.001` est vert. Les lignes non encore implémentées restent explicitement `À FAIRE`; elles ne sont pas présentées comme acquises.
|
||||||
|
|
||||||
## 2. Baseline stable
|
## 2. Baseline stable
|
||||||
|
|
||||||
@@ -434,21 +434,33 @@ SQLSTATE et texte PostgreSQL ne font pas partie du contrat public.
|
|||||||
|
|
||||||
### 17.1 Multi-version
|
### 17.1 Multi-version
|
||||||
|
|
||||||
Preuves nécessaires avant V001 :
|
Preuves acquises en `pre.002` avant création réelle de V001 :
|
||||||
|
|
||||||
- V000 reste checksum-identique ;
|
- V000 reste byte-identique et conserve le checksum `d29068b8c13b9dc0cc9ef6aaadd0fa12d41e0fe4c56541a1118c4bfc846a1450` ;
|
||||||
- registre embedded ordonné `[V000, V001]` ;
|
- le registre embedded privé est ordonné et commence à V000 ;
|
||||||
- historique vide -> bootstrap correct ;
|
- le moteur valide un historique comme préfixe exact du registre ;
|
||||||
|
- un registre synthétique `[V000, V001]` prouve qu'un historique V000 seul retourne l'index pending V001 sans être classé mismatch ;
|
||||||
|
- nom/checksum divergent, historique vide avec metadata existante ou trou de préfixe -> mismatch ;
|
||||||
|
- version supérieure au registre connu -> schema newer, sans down migration ;
|
||||||
|
- `auto_migrate = false` refuse un registre incomplet par `migration_pending` ;
|
||||||
|
- les migrations pending sont appliquées dans l'ordre sous la transaction et l'advisory lock déjà acquis ;
|
||||||
|
- chaque migration vérifie encore la forme de la metadata avant d'insérer sa ligne d'historique.
|
||||||
|
|
||||||
|
Preuves différées à `pre.003` après création de la vraie V001 :
|
||||||
|
|
||||||
|
- registre réel `[V000, V001]` ;
|
||||||
- V000 seule + auto migrate -> V001 appliquée ;
|
- V000 seule + auto migrate -> V001 appliquée ;
|
||||||
- V000 seule + auto migrate disabled -> pending/non-ready ;
|
|
||||||
- V001 checksum divergent -> mismatch ;
|
- V001 checksum divergent -> mismatch ;
|
||||||
- version > V001 -> schema newer ;
|
- binding réseau réel dans le hook ;
|
||||||
- ligne de migration manquante/divergente -> mismatch ;
|
- inventaire/checksum exact de V001.
|
||||||
- deux open concurrents -> advisory lock, historique unique.
|
|
||||||
|
La concurrence PostgreSQL réelle du moteur reste couverte par la preuve foundation acquise en `0.3.2`; la vertical slice métier recevra sa propre preuve live en `pre.009`.
|
||||||
|
|
||||||
### 17.2 Binding réseau atomique
|
### 17.2 Binding réseau atomique
|
||||||
|
|
||||||
L'identité réseau doit être créée/validée avant commit du bootstrap V001. Un crash ne doit pas pouvoir laisser V001 « appliquée » avec une base prête mais sans identité exploitable.
|
`pre.002` prépare la frontière sans créer l'identité : `RawNetworkId` est transmis au moteur et chaque migration possède un hook privé exécuté sous la même transaction/advisory lock. Deux contextes sont distingués : `AppliedNow` pour une migration qui vient d'être exécutée et `Existing` pour une migration déjà présente dans l'historique lors d'une réouverture. V000 déclare explicitement le hook neutre.
|
||||||
|
|
||||||
|
`pre.003` ajoutera le hook V001 réel : en contexte `AppliedNow`, il créera puis validera `ksp_store_identity` avant insertion de l'historique V001 ; en contexte `Existing`, il validera strictement l'identité existante sans la recréer si elle a disparu. Un échec du binding fera donc rollback du SQL V001 et de son historique lors d'une première application, et une réouverture d'une base V001 incohérente échouera avant exposition du backend prêt.
|
||||||
|
|
||||||
## 18. Boundaries
|
## 18. Boundaries
|
||||||
|
|
||||||
@@ -500,9 +512,11 @@ cap 500/1000 dans Store pagination
|
|||||||
|
|
||||||
### `pre.002`
|
### `pre.002`
|
||||||
|
|
||||||
- migration registry multi-version ;
|
- migration registry multi-version : PASS statique ;
|
||||||
- tests V000 conservés ;
|
- V000/checksum conservés : PASS ;
|
||||||
- préparation binding.
|
- validation préfixe/mismatch/newer : PASS unit design + canaris source ;
|
||||||
|
- préparation hook binding réseau transactionnel : PASS ;
|
||||||
|
- aucune V001 métier créée : PASS.
|
||||||
|
|
||||||
### `pre.003`
|
### `pre.003`
|
||||||
|
|
||||||
@@ -560,7 +574,7 @@ cap 500/1000 dans Store pagination
|
|||||||
|
|
||||||
- publication stable.
|
- publication stable.
|
||||||
|
|
||||||
## 22. Gate courant `pre.001`
|
## 22. Gate courant `pre.002`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo fmt --all
|
cargo fmt --all
|
||||||
|
|||||||
Reference in New Issue
Block a user