Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion database/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions database/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ const { rows } = await iii.trigger({
| `database::transactionExecute` | Write SQL inside an interactive transaction. Same envelope as `execute`. Rejects bare `BEGIN`/`COMMIT`/`ROLLBACK`/`SAVEPOINT`/`SET TRANSACTION` with `INVALID_PARAM` — finalize via the dedicated handlers below. |
| `database::commitTransaction` | Commit and finalize an interactive transaction. Subsequent calls against the same id return `TRANSACTION_NOT_FOUND`. |
| `database::rollbackTransaction` | Rollback and finalize an interactive transaction. Subsequent calls against the same id return `TRANSACTION_NOT_FOUND`. |
| `database::listDatabases` | List configured databases. Returns `{ databases, count }`; each entry has `name`, `driver`, credential-redacted `url`, `pool` settings, and `tls` (`mode`, `ca_cert_present`, `trust_native`). Config only — no health checks or live pool stats. |

## Triggers

Expand Down
3 changes: 3 additions & 0 deletions database/skills/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ point. Placeholder syntax: `?` for SQLite and MySQL, `$1`/`$2`/… for Postgres.
transaction.
- `database::rollbackTransaction` — roll back and finalize an interactive
transaction.
- `database::listDatabases` — list every configured database with its
driver, credential-redacted connection URL, pool settings, and TLS mode.
Config details only; no health checks or live pool statistics.

Interactive transactions auto-roll back when `timeout_ms` elapses (default
30 s, max 5 min). Prepared handles default to a 1 h TTL (max 24 h) with no
Expand Down
11 changes: 8 additions & 3 deletions database/src/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,16 @@ pub async fn build_pools(cfg: &WorkerConfig) -> Result<HashMap<String, Pool>, St
Ok(pools)
}

/// Replace in-memory pools with freshly built ones from `cfg`.
pub async fn apply_config(state: &AppState, cfg: WorkerConfig) -> Result<(), String> {
let new_pools = build_pools(&cfg).await?;
let mut guard = state.pools.write().await;
*guard = new_pools;
// Swap pools and the config snapshot inside one critical section (pools
// lock first, then config) so a concurrent reader never observes new
// pools paired with the old config or vice-versa. A failed build above
// leaves both untouched.
let mut pools_guard = state.pools.write().await;
let mut config_guard = state.config.write().await;
*pools_guard = new_pools;
*config_guard = cfg;
Ok(())
}

Expand Down
1 change: 1 addition & 0 deletions database/src/handlers/begin_transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ pub(crate) mod tests {
pools.insert("primary".to_string(), Pool::Sqlite(pool));
AppState {
pools: Arc::new(RwLock::new(pools)),
config: Arc::new(RwLock::new(crate::config::WorkerConfig::default())),
handles: Arc::new(HandleRegistry::new()),
transactions: TxRegistry::new(),
log: Logger::new(),
Expand Down
3 changes: 3 additions & 0 deletions database/src/handlers/commit_transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,9 @@ mod tests {
pools.insert("primary".to_string(), crate::pool::Pool::Sqlite(pool));
let st = crate::handlers::AppState {
pools: std::sync::Arc::new(tokio::sync::RwLock::new(pools)),
config: std::sync::Arc::new(tokio::sync::RwLock::new(
crate::config::WorkerConfig::default(),
)),
handles: std::sync::Arc::new(crate::handle::HandleRegistry::new()),
transactions: crate::transaction::TxRegistry::new(),
log: iii_observability::Logger::new(),
Expand Down
1 change: 1 addition & 0 deletions database/src/handlers/execute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ mod tests {
pools.insert("primary".to_string(), Pool::Sqlite(pool));
AppState {
pools: Arc::new(RwLock::new(pools)),
config: Arc::new(RwLock::new(crate::config::WorkerConfig::default())),
handles: Arc::new(HandleRegistry::new()),
transactions: crate::transaction::TxRegistry::new(),
log: iii_observability::Logger::new(),
Expand Down
222 changes: 222 additions & 0 deletions database/src/handlers/list_databases.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
//! `database::listDatabases` — config details for every registered database.
//! Config-only: no health checks, no live pool stats. Credentials are
//! scrubbed from the connection URL before it leaves the process.

use super::AppState;
use crate::config::{redact_url, TlsMode};
use crate::transaction::driver_system;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct ListDatabasesReq {}

/// Pool settings echoed back from config (no live stats).
#[derive(Debug, Serialize, JsonSchema)]
pub struct PoolInfo {
pub max: u32,
pub idle_timeout_ms: u64,
pub acquire_timeout_ms: u64,
}

/// TLS settings. `ca_cert` is reported as a presence boolean only — never
/// the path, which would leak filesystem layout.
#[derive(Debug, Serialize, JsonSchema)]
pub struct TlsInfo {
pub mode: TlsMode,
pub ca_cert_present: bool,
pub trust_native: bool,
}

#[derive(Debug, Serialize, JsonSchema)]
pub struct DatabaseInfo {
/// Logical key (e.g. "primary").
pub name: String,
/// "postgres" | "mysql" | "sqlite".
pub driver: String,
/// Connection URL with credentials redacted.
pub url: String,
pub pool: PoolInfo,
pub tls: TlsInfo,
}

#[derive(Debug, Serialize, JsonSchema)]
pub struct ListDatabasesResp {
pub databases: Vec<DatabaseInfo>,
pub count: usize,
}

pub async fn handle(state: &AppState, _req: ListDatabasesReq) -> Result<ListDatabasesResp, String> {
let cfg = state.config.read().await;
let mut databases: Vec<DatabaseInfo> = cfg
.databases
.iter()
.map(|(name, db)| DatabaseInfo {
name: name.clone(),
driver: driver_system(db.driver).to_string(),
url: redact_url(&db.url),
pool: PoolInfo {
max: db.pool.max,
idle_timeout_ms: db.pool.idle_timeout_ms,
acquire_timeout_ms: db.pool.acquire_timeout_ms,
},
tls: TlsInfo {
mode: db.tls.mode,
ca_cert_present: db.tls.ca_cert.is_some(),
trust_native: db.tls.trust_native,
},
})
.collect();
// HashMap iteration order is nondeterministic; sort so output is stable.
databases.sort_by(|a, b| a.name.cmp(&b.name));
let count = databases.len();
Ok(ListDatabasesResp { databases, count })
}

#[cfg(test)]
mod tests {
use super::*;
use crate::config::WorkerConfig;
use crate::handle::HandleRegistry;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;

fn state_from_yaml(yaml: &str) -> AppState {
let cfg = WorkerConfig::from_yaml(yaml).unwrap();
AppState {
// The handler reads only `config`; pools are never touched.
pools: Arc::new(RwLock::new(HashMap::new())),
config: Arc::new(RwLock::new(cfg)),
handles: Arc::new(HandleRegistry::new()),
transactions: crate::transaction::TxRegistry::new(),
log: iii_observability::Logger::new(),
}
}

#[tokio::test(flavor = "multi_thread")]
async fn returns_sqlite_primary_with_config_defaults() {
// Arrange
let st = state_from_yaml("databases:\n primary:\n url: \"sqlite::memory:\"\n");

// Act
let resp = handle(&st, ListDatabasesReq::default()).await.unwrap();

// Assert
assert_eq!(resp.count, 1);
let db = &resp.databases[0];
assert_eq!(db.name, "primary");
assert_eq!(db.driver, "sqlite");
assert_eq!(db.pool.max, 10);
assert_eq!(db.pool.idle_timeout_ms, 30_000);
assert_eq!(db.pool.acquire_timeout_ms, 5_000);
assert_eq!(db.tls.mode, TlsMode::Require);
assert!(!db.tls.ca_cert_present);
assert!(db.tls.trust_native);
}

#[tokio::test(flavor = "multi_thread")]
async fn redacts_postgres_password() {
// Arrange
let st = state_from_yaml(
"databases:\n main:\n url: \"postgres://user:secret@host:5432/db\"\n",
);

// Act
let resp = handle(&st, ListDatabasesReq::default()).await.unwrap();

// Assert
let db = &resp.databases[0];
assert_eq!(db.driver, "postgres");
assert_eq!(db.url, "postgres://***@host:5432/db");
assert!(!db.url.contains("secret"));
assert!(!db.url.contains("user:"));
}

#[tokio::test(flavor = "multi_thread")]
async fn redacts_mysql_password() {
// Arrange
let st = state_from_yaml(
"databases:\n main:\n url: \"mysql://admin:pw@127.0.0.1:3306/test\"\n",
);

// Act
let resp = handle(&st, ListDatabasesReq::default()).await.unwrap();

// Assert
let db = &resp.databases[0];
assert_eq!(db.driver, "mysql");
assert_eq!(db.url, "mysql://***@127.0.0.1:3306/test");
assert!(!db.url.contains("pw@"));
}

#[tokio::test(flavor = "multi_thread")]
async fn sqlite_url_passes_through_without_credentials() {
// Arrange
let st = state_from_yaml("databases:\n mem:\n url: \"sqlite::memory:\"\n");

// Act
let resp = handle(&st, ListDatabasesReq::default()).await.unwrap();

// Assert
assert_eq!(resp.databases[0].url, "sqlite::memory:");
assert_eq!(resp.databases[0].driver, "sqlite");
}

#[tokio::test(flavor = "multi_thread")]
async fn sorts_databases_by_name_and_counts() {
// Arrange
let st = state_from_yaml(
"databases:\n zeta:\n url: \"sqlite::memory:\"\n alpha:\n url: \"sqlite::memory:\"\n mid:\n url: \"sqlite::memory:\"\n",
);

// Act
let resp = handle(&st, ListDatabasesReq::default()).await.unwrap();

// Assert
assert_eq!(resp.count, 3);
let names: Vec<&str> = resp.databases.iter().map(|d| d.name.as_str()).collect();
assert_eq!(names, ["alpha", "mid", "zeta"]);
}

#[tokio::test(flavor = "multi_thread")]
async fn reports_tls_overrides_without_leaking_cert_path() {
// Arrange
let st = state_from_yaml(
"databases:\n main:\n url: \"postgres://u@host/db\"\n tls:\n mode: verify-full\n ca_cert: /etc/ssl/private-ca.pem\n trust_native: false\n",
);

// Act
let resp = handle(&st, ListDatabasesReq::default()).await.unwrap();

// Assert
let json = serde_json::to_value(&resp).unwrap();
let db = &json["databases"][0];
assert_eq!(db["tls"]["mode"], "verify-full");
assert_eq!(db["tls"]["ca_cert_present"], true);
assert_eq!(db["tls"]["trust_native"], false);
assert!(!json.to_string().contains("private-ca.pem"));
}

#[tokio::test(flavor = "multi_thread")]
async fn response_envelope_matches_sibling_convention() {
// Arrange
let st = state_from_yaml("databases:\n primary:\n url: \"sqlite::memory:\"\n");

// Act
let resp = handle(&st, ListDatabasesReq::default()).await.unwrap();

// Assert
let json = serde_json::to_value(&resp).unwrap();
let mut keys: Vec<&str> = json
.as_object()
.unwrap()
.keys()
.map(|k| k.as_str())
.collect();
keys.sort_unstable();
assert_eq!(keys, ["count", "databases"]);
assert!(json["databases"].is_array());
assert!(json["count"].is_number());
}
}
6 changes: 6 additions & 0 deletions database/src/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
//! payload from the SDK, validates it, dispatches to the configured pool,
//! and serializes the result.

use crate::config::WorkerConfig;
use crate::error::DbError;
use crate::handle::HandleRegistry;
use crate::pool::Pool;
Expand All @@ -14,6 +15,7 @@ use tokio::sync::RwLock;
pub mod begin_transaction;
pub mod commit_transaction;
pub mod execute;
pub mod list_databases;
pub mod prepare;
pub mod query;
pub mod rollback_transaction;
Expand All @@ -28,6 +30,10 @@ pub(crate) use query::rows_to_objects as query_rows_to_objects;
#[derive(Clone)]
pub struct AppState {
pub pools: Arc<RwLock<HashMap<String, Pool>>>,
/// Live config snapshot the pools were built from. Swapped together with
/// `pools` on hot-reload (see `configuration::apply_config`) so readers
/// never observe new pools paired with stale config.
pub config: Arc<RwLock<WorkerConfig>>,
pub handles: Arc<HandleRegistry>,
pub transactions: TxRegistry,
pub log: Logger,
Expand Down
1 change: 1 addition & 0 deletions database/src/handlers/prepare.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ mod tests {
pools.insert("primary".to_string(), Pool::Sqlite(pool));
AppState {
pools: Arc::new(RwLock::new(pools)),
config: Arc::new(RwLock::new(crate::config::WorkerConfig::default())),
handles: Arc::new(HandleRegistry::new()),
transactions: crate::transaction::TxRegistry::new(),
log: iii_observability::Logger::new(),
Expand Down
1 change: 1 addition & 0 deletions database/src/handlers/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ mod tests {
pools.insert("primary".to_string(), Pool::Sqlite(pool));
AppState {
pools: Arc::new(RwLock::new(pools)),
config: Arc::new(RwLock::new(crate::config::WorkerConfig::default())),
handles: Arc::new(HandleRegistry::new()),
transactions: crate::transaction::TxRegistry::new(),
log: iii_observability::Logger::new(),
Expand Down
3 changes: 3 additions & 0 deletions database/src/handlers/rollback_transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ mod tests {
pools.insert("primary".to_string(), crate::pool::Pool::Sqlite(pool));
let st = crate::handlers::AppState {
pools: std::sync::Arc::new(tokio::sync::RwLock::new(pools)),
config: std::sync::Arc::new(tokio::sync::RwLock::new(
crate::config::WorkerConfig::default(),
)),
handles: std::sync::Arc::new(crate::handle::HandleRegistry::new()),
transactions: crate::transaction::TxRegistry::new(),
log: iii_observability::Logger::new(),
Expand Down
2 changes: 2 additions & 0 deletions database/src/handlers/run_statement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ mod tests {
pools.insert("primary".to_string(), Pool::Sqlite(pool));
AppState {
pools: Arc::new(RwLock::new(pools)),
config: Arc::new(RwLock::new(crate::config::WorkerConfig::default())),
handles: Arc::new(HandleRegistry::new()),
transactions: crate::transaction::TxRegistry::new(),
log: iii_observability::Logger::new(),
Expand All @@ -75,6 +76,7 @@ mod tests {
pools.insert("primary".to_string(), Pool::Sqlite(pool));
let st = AppState {
pools: Arc::new(RwLock::new(pools)),
config: Arc::new(RwLock::new(crate::config::WorkerConfig::default())),
handles: Arc::new(HandleRegistry::new()),
transactions: crate::transaction::TxRegistry::new(),
log: iii_observability::Logger::new(),
Expand Down
1 change: 1 addition & 0 deletions database/src/handlers/transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ mod tests {
pools.insert("primary".to_string(), Pool::Sqlite(pool));
AppState {
pools: Arc::new(RwLock::new(pools)),
config: Arc::new(RwLock::new(crate::config::WorkerConfig::default())),
handles: Arc::new(HandleRegistry::new()),
transactions: crate::transaction::TxRegistry::new(),
log: iii_observability::Logger::new(),
Expand Down
3 changes: 3 additions & 0 deletions database/src/handlers/transaction_execute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,9 @@ mod tests {
pools.insert("primary".to_string(), crate::pool::Pool::Sqlite(pool));
let st = crate::handlers::AppState {
pools: std::sync::Arc::new(tokio::sync::RwLock::new(pools)),
config: std::sync::Arc::new(tokio::sync::RwLock::new(
crate::config::WorkerConfig::default(),
)),
handles: std::sync::Arc::new(crate::handle::HandleRegistry::new()),
transactions: crate::transaction::TxRegistry::new(),
log: iii_observability::Logger::new(),
Expand Down
Loading
Loading