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
16 changes: 16 additions & 0 deletions clients/rook/migrations/0002_settings.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- Migration 0002: settings singleton
--
-- A single-row table that holds the global Rook runtime settings.
-- The row is always upserted via the primary-key value 1, so there
-- can never be more than one settings record.

CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
gateway_port INTEGER NOT NULL DEFAULT 11434,
default_routing_policy TEXT NOT NULL DEFAULT 'priority',
max_retries INTEGER NOT NULL DEFAULT 3,
cooldown_seconds INTEGER NOT NULL DEFAULT 60,
log_json INTEGER NOT NULL DEFAULT 0,
log_level TEXT NOT NULL DEFAULT 'info',
updated_at TEXT NOT NULL
);
35 changes: 35 additions & 0 deletions clients/rook/src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
pub mod account;
pub mod pool;
pub mod route;
pub mod settings;

use crate::domain::RookError;
use chrono::Utc;
Expand All @@ -20,6 +21,11 @@ const MIGRATION_SQL: &str = include_str!(concat!(
"/migrations/0001_initial.sql"
));

const MIGRATION_SQL_0002: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/migrations/0002_settings.sql"
));

/// A handle to the Rook SQLite database.
///
/// Cheap to clone — cloning shares the underlying connection pool.
Expand Down Expand Up @@ -124,6 +130,35 @@ impl SqliteDb {
.map_err(|e| RookError::Registry(format!("failed to record migration: {e}")))?;
}

// ── Migration 0002: settings ──────────────────────────────────────────
let version_0002 = "0002_settings";
let row_0002: Option<(String,)> = sqlx::query_as(
"SELECT version FROM schema_migrations WHERE version = ?"
)
.bind(version_0002)
.fetch_optional(pool)
.await
.map_err(|e| RookError::Registry(format!("failed to check migration 0002 status: {e}")))?;

if row_0002.is_none() {
sqlx::raw_sql(MIGRATION_SQL_0002)
.execute(pool)
.await
.map_err(|e| RookError::Registry(format!("migration 0002 failed: {e}")))?;

let now = Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)"
)
.bind(version_0002)
.bind(&now)
.execute(pool)
.await
.map_err(|e| {
RookError::Registry(format!("failed to record migration 0002: {e}"))
})?;
}

Ok(())
}
}
177 changes: 177 additions & 0 deletions clients/rook/src/db/settings.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
//! SQLite persistence for the [`RookSettings`] singleton.
//!
//! The `settings` table uses a single row (always `id = 1`) that is upserted
//! on every [`SqliteDb::save_settings`] call.

use crate::db::SqliteDb;
use crate::domain::{RookError, RookSettings, RoutingPolicy, SelectionStrategy};
use chrono::Utc;
use sqlx::Row;

// ── Serialization helpers ─────────────────────────────────────────────────────

fn strategy_to_str(s: &SelectionStrategy) -> &'static str {
match s {
SelectionStrategy::Priority => "priority",
SelectionStrategy::RoundRobin => "round_robin",
SelectionStrategy::Weighted => "weighted",
SelectionStrategy::Failover => "failover",
}
}

fn str_to_strategy(s: &str) -> Result<SelectionStrategy, RookError> {
match s {
"priority" => Ok(SelectionStrategy::Priority),
"round_robin" => Ok(SelectionStrategy::RoundRobin),
"weighted" => Ok(SelectionStrategy::Weighted),
"failover" => Ok(SelectionStrategy::Failover),
other => Err(RookError::Registry(format!(
"unknown selection strategy '{other}'"
))),
}
}

// ── Row mapping ───────────────────────────────────────────────────────────────

fn row_to_settings(row: &sqlx::sqlite::SqliteRow) -> Result<RookSettings, RookError> {
let gateway_port: i64 = row
.try_get("gateway_port")
.map_err(|e| RookError::Registry(format!("missing gateway_port: {e}")))?;

let strategy_str: String = row
.try_get("default_routing_policy")
.map_err(|e| RookError::Registry(format!("missing default_routing_policy: {e}")))?;
let strategy = str_to_strategy(&strategy_str)?;

let max_retries: i64 = row
.try_get("max_retries")
.map_err(|e| RookError::Registry(format!("missing max_retries: {e}")))?;

let cooldown_seconds: i64 = row
.try_get("cooldown_seconds")
.map_err(|e| RookError::Registry(format!("missing cooldown_seconds: {e}")))?;

let log_json: i64 = row
.try_get("log_json")
.map_err(|e| RookError::Registry(format!("missing log_json: {e}")))?;

let log_level: String = row
.try_get("log_level")
.map_err(|e| RookError::Registry(format!("missing log_level: {e}")))?;

Ok(RookSettings {
gateway_port: gateway_port as u16,
default_routing_policy: RoutingPolicy {
strategy,
max_retries: max_retries as u32,
cooldown_seconds: cooldown_seconds as u64,
},
log_json: log_json != 0,
log_level,
})
}

// ── SqliteDb methods ──────────────────────────────────────────────────────────

impl SqliteDb {
/// Load the settings singleton.
///
/// Returns `None` if no settings row exists yet (caller should use
/// [`RookSettings::default`]).
pub async fn load_settings(&self) -> Option<RookSettings> {
let result = sqlx::query(
"SELECT gateway_port, default_routing_policy, max_retries,
cooldown_seconds, log_json, log_level
FROM settings
WHERE id = 1",
)
.fetch_optional(self.pool())
.await;

match result {
Ok(Some(row)) => row_to_settings(&row).ok(),
_ => None,
}
}

/// Upsert the settings singleton.
pub async fn save_settings(&self, s: RookSettings) -> Result<(), RookError> {
let strategy = strategy_to_str(&s.default_routing_policy.strategy);
let now = Utc::now().to_rfc3339();

sqlx::query(
"INSERT INTO settings
(id, gateway_port, default_routing_policy, max_retries,
cooldown_seconds, log_json, log_level, updated_at)
VALUES (1, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
gateway_port = excluded.gateway_port,
default_routing_policy = excluded.default_routing_policy,
max_retries = excluded.max_retries,
cooldown_seconds = excluded.cooldown_seconds,
log_json = excluded.log_json,
log_level = excluded.log_level,
updated_at = excluded.updated_at",
)
.bind(s.gateway_port as i64)
.bind(strategy)
.bind(s.default_routing_policy.max_retries as i64)
.bind(s.default_routing_policy.cooldown_seconds as i64)
.bind(if s.log_json { 1i64 } else { 0i64 })
.bind(&s.log_level)
.bind(&now)
.execute(self.pool())
.await
.map_err(|e| RookError::Registry(format!("failed to save settings: {e}")))?;

Ok(())
}
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
use super::*;

#[tokio::test]
async fn load_returns_none_when_no_row() {
let db = SqliteDb::open_in_memory().await.unwrap();
assert!(db.load_settings().await.is_none());
}

#[tokio::test]
async fn save_and_load_round_trip() {
let db = SqliteDb::open_in_memory().await.unwrap();

let mut s = RookSettings::default();
s.gateway_port = 9090;
s.log_json = true;
s.log_level = "debug".to_owned();
s.default_routing_policy.max_retries = 5;

db.save_settings(s).await.unwrap();

let loaded = db.load_settings().await.unwrap();
assert_eq!(loaded.gateway_port, 9090);
assert!(loaded.log_json);
assert_eq!(loaded.log_level, "debug");
assert_eq!(loaded.default_routing_policy.max_retries, 5);
}

#[tokio::test]
async fn save_twice_upserts() {
let db = SqliteDb::open_in_memory().await.unwrap();

let mut s1 = RookSettings::default();
s1.gateway_port = 8080;
db.save_settings(s1).await.unwrap();

let mut s2 = RookSettings::default();
s2.gateway_port = 9999;
db.save_settings(s2).await.unwrap();

let loaded = db.load_settings().await.unwrap();
assert_eq!(loaded.gateway_port, 9999);
}
}
31 changes: 31 additions & 0 deletions clients/rook/src/domain/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,37 @@ pub struct RoutingPolicy {
pub cooldown_seconds: u64,
}

/// Global runtime settings for the Rook gateway.
///
/// Stored as a single row in the `settings` table (key/value or single-row
/// schema). Defaults are applied when no row is present.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RookSettings {
/// TCP port the HTTP gateway listens on.
pub gateway_port: u16,
/// Default routing policy applied when no per-route policy is set.
pub default_routing_policy: RoutingPolicy,
/// Whether to emit structured JSON logs (false = human-readable).
pub log_json: bool,
/// Minimum log level filter (e.g., `"info"`, `"debug"`).
pub log_level: String,
}

impl Default for RookSettings {
fn default() -> Self {
Self {
gateway_port: 11434,
default_routing_policy: RoutingPolicy {
strategy: SelectionStrategy::Priority,
max_retries: 3,
cooldown_seconds: 60,
},
log_json: false,
log_level: "info".to_owned(),
}
}
}

// ── Tests ────────────────────────────────────────────────────────────────────

#[cfg(test)]
Expand Down
Loading
Loading