This commit is contained in:
2026-04-23 00:07:13 +02:00
parent f073b14e01
commit c36d6b9ded
18 changed files with 609 additions and 14 deletions

View File

@@ -16,3 +16,4 @@
0.4.2 - Préparation de la politique HTTP avancée : états de pause avant envoi, quotas par famille de méthodes et futur pool dendpoints 0.4.2 - Préparation de la politique HTTP avancée : états de pause avant envoi, quotas par famille de méthodes et futur pool dendpoints
0.4.3 - Pool dendpoints HTTP 0.4.3 - Pool dendpoints HTTP
0.4.4 - Ajout de la fenêtre Demo Http dans kb_app, exécution manuelle des méthodes HTTP via le pool, snapshot des endpoints et amélioration des presets UI 0.4.4 - Ajout de la fenêtre Demo Http dans kb_app, exécution manuelle des méthodes HTTP via le pool, snapshot des endpoints et amélioration des presets UI
0.5.0 - Début du socle SQLite : configuration database, ouverture/validation de la base et premières briques de persistance

View File

@@ -8,7 +8,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.4.4" version = "0.5.0"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
repository = "https://git.sasedev.com/Sasedev/khadhroony-bobobot" repository = "https://git.sasedev.com/Sasedev/khadhroony-bobobot"

View File

@@ -20,6 +20,7 @@ solana-sdk-ids.workspace = true
spl-associated-token-account-interface.workspace = true spl-associated-token-account-interface.workspace = true
spl-token-2022-interface.workspace = true spl-token-2022-interface.workspace = true
spl-token-interface.workspace = true spl-token-interface.workspace = true
sqlx.workspace = true
tokio.workspace = true tokio.workspace = true
tokio-tungstenite.workspace = true tokio-tungstenite.workspace = true
tracing.workspace = true tracing.workspace = true
@@ -27,4 +28,5 @@ tracing-appender.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
[dev-dependencies] [dev-dependencies]
tempfile.workspace = true

View File

@@ -13,6 +13,8 @@ pub struct KbConfig {
pub data: KbDataConfig, pub data: KbDataConfig,
/// Solana endpoint configuration. /// Solana endpoint configuration.
pub solana: KbSolanaConfig, pub solana: KbSolanaConfig,
/// Database configuration.
pub database: KbDatabaseConfig,
} }
impl KbConfig { impl KbConfig {
@@ -489,6 +491,36 @@ impl KbWsEndpointConfig {
} }
} }
/// SQLite configuration.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KbSqliteDatabaseConfig {
/// SQLite database path.
pub path: std::string::String,
/// Whether the file should be created if missing.
pub create_if_missing: bool,
/// SQLite busy timeout in milliseconds.
pub busy_timeout_ms: u64,
/// Maximum pool connections.
pub max_connections: u32,
/// Whether the schema should be initialized automatically at startup.
pub auto_initialize_schema: bool,
/// Whether WAL journal mode should be enabled.
pub use_wal: bool,
}
/// Database configuration.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KbDatabaseConfig {
/// Whether the database layer is enabled.
pub enabled: bool,
/// Selected backend.
pub backend: crate::KbDatabaseBackend,
/// SQLite-specific configuration.
pub sqlite: KbSqliteDatabaseConfig,
}
fn kb_workspace_root_dir() -> std::path::PathBuf { fn kb_workspace_root_dir() -> std::path::PathBuf {
let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
match manifest_dir.parent() { match manifest_dir.parent() {

23
kb_lib/src/db.rs Normal file
View File

@@ -0,0 +1,23 @@
// file: kb_lib/src/db.rs
//! Database facade.
//!
//! This module centralizes the database layer and exposes a storage API that is
//! intentionally structured to remain evolutive across backends.
mod connection;
mod dtos;
mod entities;
mod queries;
mod schema;
mod sqlite;
mod types;
pub use crate::db::connection::KbDatabase;
pub use crate::db::connection::KbDatabaseConnection;
pub use crate::db::dtos::KbDbMetadataDto;
pub use crate::db::entities::KbDbMetadataEntity;
pub use crate::db::queries::get_db_metadata;
pub use crate::db::queries::list_db_metadata;
pub use crate::db::queries::upsert_db_metadata;
pub use crate::db::types::KbDatabaseBackend;

140
kb_lib/src/db/connection.rs Normal file
View File

@@ -0,0 +1,140 @@
// file: kb_lib/src/db/connection.rs
//! Database connection facade.
/// Concrete database connection.
#[derive(Debug, Clone)]
pub enum KbDatabaseConnection {
/// SQLite connection pool.
Sqlite(sqlx::SqlitePool),
}
/// Database facade.
#[derive(Debug, Clone)]
pub struct KbDatabase {
backend: crate::KbDatabaseBackend,
database_url: std::string::String,
connection: KbDatabaseConnection,
}
impl KbDatabase {
/// Opens a database connection without initializing the schema.
pub async fn connect(
config: &crate::KbDatabaseConfig,
) -> Result<Self, crate::KbError> {
if !config.enabled {
return Err(crate::KbError::Config(
"database is disabled in configuration".to_string(),
));
}
match config.backend {
crate::KbDatabaseBackend::Sqlite => {
let database_url_result =
crate::db::sqlite::sqlite_database_url_from_config(config);
let database_url = match database_url_result {
Ok(database_url) => database_url,
Err(error) => return Err(error),
};
let pool_result = crate::db::sqlite::connect_sqlite(config).await;
let pool = match pool_result {
Ok(pool) => pool,
Err(error) => return Err(error),
};
Ok(Self {
backend: crate::KbDatabaseBackend::Sqlite,
database_url,
connection: KbDatabaseConnection::Sqlite(pool),
})
},
}
}
/// Opens a database connection and initializes the schema if configured.
pub async fn connect_and_initialize(
config: &crate::KbDatabaseConfig,
) -> Result<Self, crate::KbError> {
let connect_result = Self::connect(config).await;
let database = match connect_result {
Ok(database) => database,
Err(error) => return Err(error),
};
if config.sqlite.auto_initialize_schema {
let init_result = crate::db::schema::ensure_schema(&database).await;
if let Err(error) = init_result {
return Err(error);
}
}
Ok(database)
}
/// Returns the configured backend.
pub fn backend(
&self,
) -> crate::KbDatabaseBackend {
self.backend
}
/// Returns a displayable database URL-like string.
pub fn database_url(
&self,
) -> &str {
&self.database_url
}
/// Pings the database.
pub async fn ping(
&self,
) -> Result<(), crate::KbError> {
match &self.connection {
KbDatabaseConnection::Sqlite(pool) => {
let ping_result = sqlx::query("SELECT 1").execute(pool).await;
match ping_result {
Ok(_) => Ok(()),
Err(error) => Err(crate::KbError::Db(format!(
"cannot ping sqlite database '{}': {}",
self.database_url,
error
))),
}
},
}
}
/// Returns the underlying connection enum.
pub(crate) fn connection(
&self,
) -> &KbDatabaseConnection {
&self.connection
}
}
#[cfg(test)]
mod tests {
#[tokio::test]
async fn connect_and_ping_sqlite_database_works() {
let tempdir = tempfile::tempdir().expect("tempdir must succeed");
let database_path = tempdir.path().join("connection.sqlite3");
let config = crate::KbDatabaseConfig {
enabled: true,
backend: crate::KbDatabaseBackend::Sqlite,
sqlite: crate::KbSqliteDatabaseConfig {
path: database_path.to_string_lossy().to_string(),
create_if_missing: true,
busy_timeout_ms: 5000,
max_connections: 1,
auto_initialize_schema: true,
use_wal: true,
},
};
let database = crate::KbDatabase::connect_and_initialize(&config)
.await
.expect("database init must succeed");
assert_eq!(database.backend(), crate::KbDatabaseBackend::Sqlite);
assert!(database.database_url().starts_with("sqlite://"));
database.ping().await.expect("ping must succeed");
let metadata = crate::get_db_metadata(&database, "schema_version")
.await
.expect("metadata fetch must succeed");
assert!(metadata.is_some());
}
}

7
kb_lib/src/db/dtos.rs Normal file
View File

@@ -0,0 +1,7 @@
// file: kb_lib/src/db/dtos.rs
//! Database data transfer objects.
mod db_metadata;
pub use crate::db::dtos::db_metadata::KbDbMetadataDto;

View File

@@ -0,0 +1,57 @@
// file: kb_lib/src/db/dtos/db_metadata.rs
//! Metadata DTOs.
/// Metadata DTO used by the application layer.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct KbDbMetadataDto {
/// Metadata key.
pub key: std::string::String,
/// Metadata value.
pub value: std::string::String,
/// Last update timestamp.
pub updated_at: chrono::DateTime<chrono::Utc>,
}
impl KbDbMetadataDto {
/// Creates a new metadata DTO with the current UTC timestamp.
pub fn new(key: std::string::String, value: std::string::String) -> Self {
Self {
key,
value,
updated_at: chrono::Utc::now(),
}
}
}
impl TryFrom<crate::KbDbMetadataEntity> for KbDbMetadataDto {
type Error = crate::KbError;
fn try_from(entity: crate::KbDbMetadataEntity) -> Result<Self, Self::Error> {
let parsed_result = chrono::DateTime::parse_from_rfc3339(&entity.updated_at);
let parsed = match parsed_result {
Ok(parsed) => parsed,
Err(error) => {
return Err(crate::KbError::Db(format!(
"cannot parse db metadata timestamp '{}': {}",
entity.updated_at, error
)));
}
};
Ok(Self {
key: entity.key,
value: entity.value,
updated_at: parsed.with_timezone(&chrono::Utc),
})
}
}
impl From<KbDbMetadataDto> for crate::KbDbMetadataEntity {
fn from(dto: KbDbMetadataDto) -> Self {
Self {
key: dto.key,
value: dto.value,
updated_at: dto.updated_at.to_rfc3339(),
}
}
}

View File

@@ -0,0 +1,9 @@
// file: kb_lib/src/db/entities.rs
//! Database entities.
//!
//! These types are close to persisted rows and SQL query results.
mod db_metadata;
pub use crate::db::entities::db_metadata::KbDbMetadataEntity;

View File

@@ -0,0 +1,14 @@
// file: kb_lib/src/db/entities/db_metadata.rs
//! Metadata table entity.
/// Persisted metadata row.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow)]
pub struct KbDbMetadataEntity {
/// Metadata key.
pub key: std::string::String,
/// Metadata value.
pub value: std::string::String,
/// Last update timestamp encoded as RFC3339 UTC text.
pub updated_at: std::string::String,
}

9
kb_lib/src/db/queries.rs Normal file
View File

@@ -0,0 +1,9 @@
// file: kb_lib/src/db/queries.rs
//! Database queries.
mod db_metadata;
pub use crate::db::queries::db_metadata::get_db_metadata;
pub use crate::db::queries::db_metadata::list_db_metadata;
pub use crate::db::queries::db_metadata::upsert_db_metadata;

View File

@@ -0,0 +1,164 @@
// file: kb_lib/src/db/queries/db_metadata.rs
//! Queries for `kb_db_metadata`.
/// Inserts or updates one metadata row.
pub async fn upsert_db_metadata(
database: &crate::KbDatabase,
dto: &crate::KbDbMetadataDto,
) -> Result<(), crate::KbError> {
let entity = crate::KbDbMetadataEntity::from(dto.clone());
match database.connection() {
crate::KbDatabaseConnection::Sqlite(pool) => {
let query_result = sqlx::query(
r#"
INSERT INTO kb_db_metadata (
key,
value,
updated_at
)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = excluded.updated_at
"#,
)
.bind(entity.key)
.bind(entity.value)
.bind(entity.updated_at)
.execute(pool)
.await;
match query_result {
Ok(_) => Ok(()),
Err(error) => Err(crate::KbError::Db(format!(
"cannot upsert kb_db_metadata on sqlite: {}",
error
))),
}
}
}
}
/// Reads one metadata row by key.
pub async fn get_db_metadata(
database: &crate::KbDatabase,
key: &str,
) -> Result<std::option::Option<crate::KbDbMetadataDto>, crate::KbError> {
match database.connection() {
crate::KbDatabaseConnection::Sqlite(pool) => {
let query_result = sqlx::query_as::<sqlx::Sqlite, crate::KbDbMetadataEntity>(
r#"
SELECT
key,
value,
updated_at
FROM kb_db_metadata
WHERE key = ?
LIMIT 1
"#,
)
.bind(key)
.fetch_optional(pool)
.await;
let entity_option = match query_result {
Ok(entity_option) => entity_option,
Err(error) => {
return Err(crate::KbError::Db(format!(
"cannot read kb_db_metadata '{}' on sqlite: {}",
key, error
)));
}
};
match entity_option {
Some(entity) => {
let dto_result = crate::KbDbMetadataDto::try_from(entity);
match dto_result {
Ok(dto) => Ok(Some(dto)),
Err(error) => Err(error),
}
}
None => Ok(None),
}
}
}
}
/// Lists all metadata rows.
pub async fn list_db_metadata(
database: &crate::KbDatabase,
) -> Result<std::vec::Vec<crate::KbDbMetadataDto>, crate::KbError> {
match database.connection() {
crate::KbDatabaseConnection::Sqlite(pool) => {
let query_result = sqlx::query_as::<sqlx::Sqlite, crate::KbDbMetadataEntity>(
r#"
SELECT
key,
value,
updated_at
FROM kb_db_metadata
ORDER BY key ASC
"#,
)
.fetch_all(pool)
.await;
let entities = match query_result {
Ok(entities) => entities,
Err(error) => {
return Err(crate::KbError::Db(format!(
"cannot list kb_db_metadata on sqlite: {}",
error
)));
}
};
let mut dtos = std::vec::Vec::new();
for entity in entities {
let dto_result = crate::KbDbMetadataDto::try_from(entity);
let dto = match dto_result {
Ok(dto) => dto,
Err(error) => return Err(error),
};
dtos.push(dto);
}
Ok(dtos)
}
}
}
#[cfg(test)]
mod tests {
#[tokio::test]
async fn db_metadata_roundtrip_works() {
let tempdir = tempfile::tempdir().expect("tempdir must succeed");
let database_path = tempdir.path().join("roundtrip.sqlite3");
let config = crate::KbDatabaseConfig {
enabled: true,
backend: crate::KbDatabaseBackend::Sqlite,
sqlite: crate::KbSqliteDatabaseConfig {
path: database_path.to_string_lossy().to_string(),
create_if_missing: true,
busy_timeout_ms: 5000,
max_connections: 1,
auto_initialize_schema: true,
use_wal: true,
},
};
let database = crate::KbDatabase::connect_and_initialize(&config)
.await
.expect("database init must succeed");
let dto = crate::KbDbMetadataDto::new("schema_version".to_string(), "0.5.0".to_string());
crate::upsert_db_metadata(&database, &dto)
.await
.expect("upsert must succeed");
let fetched = crate::get_db_metadata(&database, "schema_version")
.await
.expect("fetch must succeed");
assert!(fetched.is_some());
let fetched = fetched.expect("metadata must exist");
assert_eq!(fetched.key, "schema_version");
assert_eq!(fetched.value, "0.5.0");
let listed = crate::list_db_metadata(&database)
.await
.expect("list must succeed");
assert!(!listed.is_empty());
}
}

39
kb_lib/src/db/schema.rs Normal file
View File

@@ -0,0 +1,39 @@
// file: kb_lib/src/db/schema.rs
//! Database schema initialization.
/// Ensures that the database schema exists.
pub(super) async fn ensure_schema(
database: &crate::KbDatabase,
) -> Result<(), crate::KbError> {
match database.connection() {
crate::KbDatabaseConnection::Sqlite(pool) => {
let metadata_table_result = sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS kb_db_metadata (
key TEXT NOT NULL PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"#,
)
.execute(pool)
.await;
if let Err(error) = metadata_table_result {
return Err(crate::KbError::Db(format!(
"cannot create table kb_db_metadata on sqlite: {}",
error
)));
}
let schema_version = crate::KbDbMetadataDto::new(
"schema_version".to_string(),
env!("CARGO_PKG_VERSION").to_string(),
);
let upsert_result = crate::upsert_db_metadata(database, &schema_version).await;
if let Err(error) = upsert_result {
return Err(error);
}
Ok(())
},
}
}

67
kb_lib/src/db/sqlite.rs Normal file
View File

@@ -0,0 +1,67 @@
// file: kb_lib/src/db/sqlite.rs
//! SQLite backend helpers.
/// Returns a displayable SQLite database URL-like string.
pub(crate) fn sqlite_database_url_from_config(
config: &crate::KbDatabaseConfig,
) -> Result<std::string::String, crate::KbError> {
let path = config.sqlite.path.trim();
if path.is_empty() {
return Err(crate::KbError::Config(
"database.sqlite.path must not be empty".to_string(),
));
}
Ok(format!("sqlite://{}", path))
}
/// Opens a SQLite pool according to configuration.
pub(crate) async fn connect_sqlite(
config: &crate::KbDatabaseConfig,
) -> Result<sqlx::SqlitePool, crate::KbError> {
let path = config.sqlite.path.trim();
if path.is_empty() {
return Err(crate::KbError::Config(
"database.sqlite.path must not be empty".to_string(),
));
}
if config.sqlite.max_connections == 0 {
return Err(crate::KbError::Config(
"database.sqlite.max_connections must be > 0".to_string(),
));
}
let database_path = std::path::Path::new(path);
let parent_option = database_path.parent();
if let Some(parent) = parent_option {
if !parent.as_os_str().is_empty() {
let create_result = std::fs::create_dir_all(parent);
if let Err(error) = create_result {
return Err(crate::KbError::Db(format!(
"cannot create sqlite parent directory '{}': {}",
parent.display(),
error
)));
}
}
}
let mut connect_options = sqlx::sqlite::SqliteConnectOptions::new()
.filename(database_path)
.create_if_missing(config.sqlite.create_if_missing)
.foreign_keys(true)
.busy_timeout(std::time::Duration::from_millis(
config.sqlite.busy_timeout_ms,
));
if config.sqlite.use_wal {
connect_options = connect_options.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal);
}
let pool_options =
sqlx::sqlite::SqlitePoolOptions::new().max_connections(config.sqlite.max_connections);
let connect_result = pool_options.connect_with(connect_options).await;
match connect_result {
Ok(pool) => Ok(pool),
Err(error) => Err(crate::KbError::Db(format!(
"cannot open sqlite database '{}': {}",
path, error
))),
}
}

7
kb_lib/src/db/types.rs Normal file
View File

@@ -0,0 +1,7 @@
// file: kb_lib/src/db/types.rs
//! Database shared types.
mod database_backend;
pub use crate::db::types::database_backend::KbDatabaseBackend;

View File

@@ -0,0 +1,11 @@
// file: kb_lib/src/db/types/database_backend.rs
//! Database backend discriminator.
/// Supported database backends.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum KbDatabaseBackend {
/// SQLite backend.
Sqlite,
}

View File

@@ -20,6 +20,8 @@ pub enum KbError {
Http(std::string::String), Http(std::string::String),
/// WebSocket transport error. /// WebSocket transport error.
Ws(std::string::String), Ws(std::string::String),
/// Database error.
Db(std::string::String),
/// Invalid internal state error. /// Invalid internal state error.
InvalidState(std::string::String), InvalidState(std::string::String),
/// Operation requested while the client is not connected. /// Operation requested while the client is not connected.
@@ -29,38 +31,38 @@ pub enum KbError {
} }
impl std::fmt::Display for KbError { impl std::fmt::Display for KbError {
fn fmt( fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
&self,
formatter: &mut std::fmt::Formatter<'_>,
) -> std::fmt::Result {
match self { match self {
Self::Config(message) => { Self::Config(message) => {
write!(formatter, "configuration error: {message}") write!(formatter, "configuration error: {message}")
}, }
Self::Io(message) => { Self::Io(message) => {
write!(formatter, "io error: {message}") write!(formatter, "io error: {message}")
}, }
Self::Json(message) => { Self::Json(message) => {
write!(formatter, "json error: {message}") write!(formatter, "json error: {message}")
}, }
Self::Tracing(message) => { Self::Tracing(message) => {
write!(formatter, "tracing error: {message}") write!(formatter, "tracing error: {message}")
}, }
Self::Http(message) => { Self::Http(message) => {
write!(formatter, "http error: {message}") write!(formatter, "http error: {message}")
}, }
Self::Ws(message) => { Self::Ws(message) => {
write!(formatter, "websocket error: {message}") write!(formatter, "websocket error: {message}")
}, }
Self::Db(message) => {
write!(formatter, "db error: {}", message)
}
Self::InvalidState(message) => { Self::InvalidState(message) => {
write!(formatter, "invalid state: {message}") write!(formatter, "invalid state: {message}")
}, }
Self::NotConnected(message) => { Self::NotConnected(message) => {
write!(formatter, "not connected: {message}") write!(formatter, "not connected: {message}")
}, }
Self::NotImplemented(message) => { Self::NotImplemented(message) => {
write!(formatter, "not implemented: {message}") write!(formatter, "not implemented: {message}")
}, }
} }
} }
} }

View File

@@ -18,6 +18,7 @@ mod types;
mod ws_client; mod ws_client;
mod rpc_ws_solana; mod rpc_ws_solana;
mod http_pool; mod http_pool;
mod db;
pub use crate::config::KbAppConfig; pub use crate::config::KbAppConfig;
pub use crate::config::KbConfig; pub use crate::config::KbConfig;
@@ -26,6 +27,8 @@ pub use crate::config::KbHttpEndpointConfig;
pub use crate::config::KbLoggingConfig; pub use crate::config::KbLoggingConfig;
pub use crate::config::KbSolanaConfig; pub use crate::config::KbSolanaConfig;
pub use crate::config::KbWsEndpointConfig; pub use crate::config::KbWsEndpointConfig;
pub use crate::config::KbDatabaseConfig;
pub use crate::config::KbSqliteDatabaseConfig;
pub use crate::constants::*; pub use crate::constants::*;
pub use crate::error::KbError; pub use crate::error::KbError;
pub use crate::rpc_ws::KbJsonRpcWsErrorObject; pub use crate::rpc_ws::KbJsonRpcWsErrorObject;
@@ -60,4 +63,12 @@ pub use crate::rpc_ws_solana::parse_kb_solana_ws_typed_notification;
pub use crate::rpc_ws_solana::parse_kb_solana_ws_typed_notification_from_event; pub use crate::rpc_ws_solana::parse_kb_solana_ws_typed_notification_from_event;
pub use crate::http_pool::HttpEndpointPool; pub use crate::http_pool::HttpEndpointPool;
pub use crate::http_pool::KbHttpPoolClientSnapshot; pub use crate::http_pool::KbHttpPoolClientSnapshot;
pub use crate::db::KbDatabase;
pub use crate::db::KbDatabaseBackend;
pub use crate::db::KbDatabaseConnection;
pub use crate::db::KbDbMetadataDto;
pub use crate::db::KbDbMetadataEntity;
pub use crate::db::get_db_metadata;
pub use crate::db::list_db_metadata;
pub use crate::db::upsert_db_metadata;