use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<ProfileConfig>,
#[serde(default)]
pub storage: StorageConfig,
#[serde(default)]
pub encryption: EncryptionConfig,
#[serde(default)]
pub server: ServerConfig,
#[serde(default)]
pub performance: PerformanceConfig,
#[serde(default)]
pub audit: crate::audit::AuditConfig,
#[serde(default)]
pub optimizer: OptimizerConfig,
#[serde(default)]
pub authentication: AuthenticationConfig,
#[serde(default)]
pub compression: CompressionConfig,
#[serde(default)]
pub materialized_views: MaterializedViewConfig,
#[serde(default)]
pub vector: VectorConfig,
#[serde(default)]
pub sync: SyncConfig,
#[serde(default)]
pub session: SessionConfig,
#[serde(default)]
pub locks: LockConfig,
#[serde(default)]
pub dump: DumpConfig,
#[serde(default)]
pub resource_quotas: ResourceQuotaConfig,
#[serde(default)]
pub api: ApiConfig,
}
impl Default for Config {
fn default() -> Self {
Self {
profile: None,
storage: StorageConfig::default(),
encryption: EncryptionConfig::default(),
server: ServerConfig::default(),
performance: PerformanceConfig::default(),
audit: crate::audit::AuditConfig::default(),
optimizer: OptimizerConfig::default(),
authentication: AuthenticationConfig::default(),
compression: CompressionConfig::default(),
materialized_views: MaterializedViewConfig::default(),
vector: VectorConfig::default(),
sync: SyncConfig::default(),
session: SessionConfig::default(),
locks: LockConfig::default(),
dump: DumpConfig::default(),
resource_quotas: ResourceQuotaConfig::default(),
api: ApiConfig::default(),
}
}
}
impl Config {
pub fn in_memory() -> Self {
Self {
storage: StorageConfig {
path: None,
memory_only: true,
wal_enabled: false, ..Default::default()
},
audit: crate::audit::AuditConfig::default(),
..Default::default()
}
}
pub fn from_file(path: impl AsRef<std::path::Path>) -> crate::Result<Self> {
let content = std::fs::read_to_string(path)?;
Self::from_toml_str(&content)
}
pub fn from_toml_str(content: &str) -> crate::Result<Self> {
let mut config: Config =
toml::from_str(content).map_err(|e| crate::Error::config(format!("Failed to parse config: {}", e)))?;
if let Some(profile) = config.profile {
let raw: toml::Value =
toml::from_str(content).map_err(|e| crate::Error::config(format!("Failed to parse config: {}", e)))?;
config.apply_profile_defaults(profile, |key| raw.get("storage").and_then(|s| s.get(key)).is_some());
}
Ok(config)
}
pub fn with_profile(profile: ProfileConfig) -> Self {
let mut config = Config {
profile: Some(profile),
..Config::default()
};
config.apply_profile_defaults(profile, |_| false);
config
}
fn apply_profile_defaults(&mut self, profile: ProfileConfig, explicitly_set: impl Fn(&str) -> bool) {
let bundle = profile.storage_bundle();
if !explicitly_set("wal_sync_mode") {
self.storage.wal_sync_mode = bundle.wal_sync_mode;
}
if !explicitly_set("time_travel_enabled") {
self.storage.time_travel_enabled = bundle.time_travel_enabled;
}
if let Some(durable_commit) = bundle.durable_commit {
if !explicitly_set("durable_commit") {
self.storage.durable_commit = durable_commit;
}
}
if let Some(compression) = bundle.compression {
if !explicitly_set("compression") {
self.storage.compression = compression;
}
}
if let Some(cache_size) = bundle.cache_size {
if !explicitly_set("cache_size") {
self.storage.cache_size = cache_size;
}
}
}
pub fn save_to_file(&self, path: impl AsRef<std::path::Path>) -> crate::Result<()> {
let content = toml::to_string_pretty(self)
.map_err(|e| crate::Error::config(format!("Failed to serialize config: {}", e)))?;
std::fs::write(path, content)?;
Ok(())
}
pub fn validate(&self) -> crate::Result<()> {
self.session.validate()?;
self.locks.validate()?;
self.dump.validate()?;
self.resource_quotas.validate()?;
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct StorageConfig {
pub path: Option<PathBuf>,
pub memory_only: bool,
pub wal_enabled: bool,
pub wal_sync_mode: WalSyncModeConfig,
pub cache_size: usize,
pub compression: CompressionType,
pub time_travel_enabled: bool,
pub query_timeout_ms: Option<u64>,
pub statement_timeout_ms: Option<u64>,
pub transaction_isolation: TransactionIsolation,
#[serde(default = "default_slow_query_threshold")]
pub slow_query_threshold_ms: Option<u64>,
#[serde(default)]
pub logical_wal_per_statement: bool,
pub durable_commit: bool,
#[serde(default)]
pub version_retention: Option<String>,
#[serde(default)]
pub version_gc_interval_secs: Option<u64>,
#[serde(default = "default_version_gc_max_per_cycle")]
pub version_gc_max_per_cycle: usize,
pub group_commit_window_us: u64,
#[serde(default)]
pub rocksdb_write_buffer_size: Option<usize>,
#[serde(default)]
pub rocksdb_max_write_buffer_number: Option<i32>,
#[serde(default)]
pub rocksdb_min_write_buffer_number_to_merge: Option<i32>,
#[serde(default)]
pub rocksdb_level0_file_num_compaction_trigger: Option<i32>,
#[serde(default)]
pub rocksdb_max_background_jobs: Option<i32>,
#[serde(default)]
pub rocksdb_bytes_per_sync: Option<u64>,
}
fn default_slow_query_threshold() -> Option<u64> {
Some(1000)
}
fn default_version_gc_max_per_cycle() -> usize {
50_000
}
impl StorageConfig {
pub fn version_retention_secs(&self) -> crate::Result<Option<u64>> {
match &self.version_retention {
None => Ok(None),
Some(raw) => parse_retention_duration_secs(raw).map(Some),
}
}
pub fn effective_version_gc_interval_secs(&self) -> crate::Result<u64> {
if self.version_retention_secs()?.is_none() {
return Ok(0);
}
Ok(self.version_gc_interval_secs.unwrap_or(300))
}
}
pub fn parse_retention_duration_secs(raw: &str) -> crate::Result<u64> {
let s = raw.trim();
if s.is_empty() {
return Err(crate::Error::config(
"storage.version_retention is empty; use e.g. \"24h\", \"7d\" or a number of seconds",
));
}
let (digits, multiplier) = match s.chars().last() {
Some('s') | Some('S') => (&s[..s.len() - 1], 1u64),
Some('m') | Some('M') => (&s[..s.len() - 1], 60u64),
Some('h') | Some('H') => (&s[..s.len() - 1], 3_600u64),
Some('d') | Some('D') => (&s[..s.len() - 1], 86_400u64),
Some('w') | Some('W') => (&s[..s.len() - 1], 604_800u64),
_ => (s, 1u64),
};
let value: u64 = digits.trim().parse().map_err(|_| {
crate::Error::config(format!(
"invalid storage.version_retention '{}': expected seconds or <n>[s|m|h|d|w]",
raw
))
})?;
value
.checked_mul(multiplier)
.ok_or_else(|| crate::Error::config(format!("storage.version_retention '{}' overflows u64 seconds", raw)))
}
fn default_idle_timeout_secs() -> u64 {
300 }
fn default_copy_max_buffered_rows() -> usize {
10_000_000 }
fn default_copy_max_record_bytes() -> usize {
1_073_741_824 }
impl Default for StorageConfig {
fn default() -> Self {
Self {
path: Some(PathBuf::from("./heliosdb-data")),
memory_only: false,
wal_enabled: true,
wal_sync_mode: WalSyncModeConfig::Sync, cache_size: 512 * 1024 * 1024, compression: CompressionType::Zstd,
time_travel_enabled: true, query_timeout_ms: None, statement_timeout_ms: None, transaction_isolation: TransactionIsolation::ReadCommitted, slow_query_threshold_ms: Some(1000), logical_wal_per_statement: false, durable_commit: false,
version_retention: None, version_gc_interval_secs: None, version_gc_max_per_cycle: default_version_gc_max_per_cycle(),
group_commit_window_us: 1000,
rocksdb_write_buffer_size: None,
rocksdb_max_write_buffer_number: None,
rocksdb_min_write_buffer_number_to_merge: None,
rocksdb_level0_file_num_compaction_trigger: None,
rocksdb_max_background_jobs: None,
rocksdb_bytes_per_sync: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProfileConfig {
Safe,
Balanced,
Fast,
Agent,
#[serde(rename = "fast_ingest")]
FastIngest,
}
pub(crate) struct ProfileStorageBundle {
pub wal_sync_mode: WalSyncModeConfig,
pub time_travel_enabled: bool,
pub durable_commit: Option<bool>,
pub compression: Option<CompressionType>,
pub cache_size: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CodeIndexProfileDefaults {
pub skip_symbol_refs: bool,
pub skip_cross_file_resolve: bool,
pub chunk_size: Option<usize>,
}
impl ProfileConfig {
pub fn as_str(&self) -> &'static str {
match self {
ProfileConfig::Safe => "safe",
ProfileConfig::Balanced => "balanced",
ProfileConfig::Fast => "fast",
ProfileConfig::Agent => "agent",
ProfileConfig::FastIngest => "fast_ingest",
}
}
pub fn code_index_overrides(&self) -> Option<CodeIndexProfileDefaults> {
match self {
ProfileConfig::FastIngest => Some(CodeIndexProfileDefaults {
skip_symbol_refs: true,
skip_cross_file_resolve: true,
chunk_size: Some(2000),
}),
_ => None,
}
}
pub(crate) fn storage_bundle(&self) -> ProfileStorageBundle {
match self {
ProfileConfig::Safe => ProfileStorageBundle {
wal_sync_mode: WalSyncModeConfig::Sync,
time_travel_enabled: true,
durable_commit: None,
compression: None,
cache_size: None,
},
ProfileConfig::Balanced => ProfileStorageBundle {
wal_sync_mode: WalSyncModeConfig::GroupCommit,
time_travel_enabled: true,
durable_commit: None,
compression: None,
cache_size: None,
},
ProfileConfig::Fast => ProfileStorageBundle {
wal_sync_mode: WalSyncModeConfig::GroupCommit,
time_travel_enabled: false,
durable_commit: Some(false),
compression: None,
cache_size: None,
},
ProfileConfig::FastIngest => ProfileStorageBundle {
wal_sync_mode: WalSyncModeConfig::Async,
time_travel_enabled: false,
durable_commit: Some(false),
compression: Some(CompressionType::Lz4),
cache_size: Some(2 * 1024 * 1024 * 1024),
},
ProfileConfig::Agent => ProfileStorageBundle {
wal_sync_mode: WalSyncModeConfig::GroupCommit,
time_travel_enabled: true,
durable_commit: None,
compression: None,
cache_size: None,
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WalSyncModeConfig {
Sync,
Async,
GroupCommit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CompressionType {
None,
Zstd,
Lz4,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum TransactionIsolation {
ReadUncommitted,
ReadCommitted,
RepeatableRead,
Serializable,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct EncryptionConfig {
pub enabled: bool,
pub algorithm: EncryptionAlgorithm,
pub key_source: KeySource,
pub rotation_interval_days: u32,
#[serde(default)]
pub zke: ZkeEncryptionConfig,
}
impl Default for EncryptionConfig {
fn default() -> Self {
Self {
enabled: false,
algorithm: EncryptionAlgorithm::Aes256Gcm,
key_source: KeySource::Environment("HELIOSDB_ENCRYPTION_KEY".to_string()),
rotation_interval_days: 90,
zke: ZkeEncryptionConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ZkeEncryptionConfig {
pub enabled: bool,
pub mode: ZkeMode,
pub require_key_hash: bool,
pub replay_protection: bool,
pub nonce_window_secs: u64,
pub max_cached_nonces: usize,
}
impl Default for ZkeEncryptionConfig {
fn default() -> Self {
Self {
enabled: false,
mode: ZkeMode::PerRequest,
require_key_hash: true,
replay_protection: true,
nonce_window_secs: 300,
max_cached_nonces: 10000,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ZkeMode {
Full,
Hybrid,
PerRequest,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum EncryptionAlgorithm {
Aes256Gcm,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum KeySource {
Environment(String),
File(PathBuf),
Kms {
provider: String,
key_id: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ServerConfig {
pub listen_addr: String,
pub port: u16,
pub oracle_port: Option<u16>,
pub max_connections: usize,
#[serde(default = "default_idle_timeout_secs")]
pub idle_timeout_secs: u64,
#[serde(default = "default_copy_max_buffered_rows")]
pub copy_max_buffered_rows: usize,
#[serde(default = "default_copy_max_record_bytes")]
pub copy_max_record_bytes: usize,
pub tls_enabled: bool,
pub tls_cert_path: Option<PathBuf>,
pub tls_key_path: Option<PathBuf>,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
listen_addr: "127.0.0.1".to_string(),
port: 5432,
oracle_port: Some(1521), max_connections: 100,
idle_timeout_secs: default_idle_timeout_secs(),
copy_max_buffered_rows: default_copy_max_buffered_rows(),
copy_max_record_bytes: default_copy_max_record_bytes(),
tls_enabled: false,
tls_cert_path: None,
tls_key_path: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct PerformanceConfig {
pub worker_threads: usize,
pub query_timeout_secs: u64,
pub simd_enabled: bool,
pub parallel_query: bool,
pub lock_census: bool,
pub write_volume_stats: bool,
pub copy_phase_stats: bool,
}
impl Default for PerformanceConfig {
fn default() -> Self {
Self {
worker_threads: num_cpus::get(),
query_timeout_secs: 300,
simd_enabled: true,
parallel_query: true,
lock_census: false,
write_volume_stats: false,
copy_phase_stats: false,
}
}
}
mod num_cpus {
pub fn get() -> usize {
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct OptimizerConfig {
pub enabled: bool,
pub enable_seqscan: bool,
pub enable_indexscan: bool,
pub enable_hashjoin: bool,
pub enable_mergejoin: bool,
pub enable_nestloop: bool,
pub seq_page_cost: f64,
pub random_page_cost: f64,
pub cpu_tuple_cost: f64,
pub cpu_index_tuple_cost: f64,
}
impl Default for OptimizerConfig {
fn default() -> Self {
Self {
enabled: true,
enable_seqscan: true,
enable_indexscan: true,
enable_hashjoin: true,
enable_mergejoin: true,
enable_nestloop: true,
seq_page_cost: 1.0,
random_page_cost: 4.0,
cpu_tuple_cost: 0.01,
cpu_index_tuple_cost: 0.005,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct AuthenticationConfig {
pub enabled: bool,
pub method: AuthMethod,
pub jwt_secret: Option<String>,
pub jwt_expiration_secs: u64,
pub password_hash_algorithm: PasswordHashAlgorithm,
pub users_file: Option<PathBuf>,
}
impl Default for AuthenticationConfig {
fn default() -> Self {
Self {
enabled: false,
method: AuthMethod::Trust,
jwt_secret: None,
jwt_expiration_secs: 86400, password_hash_algorithm: PasswordHashAlgorithm::Argon2,
users_file: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AuthMethod {
Trust,
Password,
Jwt,
Ldap,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PasswordHashAlgorithm {
Argon2,
Bcrypt,
Pbkdf2,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct CompressionConfig {
pub default_type: CompressionType,
pub level: i32,
pub enable_alp: bool,
pub enable_fsst: bool,
pub min_size_bytes: usize,
}
impl Default for CompressionConfig {
fn default() -> Self {
Self {
default_type: CompressionType::Zstd,
level: 3,
enable_alp: true,
enable_fsst: true,
min_size_bytes: 1024, }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct MaterializedViewConfig {
pub auto_refresh_default: bool,
pub default_max_cpu_percent: u8,
pub refresh_check_interval_secs: u64,
pub max_concurrent_refreshes: usize,
pub enable_incremental: bool,
pub enable_delta_tracking: bool,
pub enable_scheduler: bool,
pub delta_retention_hours: u64,
}
impl Default for MaterializedViewConfig {
fn default() -> Self {
Self {
auto_refresh_default: false,
default_max_cpu_percent: 15,
refresh_check_interval_secs: 60,
max_concurrent_refreshes: 2,
enable_incremental: false, enable_delta_tracking: false, enable_scheduler: false, delta_retention_hours: 168, }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct VectorConfig {
pub default_index_type: VectorIndexType,
pub hnsw_ef_construction: usize,
pub hnsw_m: usize,
pub enable_pq: bool,
pub pq_subvectors: usize,
pub pq_bits: usize,
}
impl Default for VectorConfig {
fn default() -> Self {
Self {
default_index_type: VectorIndexType::Hnsw,
hnsw_ef_construction: 200,
hnsw_m: 16,
enable_pq: true,
pq_subvectors: 8,
pq_bits: 8,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum VectorIndexType {
Flat,
Hnsw,
Ivf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SyncConfig {
pub enabled: bool,
pub node_id: Option<String>,
pub server_url: Option<String>,
pub client_id: Option<String>,
pub sync_interval_secs: u64,
pub change_log_enabled: bool,
}
impl Default for SyncConfig {
fn default() -> Self {
Self {
enabled: false,
node_id: None,
server_url: None,
client_id: None,
sync_interval_secs: 30,
change_log_enabled: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SessionConfig {
pub timeout_secs: u64,
pub max_sessions_per_user: u32,
pub cleanup_interval_secs: u64,
}
impl Default for SessionConfig {
fn default() -> Self {
Self {
timeout_secs: 3600,
max_sessions_per_user: 10,
cleanup_interval_secs: 300,
}
}
}
impl SessionConfig {
pub fn validate(&self) -> crate::Result<()> {
if self.timeout_secs < 1 {
return Err(crate::Error::config("session.timeout_secs must be at least 1 second"));
}
if self.max_sessions_per_user < 1 {
return Err(crate::Error::config("session.max_sessions_per_user must be at least 1"));
}
if self.cleanup_interval_secs < 1 {
return Err(crate::Error::config(
"session.cleanup_interval_secs must be at least 1 second",
));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct LockConfig {
pub timeout_ms: u32,
pub deadlock_check_interval_ms: u32,
pub max_lock_holders: u32,
}
impl Default for LockConfig {
fn default() -> Self {
Self {
timeout_ms: 30000,
deadlock_check_interval_ms: 100,
max_lock_holders: 10000,
}
}
}
impl LockConfig {
pub fn validate(&self) -> crate::Result<()> {
if self.timeout_ms < 100 {
return Err(crate::Error::config(
"locks.timeout_ms must be at least 100 milliseconds",
));
}
if self.deadlock_check_interval_ms < 10 {
return Err(crate::Error::config(
"locks.deadlock_check_interval_ms must be at least 10 milliseconds",
));
}
if self.max_lock_holders < 1 {
return Err(crate::Error::config("locks.max_lock_holders must be at least 1"));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DumpConfig {
pub auto_dump_enabled: bool,
pub schedule: String,
pub compression: String,
pub max_dump_size_mb: u64,
pub keep_dumps: usize,
pub dump_dir: String,
}
impl Default for DumpConfig {
fn default() -> Self {
Self {
auto_dump_enabled: false,
schedule: String::new(),
compression: "zstd".to_string(),
max_dump_size_mb: 10000,
keep_dumps: 10,
dump_dir: ".dumps".to_string(),
}
}
}
impl DumpConfig {
pub fn validate(&self) -> crate::Result<()> {
match self.compression.as_str() {
"zstd" | "gzip" | "none" => {}
_ => {
return Err(crate::Error::config(format!(
"dump.compression must be 'zstd', 'gzip', or 'none', got '{}'",
self.compression
)));
}
}
if self.max_dump_size_mb < 1 {
return Err(crate::Error::config("dump.max_dump_size_mb must be at least 1 MB"));
}
if self.auto_dump_enabled && !self.schedule.is_empty() {
Self::validate_cron_schedule(&self.schedule)?;
}
Ok(())
}
fn validate_cron_schedule(schedule: &str) -> crate::Result<()> {
let parts: Vec<&str> = schedule.split_whitespace().collect();
if parts.len() != 5 {
return Err(crate::Error::config(format!(
"Invalid cron schedule format '{}'. Expected 5 fields: minute hour day month weekday",
schedule
)));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ResourceQuotaConfig {
pub memory_limit_per_user_mb: u64,
pub max_concurrent_queries: u32,
pub query_timeout_secs: u64,
}
impl Default for ResourceQuotaConfig {
fn default() -> Self {
Self {
memory_limit_per_user_mb: 1024,
max_concurrent_queries: 100,
query_timeout_secs: 300,
}
}
}
impl ResourceQuotaConfig {
pub fn validate(&self) -> crate::Result<()> {
if self.memory_limit_per_user_mb < 1 {
return Err(crate::Error::config(
"resource_quotas.memory_limit_per_user_mb must be at least 1 MB",
));
}
if self.max_concurrent_queries < 1 {
return Err(crate::Error::config(
"resource_quotas.max_concurrent_queries must be at least 1",
));
}
if self.query_timeout_secs < 1 {
return Err(crate::Error::config(
"resource_quotas.query_timeout_secs must be at least 1 second",
));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ApiConfig {
pub jwt_secret: String,
pub anon_key: Option<String>,
pub service_role_key: Option<String>,
#[serde(default)]
pub oauth_providers: Vec<OAuthProviderConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthProviderConfig {
pub name: String,
pub client_id: String,
pub client_secret: String,
pub redirect_uri: String,
}
impl Default for ApiConfig {
fn default() -> Self {
Self {
jwt_secret: generate_random_secret(),
anon_key: None,
service_role_key: None,
oauth_providers: Vec::new(),
}
}
}
fn generate_random_secret() -> String {
use std::collections::hash_map::RandomState;
use std::hash::{BuildHasher, Hasher};
let s = RandomState::new();
let h1 = s.build_hasher().finish();
let h2 = RandomState::new().build_hasher().finish();
format!("{h1:016x}{h2:016x}{h1:016x}{h2:016x}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_profile_fast_applies_bundle() {
let config = Config::from_toml_str("profile = \"fast\"").expect("parse");
assert_eq!(config.profile, Some(ProfileConfig::Fast));
assert_eq!(config.storage.wal_sync_mode, WalSyncModeConfig::GroupCommit);
assert!(!config.storage.time_travel_enabled);
assert!(!config.storage.durable_commit);
}
#[test]
fn test_profile_balanced_applies_bundle() {
let config = Config::from_toml_str("profile = \"balanced\"").expect("parse");
assert_eq!(config.storage.wal_sync_mode, WalSyncModeConfig::GroupCommit);
assert!(config.storage.time_travel_enabled);
}
#[test]
fn test_profile_agent_applies_bundle() {
let config = Config::from_toml_str("profile = \"agent\"").expect("parse");
assert_eq!(config.profile, Some(ProfileConfig::Agent));
assert_eq!(config.storage.wal_sync_mode, WalSyncModeConfig::GroupCommit);
assert!(config.storage.time_travel_enabled);
assert!(!config.storage.durable_commit);
assert_eq!(ProfileConfig::Agent.as_str(), "agent");
}
#[test]
fn test_agent_profile_does_not_downgrade_durable_commit() {
let config = Config::from_toml_str(
r#"
profile = "agent"
[storage]
durable_commit = true
"#,
)
.expect("parse");
assert!(config.storage.durable_commit);
assert_eq!(config.storage.wal_sync_mode, WalSyncModeConfig::GroupCommit);
assert!(config.storage.time_travel_enabled);
}
#[test]
fn test_profile_safe_matches_defaults() {
let config = Config::from_toml_str("profile = \"safe\"").expect("parse");
let defaults = Config::default();
assert_eq!(config.storage.wal_sync_mode, defaults.storage.wal_sync_mode);
assert_eq!(config.storage.time_travel_enabled, defaults.storage.time_travel_enabled);
assert_eq!(config.storage.durable_commit, defaults.storage.durable_commit);
}
#[test]
fn test_explicit_field_wins_over_profile() {
let config = Config::from_toml_str(
r#"
profile = "fast"
[storage]
time_travel_enabled = true
wal_sync_mode = "sync"
"#,
)
.expect("parse");
assert!(config.storage.time_travel_enabled, "explicit field must win");
assert_eq!(config.storage.wal_sync_mode, WalSyncModeConfig::Sync);
assert!(!config.storage.durable_commit);
}
#[test]
fn test_safe_profile_does_not_downgrade_durable_commit() {
let config = Config::from_toml_str(
r#"
profile = "safe"
[storage]
durable_commit = true
"#,
)
.expect("parse");
assert!(config.storage.durable_commit);
}
#[test]
fn test_no_profile_keeps_defaults() {
let config = Config::from_toml_str("").expect("parse");
assert_eq!(config.profile, None);
assert_eq!(config.storage.wal_sync_mode, WalSyncModeConfig::Sync);
assert!(config.storage.time_travel_enabled);
}
#[test]
fn test_with_profile_constructor() {
let config = Config::with_profile(ProfileConfig::Fast);
assert_eq!(config.profile, Some(ProfileConfig::Fast));
assert_eq!(config.storage.wal_sync_mode, WalSyncModeConfig::GroupCommit);
assert!(!config.storage.time_travel_enabled);
}
#[test]
fn test_unknown_profile_rejected() {
let err = Config::from_toml_str("profile = \"ludicrous\"").expect_err("must fail");
assert!(err.to_string().contains("Failed to parse config"));
}
#[test]
fn test_config_example_toml_parses() {
let content = include_str!("../config.example.toml");
let config = Config::from_toml_str(content).expect("config.example.toml must parse");
config.validate().expect("config.example.toml must validate");
assert_eq!(config.storage.wal_sync_mode, WalSyncModeConfig::Sync);
}
#[test]
fn test_profile_round_trips_through_serialization() {
let config = Config::with_profile(ProfileConfig::Balanced);
let toml_str = toml::to_string(&config).expect("serialize");
assert!(
toml_str.starts_with("profile = \"balanced\""),
"profile must be first: {toml_str}"
);
let back = Config::from_toml_str(&toml_str).expect("reparse");
assert_eq!(back.profile, Some(ProfileConfig::Balanced));
assert_eq!(back.storage.wal_sync_mode, WalSyncModeConfig::GroupCommit);
}
#[test]
fn test_session_config_default() {
let config = SessionConfig::default();
assert_eq!(config.timeout_secs, 3600);
assert_eq!(config.max_sessions_per_user, 10);
assert_eq!(config.cleanup_interval_secs, 300);
assert!(config.validate().is_ok());
}
#[test]
fn test_session_config_validation() {
let mut config = SessionConfig::default();
assert!(config.validate().is_ok());
config.timeout_secs = 0;
assert!(config.validate().is_err());
config.timeout_secs = 3600;
config.max_sessions_per_user = 0;
assert!(config.validate().is_err());
config.max_sessions_per_user = 10;
config.cleanup_interval_secs = 0;
assert!(config.validate().is_err());
}
#[test]
fn test_lock_config_default() {
let config = LockConfig::default();
assert_eq!(config.timeout_ms, 30000);
assert_eq!(config.deadlock_check_interval_ms, 100);
assert_eq!(config.max_lock_holders, 10000);
assert!(config.validate().is_ok());
}
#[test]
fn test_lock_config_validation() {
let mut config = LockConfig::default();
assert!(config.validate().is_ok());
config.timeout_ms = 50;
assert!(config.validate().is_err());
config.timeout_ms = 30000;
config.deadlock_check_interval_ms = 5;
assert!(config.validate().is_err());
config.deadlock_check_interval_ms = 100;
config.max_lock_holders = 0;
assert!(config.validate().is_err());
}
#[test]
fn test_dump_config_default() {
let config = DumpConfig::default();
assert!(!config.auto_dump_enabled);
assert_eq!(config.schedule, "");
assert_eq!(config.compression, "zstd");
assert_eq!(config.max_dump_size_mb, 10000);
assert_eq!(config.keep_dumps, 10);
assert_eq!(config.dump_dir, ".dumps");
assert!(config.validate().is_ok());
}
#[test]
fn test_dump_config_validation() {
let mut config = DumpConfig::default();
assert!(config.validate().is_ok());
config.compression = "invalid".to_string();
assert!(config.validate().is_err());
config.compression = "zstd".to_string();
config.compression = "gzip".to_string();
assert!(config.validate().is_ok());
config.compression = "none".to_string();
assert!(config.validate().is_ok());
config.compression = "zstd".to_string();
config.max_dump_size_mb = 0;
assert!(config.validate().is_err());
config.max_dump_size_mb = 10000;
config.auto_dump_enabled = true;
config.schedule = "0 */6 * * *".to_string();
assert!(config.validate().is_ok());
config.schedule = "invalid".to_string();
assert!(config.validate().is_err());
config.schedule = "".to_string();
assert!(config.validate().is_ok());
}
#[test]
fn test_resource_quota_config_default() {
let config = ResourceQuotaConfig::default();
assert_eq!(config.memory_limit_per_user_mb, 1024);
assert_eq!(config.max_concurrent_queries, 100);
assert_eq!(config.query_timeout_secs, 300);
assert!(config.validate().is_ok());
}
#[test]
fn test_resource_quota_config_validation() {
let mut config = ResourceQuotaConfig::default();
assert!(config.validate().is_ok());
config.memory_limit_per_user_mb = 0;
assert!(config.validate().is_err());
config.memory_limit_per_user_mb = 1024;
config.max_concurrent_queries = 0;
assert!(config.validate().is_err());
config.max_concurrent_queries = 100;
config.query_timeout_secs = 0;
assert!(config.validate().is_err());
}
#[test]
fn test_config_with_new_sections() {
let config = Config::default();
assert_eq!(config.session.timeout_secs, 3600);
assert_eq!(config.locks.timeout_ms, 30000);
assert_eq!(config.dump.compression, "zstd");
assert_eq!(config.resource_quotas.memory_limit_per_user_mb, 1024);
assert!(config.validate().is_ok());
}
#[test]
fn test_config_serialization() {
let config = Config::default();
let toml_str = toml::to_string(&config).expect("Failed to serialize config");
assert!(toml_str.contains("[session]"));
assert!(toml_str.contains("[locks]"));
assert!(toml_str.contains("[dump]"));
assert!(toml_str.contains("[resource_quotas]"));
}
#[test]
fn test_config_deserialization() {
let toml_str = r#"
[storage]
memory_only = true
wal_enabled = false
wal_sync_mode = "sync"
cache_size = 536870912
compression = "Zstd"
time_travel_enabled = true
transaction_isolation = "READ_COMMITTED"
[session]
timeout_secs = 7200
max_sessions_per_user = 20
cleanup_interval_secs = 600
[locks]
timeout_ms = 60000
deadlock_check_interval_ms = 200
max_lock_holders = 20000
[dump]
auto_dump_enabled = true
schedule = "0 2 * * *"
compression = "gzip"
max_dump_size_mb = 5000
keep_dumps = 5
dump_dir = "/var/dumps"
[resource_quotas]
memory_limit_per_user_mb = 2048
max_concurrent_queries = 200
query_timeout_secs = 600
"#;
let config: Config = toml::from_str(toml_str).expect("Failed to deserialize config");
assert_eq!(config.session.timeout_secs, 7200);
assert_eq!(config.session.max_sessions_per_user, 20);
assert_eq!(config.session.cleanup_interval_secs, 600);
assert_eq!(config.locks.timeout_ms, 60000);
assert_eq!(config.locks.deadlock_check_interval_ms, 200);
assert_eq!(config.locks.max_lock_holders, 20000);
assert!(config.dump.auto_dump_enabled);
assert_eq!(config.dump.schedule, "0 2 * * *");
assert_eq!(config.dump.compression, "gzip");
assert_eq!(config.dump.max_dump_size_mb, 5000);
assert_eq!(config.dump.keep_dumps, 5);
assert_eq!(config.dump.dump_dir, "/var/dumps");
assert_eq!(config.resource_quotas.memory_limit_per_user_mb, 2048);
assert_eq!(config.resource_quotas.max_concurrent_queries, 200);
assert_eq!(config.resource_quotas.query_timeout_secs, 600);
assert!(config.validate().is_ok());
}
#[test]
fn test_config_with_missing_sections() {
let toml_str = r#"
[storage]
memory_only = true
wal_enabled = false
wal_sync_mode = "sync"
cache_size = 536870912
compression = "Zstd"
time_travel_enabled = true
transaction_isolation = "READ_COMMITTED"
"#;
let config: Config = toml::from_str(toml_str).expect("Failed to deserialize config");
assert_eq!(config.session.timeout_secs, 3600);
assert_eq!(config.locks.timeout_ms, 30000);
assert_eq!(config.dump.compression, "zstd");
assert_eq!(config.resource_quotas.memory_limit_per_user_mb, 1024);
assert!(config.validate().is_ok());
}
#[test]
fn test_cron_schedule_validation() {
assert!(DumpConfig::validate_cron_schedule("0 */6 * * *").is_ok());
assert!(DumpConfig::validate_cron_schedule("0 2 * * *").is_ok());
assert!(DumpConfig::validate_cron_schedule("*/15 * * * *").is_ok());
assert!(DumpConfig::validate_cron_schedule("0 0 1 * *").is_ok());
assert!(DumpConfig::validate_cron_schedule("30 3 * * 0").is_ok());
assert!(DumpConfig::validate_cron_schedule("invalid").is_err());
assert!(DumpConfig::validate_cron_schedule("0 * * *").is_err()); assert!(DumpConfig::validate_cron_schedule("0 * * * * *").is_err()); assert!(DumpConfig::validate_cron_schedule("").is_err()); }
}