Skip to main content

linkrs_server/http/handlers/
config.rs

1//! Configuration Management HTTP Processor
2
3use axum::{
4    extract::{Extension, Json, Path, State},
5    response::Json as JsonResponse,
6};
7use serde::Deserialize;
8use serde_json;
9
10use crate::http::{error::HttpError, state::AppState};
11use crate::storage::{
12    StorageClient, StorageOperationContextOps, StorageSchemaContextOps, StorageSyncContextOps,
13};
14
15#[utoipa::path(
16    get,
17    path = "/v1/config",
18    tag = "Config",
19    responses(
20        (status = 200, body = serde_json::Value, description = "Current configuration"),
21        (status = 500, description = "Internal error")
22    )
23)]
24/// Get current configuration
25pub async fn get<
26    S: StorageClient
27        + StorageSchemaContextOps
28        + StorageSyncContextOps
29        + StorageOperationContextOps
30        + Clone
31        + Send
32        + Sync
33        + 'static,
34>(
35    State(state): State<AppState<S>>,
36    Extension(session_id): Extension<i64>,
37) -> Result<JsonResponse<serde_json::Value>, HttpError> {
38    super::authz::require_admin_session(&state, session_id)?;
39    let config = state.server.get_config();
40
41    Ok(JsonResponse(serde_json::json!({
42        "database": {
43            "host": config.common.database.host,
44            "port": config.common.database.port,
45            "storage_path": config.common.database.storage_path,
46            "max_sessions": config.common.database.max_sessions,
47        },
48        "transaction": {
49            "default_timeout": config.common.transaction.default_timeout,
50            "max_concurrent_transactions": config.common.transaction.max_concurrent_transactions,
51            "auto_commit": config.common.transaction.auto_commit,
52        },
53        "log": {
54            "level": config.common.log.level,
55            "dir": config.common.log.dir,
56            "basename": config.common.log.basename,
57            "max_file_size_mb": config.common.log.max_file_size_mb,
58            "max_files": config.common.log.max_files,
59            "stdout": config.common.log.stdout,
60            "json_format": config.common.log.json_format,
61        },
62        "auth": {
63            "enable_authorize": config.server.auth.enable_authorize,
64            "failed_login_attempts": config.server.auth.failed_login_attempts,
65            "session_idle_timeout_secs": config.server.auth.session_idle_timeout_secs,
66            "force_change_default_password": config.server.auth.force_change_default_password,
67            "default_username": config.server.auth.default_username,
68            "bcrypt_cost": config.server.auth.bcrypt_cost,
69        },
70        "bootstrap": {
71            "auto_create_default_space": config.server.bootstrap.auto_create_default_space,
72            "default_space_name": config.server.bootstrap.default_space_name,
73            "single_user_mode": config.server.bootstrap.single_user_mode,
74        },
75        "optimizer": {
76            "max_iteration_rounds": config.common.optimizer.max_iteration_rounds,
77            "max_exploration_rounds": config.common.optimizer.max_exploration_rounds,
78            "enable_cost_model": config.common.optimizer.enable_cost_model,
79            "enable_multi_plan": config.common.optimizer.enable_multi_plan,
80            "enable_property_pruning": config.common.optimizer.enable_property_pruning,
81            "enable_adaptive_iteration": config.common.optimizer.enable_adaptive_iteration,
82            "stable_threshold": config.common.optimizer.stable_threshold,
83            "min_iteration_rounds": config.common.optimizer.min_iteration_rounds,
84            "statistics_sample_limit": config.common.optimizer.statistics_sample_limit,
85            "statistics_min_epoch_delta": config.common.optimizer.statistics_min_epoch_delta,
86            "storage_cost_profile": config.common.optimizer.storage_cost_profile,
87            "space_cost_profiles": config.common.optimizer.space_cost_profiles,
88        },
89        "monitoring": {
90            "enabled": config.common.monitoring.enabled,
91            "memory_cache_size": config.common.monitoring.memory_cache_size,
92            "slow_query_threshold_ms": config.common.monitoring.slow_query_threshold_ms,
93        },
94    })))
95}
96
97#[utoipa::path(
98    put,
99    path = "/v1/config",
100    tag = "Config",
101    request_body = serde_json::Value,
102    responses(
103        (status = 200, body = serde_json::Value, description = "Configuration update receipt"),
104        (status = 500, description = "Internal error")
105    )
106)]
107/// Update configuration (hot update)
108pub async fn update<
109    S: StorageClient
110        + StorageSchemaContextOps
111        + StorageSyncContextOps
112        + StorageOperationContextOps
113        + Clone
114        + Send
115        + Sync
116        + 'static,
117>(
118    State(state): State<AppState<S>>,
119    Extension(session_id): Extension<i64>,
120    Json(request): Json<serde_json::Value>,
121) -> Result<JsonResponse<serde_json::Value>, HttpError> {
122    super::authz::require_admin_session(&state, session_id)?;
123    let store = state.server.config_store();
124    let config_path = state.server.get_config_path();
125    let mut updated = Vec::new();
126    let mut requires_restart = Vec::new();
127    let mut persisted = true;
128
129    if let Some(sections) = request.as_object() {
130        for (section, values) in sections {
131            if let Some(values_obj) = values.as_object() {
132                for (key, value) in values_obj {
133                    let full_key = format!("{section}.{key}");
134                    match apply_config_update(&store, config_path.as_deref(), section, key, value) {
135                        Ok((restart, wrote)) => {
136                            updated.push(full_key);
137                            if restart {
138                                requires_restart.push(format!("{section}.{key}"));
139                            }
140                            persisted = persisted && wrote;
141                        }
142                        Err(e) => return Err(HttpError::bad_request(e)),
143                    }
144                }
145            }
146        }
147    }
148
149    Ok(JsonResponse(serde_json::json!({
150        "updated": updated,
151        "requires_restart": requires_restart,
152        "persisted": persisted,
153        "message": if persisted {
154            "Configuration updated and persisted; restart-required keys take effect after restart"
155        } else {
156            "Configuration updated in memory only (no config file retained); restart to reload from file"
157        },
158    })))
159}
160
161#[utoipa::path(
162    get,
163    path = "/v1/config/{section}/{key}",
164    tag = "Config",
165    params(
166        ("section" = String, Path, description = "Configuration section"),
167        ("key" = String, Path, description = "Configuration key")
168    ),
169    responses(
170        (status = 200, body = serde_json::Value, description = "Configuration item"),
171        (status = 500, description = "Internal error")
172    )
173)]
174/// Getting Configuration Items
175pub async fn get_key<
176    S: StorageClient
177        + StorageSchemaContextOps
178        + StorageSyncContextOps
179        + StorageOperationContextOps
180        + Clone
181        + Send
182        + Sync
183        + 'static,
184>(
185    State(state): State<AppState<S>>,
186    Extension(session_id): Extension<i64>,
187    Path((section, key)): Path<(String, String)>,
188) -> Result<JsonResponse<serde_json::Value>, HttpError> {
189    super::authz::require_admin_session(&state, session_id)?;
190    let config = state.server.get_config();
191    let value = get_config_value(&config, &section, &key);
192
193    Ok(JsonResponse(serde_json::json!({
194        "section": section,
195        "key": key,
196        "value": value,
197    })))
198}
199
200#[utoipa::path(
201    put,
202    path = "/v1/config/{section}/{key}",
203    tag = "Config",
204    params(
205        ("section" = String, Path, description = "Configuration section"),
206        ("key" = String, Path, description = "Configuration key")
207    ),
208    request_body = UpdateConfigRequest,
209    responses(
210        (status = 200, body = serde_json::Value, description = "Configuration item updated"),
211        (status = 500, description = "Internal error")
212    )
213)]
214/// Updating Configuration Items
215pub async fn update_key<
216    S: StorageClient
217        + StorageSchemaContextOps
218        + StorageSyncContextOps
219        + StorageOperationContextOps
220        + Clone
221        + Send
222        + Sync
223        + 'static,
224>(
225    State(state): State<AppState<S>>,
226    Extension(session_id): Extension<i64>,
227    Path((section, key)): Path<(String, String)>,
228    Json(request): Json<UpdateConfigRequest>,
229) -> Result<JsonResponse<serde_json::Value>, HttpError> {
230    super::authz::require_admin_session(&state, session_id)?;
231    let store = state.server.config_store();
232    let config_path = state.server.get_config_path();
233    let (requires_restart, persisted) = apply_config_update(
234        &store,
235        config_path.as_deref(),
236        &section,
237        &key,
238        &request.value,
239    )
240    .map_err(HttpError::bad_request)?;
241
242    Ok(JsonResponse(serde_json::json!({
243        "section": section,
244        "key": key,
245        "value": request.value,
246        "requires_restart": requires_restart,
247        "persisted": persisted,
248        "message": if requires_restart {
249            "Configuration item updated; restart required to take effect"
250        } else if persisted {
251            "Configuration item updated and persisted"
252        } else {
253            "Configuration item updated in memory only (no config file retained)"
254        },
255    })))
256}
257
258#[utoipa::path(
259    delete,
260    path = "/v1/config/{section}/{key}",
261    tag = "Config",
262    params(
263        ("section" = String, Path, description = "Configuration section"),
264        ("key" = String, Path, description = "Configuration key")
265    ),
266    responses(
267        (status = 200, body = serde_json::Value, description = "Configuration reset to default"),
268        (status = 500, description = "Internal error")
269    )
270)]
271/// Reset configuration items to default values
272pub async fn reset_key<
273    S: StorageClient
274        + StorageSchemaContextOps
275        + StorageSyncContextOps
276        + StorageOperationContextOps
277        + Clone
278        + Send
279        + Sync
280        + 'static,
281>(
282    State(state): State<AppState<S>>,
283    Extension(session_id): Extension<i64>,
284    Path((section, key)): Path<(String, String)>,
285) -> Result<JsonResponse<serde_json::Value>, HttpError> {
286    super::authz::require_admin_session(&state, session_id)?;
287    let default_config = crate::config::Config::default();
288    let default_value = get_config_value(&default_config, &section, &key);
289    let store = state.server.config_store();
290    let config_path = state.server.get_config_path();
291    let (requires_restart, persisted) = apply_config_update(
292        &store,
293        config_path.as_deref(),
294        &section,
295        &key,
296        &default_value,
297    )
298    .map_err(HttpError::bad_request)?;
299
300    Ok(JsonResponse(serde_json::json!({
301        "section": section,
302        "key": key,
303        "value": default_value,
304        "requires_restart": requires_restart,
305        "persisted": persisted,
306        "message": "Configuration reset to default value",
307    })))
308}
309
310/// Update Configuration Request
311#[derive(Debug, Deserialize, utoipa::ToSchema)]
312pub struct UpdateConfigRequest {
313    pub value: serde_json::Value,
314}
315
316/// Getting configuration values
317pub(crate) fn get_config_value(
318    config: &crate::config::Config,
319    section: &str,
320    key: &str,
321) -> serde_json::Value {
322    match section {
323        "database" => match key {
324            "host" => serde_json::json!(config.common.database.host),
325            "port" => serde_json::json!(config.common.database.port),
326            "storage_path" => serde_json::json!(config.common.database.storage_path),
327            "max_sessions" => serde_json::json!(config.common.database.max_sessions),
328            _ => serde_json::Value::Null,
329        },
330        "transaction" => match key {
331            "default_timeout" => serde_json::json!(config.common.transaction.default_timeout),
332            "max_concurrent_transactions" => {
333                serde_json::json!(config.common.transaction.max_concurrent_transactions)
334            }
335            "auto_commit" => serde_json::json!(config.common.transaction.auto_commit),
336            _ => serde_json::Value::Null,
337        },
338        "log" => match key {
339            "level" => serde_json::json!(config.common.log.level),
340            "dir" => serde_json::json!(config.common.log.dir),
341            "basename" => serde_json::json!(config.common.log.basename),
342            "max_file_size_mb" => serde_json::json!(config.common.log.max_file_size_mb),
343            "max_files" => serde_json::json!(config.common.log.max_files),
344            "stdout" => serde_json::json!(config.common.log.stdout),
345            "json_format" => serde_json::json!(config.common.log.json_format),
346            _ => serde_json::Value::Null,
347        },
348        "auth" => match key {
349            "enable_authorize" => serde_json::json!(config.server.auth.enable_authorize),
350            "failed_login_attempts" => serde_json::json!(config.server.auth.failed_login_attempts),
351            "session_idle_timeout_secs" => {
352                serde_json::json!(config.server.auth.session_idle_timeout_secs)
353            }
354            "force_change_default_password" => {
355                serde_json::json!(config.server.auth.force_change_default_password)
356            }
357            "default_username" => serde_json::json!(config.server.auth.default_username),
358            "bcrypt_cost" => serde_json::json!(config.server.auth.bcrypt_cost),
359            _ => serde_json::Value::Null,
360        },
361        "bootstrap" => match key {
362            "auto_create_default_space" => {
363                serde_json::json!(config.server.bootstrap.auto_create_default_space)
364            }
365            "default_space_name" => serde_json::json!(config.server.bootstrap.default_space_name),
366            "single_user_mode" => serde_json::json!(config.server.bootstrap.single_user_mode),
367            _ => serde_json::Value::Null,
368        },
369        "http" => match key {
370            "cors_enabled" => serde_json::json!(config.server.http.cors_enabled),
371            "cors_allowed_origins" => {
372                serde_json::json!(config.server.http.cors_allowed_origins)
373            }
374            _ => serde_json::Value::Null,
375        },
376        "security" => match key {
377            "password_min_length" => {
378                serde_json::json!(config.server.security.password_policy.min_length)
379            }
380            "password_require_uppercase" => {
381                serde_json::json!(config.server.security.password_policy.require_uppercase)
382            }
383            "password_require_lowercase" => {
384                serde_json::json!(config.server.security.password_policy.require_lowercase)
385            }
386            "password_require_digit" => {
387                serde_json::json!(config.server.security.password_policy.require_digit)
388            }
389            "password_require_special" => {
390                serde_json::json!(config.server.security.password_policy.require_special)
391            }
392            "password_max_age_days" => {
393                serde_json::json!(config.server.security.password_policy.max_age_days)
394            }
395            "password_history_size" => {
396                serde_json::json!(config.server.security.password_policy.history_size)
397            }
398            _ => serde_json::Value::Null,
399        },
400        "optimizer" => match key {
401            "max_iteration_rounds" => {
402                serde_json::json!(config.common.optimizer.max_iteration_rounds)
403            }
404            "max_exploration_rounds" => {
405                serde_json::json!(config.common.optimizer.max_exploration_rounds)
406            }
407            "enable_cost_model" => serde_json::json!(config.common.optimizer.enable_cost_model),
408            "enable_multi_plan" => serde_json::json!(config.common.optimizer.enable_multi_plan),
409            "enable_property_pruning" => {
410                serde_json::json!(config.common.optimizer.enable_property_pruning)
411            }
412            "enable_adaptive_iteration" => {
413                serde_json::json!(config.common.optimizer.enable_adaptive_iteration)
414            }
415            "stable_threshold" => serde_json::json!(config.common.optimizer.stable_threshold),
416            "min_iteration_rounds" => {
417                serde_json::json!(config.common.optimizer.min_iteration_rounds)
418            }
419            "statistics_sample_limit" => {
420                serde_json::json!(config.common.optimizer.statistics_sample_limit)
421            }
422            "statistics_min_epoch_delta" => {
423                serde_json::json!(config.common.optimizer.statistics_min_epoch_delta)
424            }
425            "storage_cost_profile" => {
426                serde_json::json!(config.common.optimizer.storage_cost_profile)
427            }
428            "space_cost_profiles" => {
429                serde_json::json!(config.common.optimizer.space_cost_profiles)
430            }
431            _ => serde_json::Value::Null,
432        },
433        "monitoring" => match key {
434            "enabled" => serde_json::json!(config.common.monitoring.enabled),
435            "memory_cache_size" => serde_json::json!(config.common.monitoring.memory_cache_size),
436            "slow_query_threshold_ms" => {
437                serde_json::json!(config.common.monitoring.slow_query_threshold_ms)
438            }
439            _ => serde_json::Value::Null,
440        },
441        "migration" => match key {
442            "batch_size" => serde_json::json!(config.common.migration.batch_size),
443            "checkpoint_dir" => serde_json::json!(config.common.migration.checkpoint_dir),
444            "backup_dir" => serde_json::json!(config.common.migration.backup_dir),
445            "lock_path" => serde_json::json!(config.common.migration.lock_path),
446            "drain_timeout_ms" => serde_json::json!(config.common.migration.drain_timeout_ms),
447            "min_free_bytes" => serde_json::json!(config.common.migration.min_free_bytes),
448            _ => serde_json::Value::Null,
449        },
450        _ => serde_json::Value::Null,
451    }
452}
453
454/// Check if the configuration item requires a reboot to take effect
455///
456/// Only keys read live per use could apply without restart. Every other
457/// section is snapshotted at construction (listener/storage bindings,
458/// TransactionManager, logging init, PasswordAuthenticator/session timeouts,
459/// bootstrap flags, optimizer engine, StatsManager), so updates to those
460/// keys are persisted and visible to readers but take effect on restart.
461fn is_restart_required(section: &str, key: &str) -> bool {
462    match section {
463        "database" => matches!(key, "host" | "port" | "storage_path" | "max_sessions"),
464        "transaction" => true,
465        "log" => true,
466        "auth" => key != "bcrypt_cost",
467        "bootstrap" => true,
468        "http" => true,
469        "security" => true,
470        "optimizer" => true,
471        "monitoring" => true,
472        "migration" => false,
473        _ => false,
474    }
475}
476
477fn parse_value<T>(key_desc: &str, value: &serde_json::Value) -> Result<T, String>
478where
479    T: for<'de> serde::Deserialize<'de>,
480{
481    serde_json::from_value(value.clone())
482        .map_err(|e| format!("invalid value for '{key_desc}': {e}"))
483}
484
485/// Apply one section/key/value to live config with type checking.
486///
487/// Returns whether a restart is required for the change to take effect.
488/// Unknown keys are rejected; every known key is settable.
489pub(crate) fn set_config_value(
490    config: &mut crate::config::Config,
491    section: &str,
492    key: &str,
493    value: &serde_json::Value,
494) -> Result<bool, String> {
495    let full_key = format!("{section}.{key}");
496    match section {
497        "database" => match key {
498            "host" => config.common.database.host = parse_value(&full_key, value)?,
499            "port" => config.common.database.port = parse_value(&full_key, value)?,
500            "storage_path" => config.common.database.storage_path = parse_value(&full_key, value)?,
501            "max_sessions" => config.common.database.max_sessions = parse_value(&full_key, value)?,
502            _ => return Err(format!("unknown configuration key '{full_key}'")),
503        },
504        "transaction" => match key {
505            "default_timeout" => {
506                config.common.transaction.default_timeout = parse_value(&full_key, value)?
507            }
508            "max_concurrent_transactions" => {
509                config.common.transaction.max_concurrent_transactions =
510                    parse_value(&full_key, value)?
511            }
512            "auto_commit" => config.common.transaction.auto_commit = parse_value(&full_key, value)?,
513            _ => return Err(format!("unknown configuration key '{full_key}'")),
514        },
515        "log" => match key {
516            "level" => config.common.log.level = parse_value(&full_key, value)?,
517            "dir" => config.common.log.dir = parse_value(&full_key, value)?,
518            "basename" => config.common.log.basename = parse_value(&full_key, value)?,
519            "max_file_size_mb" => {
520                config.common.log.max_file_size_mb = parse_value(&full_key, value)?
521            }
522            "max_files" => config.common.log.max_files = parse_value(&full_key, value)?,
523            "stdout" => config.common.log.stdout = parse_value(&full_key, value)?,
524            "json_format" => config.common.log.json_format = parse_value(&full_key, value)?,
525            _ => return Err(format!("unknown configuration key '{full_key}'")),
526        },
527        "auth" => match key {
528            "enable_authorize" => {
529                config.server.auth.enable_authorize = parse_value(&full_key, value)?
530            }
531            "failed_login_attempts" => {
532                config.server.auth.failed_login_attempts = parse_value(&full_key, value)?
533            }
534            "session_idle_timeout_secs" => {
535                config.server.auth.session_idle_timeout_secs = parse_value(&full_key, value)?
536            }
537            "force_change_default_password" => {
538                config.server.auth.force_change_default_password = parse_value(&full_key, value)?
539            }
540            "default_username" => {
541                config.server.auth.default_username = parse_value(&full_key, value)?
542            }
543            "bcrypt_cost" => {
544                let cost: u32 = parse_value(&full_key, value)?;
545                if !(4..=12).contains(&cost) {
546                    return Err(format!(
547                        "invalid value for '{full_key}': out of range 4..=12"
548                    ));
549                }
550                config.server.auth.bcrypt_cost = cost;
551                linkrs_core::types::set_bcrypt_cost(cost);
552            }
553            _ => return Err(format!("unknown configuration key '{full_key}'")),
554        },
555        "bootstrap" => match key {
556            "auto_create_default_space" => {
557                config.server.bootstrap.auto_create_default_space = parse_value(&full_key, value)?
558            }
559            "default_space_name" => {
560                config.server.bootstrap.default_space_name = parse_value(&full_key, value)?
561            }
562            "single_user_mode" => {
563                config.server.bootstrap.single_user_mode = parse_value(&full_key, value)?
564            }
565            _ => return Err(format!("unknown configuration key '{full_key}'")),
566        },
567        "http" => match key {
568            "cors_enabled" => config.server.http.cors_enabled = parse_value(&full_key, value)?,
569            "cors_allowed_origins" => {
570                config.server.http.cors_allowed_origins = parse_value(&full_key, value)?;
571                config
572                    .server
573                    .http
574                    .validate()
575                    .map_err(|e| format!("invalid value for '{full_key}': {e}"))?;
576            }
577            _ => return Err(format!("unknown configuration key '{full_key}'")),
578        },
579        "security" => match key {
580            "password_min_length" => {
581                config.server.security.password_policy.min_length = parse_value(&full_key, value)?;
582                config
583                    .server
584                    .security
585                    .validate()
586                    .map_err(|e| format!("invalid value for '{full_key}': {e}"))?;
587            }
588            "password_require_uppercase" => {
589                config.server.security.password_policy.require_uppercase =
590                    parse_value(&full_key, value)?
591            }
592            "password_require_lowercase" => {
593                config.server.security.password_policy.require_lowercase =
594                    parse_value(&full_key, value)?
595            }
596            "password_require_digit" => {
597                config.server.security.password_policy.require_digit =
598                    parse_value(&full_key, value)?
599            }
600            "password_require_special" => {
601                config.server.security.password_policy.require_special =
602                    parse_value(&full_key, value)?
603            }
604            "password_max_age_days" => {
605                config.server.security.password_policy.max_age_days = parse_value(&full_key, value)?
606            }
607            "password_history_size" => {
608                config.server.security.password_policy.history_size = parse_value(&full_key, value)?
609            }
610            _ => return Err(format!("unknown configuration key '{full_key}'")),
611        },
612        "optimizer" => match key {
613            "max_iteration_rounds" => {
614                config.common.optimizer.max_iteration_rounds = parse_value(&full_key, value)?
615            }
616            "max_exploration_rounds" => {
617                config.common.optimizer.max_exploration_rounds = parse_value(&full_key, value)?
618            }
619            "enable_cost_model" => {
620                config.common.optimizer.enable_cost_model = parse_value(&full_key, value)?
621            }
622            "enable_multi_plan" => {
623                config.common.optimizer.enable_multi_plan = parse_value(&full_key, value)?
624            }
625            "enable_property_pruning" => {
626                config.common.optimizer.enable_property_pruning = parse_value(&full_key, value)?
627            }
628            "enable_adaptive_iteration" => {
629                config.common.optimizer.enable_adaptive_iteration = parse_value(&full_key, value)?
630            }
631            "stable_threshold" => {
632                config.common.optimizer.stable_threshold = parse_value(&full_key, value)?
633            }
634            "min_iteration_rounds" => {
635                config.common.optimizer.min_iteration_rounds = parse_value(&full_key, value)?
636            }
637            "statistics_sample_limit" => {
638                config.common.optimizer.statistics_sample_limit = parse_value(&full_key, value)?
639            }
640            "statistics_min_epoch_delta" => {
641                config.common.optimizer.statistics_min_epoch_delta = parse_value(&full_key, value)?
642            }
643            "storage_cost_profile" => {
644                config.common.optimizer.storage_cost_profile = parse_value(&full_key, value)?
645            }
646            "space_cost_profiles" => {
647                config.common.optimizer.space_cost_profiles = parse_value(&full_key, value)?
648            }
649            _ => return Err(format!("unknown configuration key '{full_key}'")),
650        },
651        "monitoring" => match key {
652            "enabled" => config.common.monitoring.enabled = parse_value(&full_key, value)?,
653            "memory_cache_size" => {
654                config.common.monitoring.memory_cache_size = parse_value(&full_key, value)?
655            }
656            "slow_query_threshold_ms" => {
657                config.common.monitoring.slow_query_threshold_ms = parse_value(&full_key, value)?
658            }
659            _ => return Err(format!("unknown configuration key '{full_key}'")),
660        },
661        "migration" => match key {
662            "batch_size" => {
663                config.common.migration.batch_size = parse_value(&full_key, value)?;
664                config
665                    .common
666                    .migration
667                    .validate()
668                    .map_err(|e| format!("invalid value for '{full_key}': {e}"))?;
669            }
670            "checkpoint_dir" => {
671                config.common.migration.checkpoint_dir = parse_value(&full_key, value)?
672            }
673            "backup_dir" => config.common.migration.backup_dir = parse_value(&full_key, value)?,
674            "lock_path" => config.common.migration.lock_path = parse_value(&full_key, value)?,
675            "drain_timeout_ms" => {
676                config.common.migration.drain_timeout_ms = parse_value(&full_key, value)?;
677                config
678                    .common
679                    .migration
680                    .validate()
681                    .map_err(|e| format!("invalid value for '{full_key}': {e}"))?;
682            }
683            "min_free_bytes" => {
684                config.common.migration.min_free_bytes = parse_value(&full_key, value)?
685            }
686            _ => return Err(format!("unknown configuration key '{full_key}'")),
687        },
688        _ => return Err(format!("unknown configuration key '{full_key}'")),
689    }
690    Ok(is_restart_required(section, key))
691}
692
693/// Apply one update to live config and persist it to the retained config
694/// file when one exists.
695///
696/// Returns `(requires_restart, persisted)`. If persistence fails the live
697/// change is rolled back so memory and disk never diverge.
698pub(crate) fn apply_config_update(
699    store: &parking_lot::RwLock<crate::config::Config>,
700    config_path: Option<&std::path::Path>,
701    section: &str,
702    key: &str,
703    value: &serde_json::Value,
704) -> Result<(bool, bool), String> {
705    let mut guard = store.write();
706    let old = get_config_value(&guard, section, key);
707    let requires_restart = set_config_value(&mut guard, section, key, value)?;
708    if let Err(e) = guard.validate() {
709        let _ = set_config_value(&mut guard, section, key, &old);
710        return Err(format!("update rejected by config validation: {e}"));
711    }
712    let Some(path) = config_path else {
713        return Ok((requires_restart, false));
714    };
715    if let Err(e) = guard.save(path) {
716        let _ = set_config_value(&mut guard, section, key, &old);
717        return Err(format!("failed to persist configuration: {e}"));
718    }
719    Ok((requires_restart, true))
720}
721
722/// Known configuration sections: the module vocabulary for the
723/// `SHOW CONFIGS` / `UPDATE CONFIGS` query statements.
724pub(crate) const CONFIG_SECTIONS: &[&str] = &[
725    "database",
726    "transaction",
727    "log",
728    "auth",
729    "bootstrap",
730    "http",
731    "security",
732    "optimizer",
733    "monitoring",
734];
735
736/// Settable keys per section, mirroring the read/write tables above.
737pub(crate) fn section_keys(section: &str) -> Option<&'static [&'static str]> {
738    match section {
739        "database" => Some(&["host", "port", "storage_path", "max_sessions"]),
740        "transaction" => Some(&[
741            "default_timeout",
742            "max_concurrent_transactions",
743            "auto_commit",
744        ]),
745        "log" => Some(&[
746            "level",
747            "dir",
748            "basename",
749            "max_file_size_mb",
750            "max_files",
751            "stdout",
752            "json_format",
753        ]),
754        "auth" => Some(&[
755            "enable_authorize",
756            "failed_login_attempts",
757            "session_idle_timeout_secs",
758            "force_change_default_password",
759            "default_username",
760            "bcrypt_cost",
761        ]),
762        "bootstrap" => Some(&[
763            "auto_create_default_space",
764            "default_space_name",
765            "single_user_mode",
766        ]),
767        "http" => Some(&["cors_enabled", "cors_allowed_origins"]),
768        "security" => Some(&[
769            "password_min_length",
770            "password_require_uppercase",
771            "password_require_lowercase",
772            "password_require_digit",
773            "password_require_special",
774            "password_max_age_days",
775            "password_history_size",
776        ]),
777        "optimizer" => Some(&[
778            "max_iteration_rounds",
779            "max_exploration_rounds",
780            "enable_cost_model",
781            "enable_multi_plan",
782            "enable_property_pruning",
783            "enable_adaptive_iteration",
784            "stable_threshold",
785            "min_iteration_rounds",
786            "statistics_sample_limit",
787            "statistics_min_epoch_delta",
788            "storage_cost_profile",
789            "space_cost_profiles",
790        ]),
791        "monitoring" => Some(&["enabled", "memory_cache_size", "slow_query_threshold_ms"]),
792        _ => None,
793    }
794}
795
796/// Normalize a statement module to a configuration section.
797///
798/// `None` means all sections. Unknown modules fail loudly with the valid
799/// vocabulary instead of silently matching nothing.
800pub(crate) fn resolve_config_section(module: Option<&str>) -> Result<Option<String>, String> {
801    match module {
802        None => Ok(None),
803        Some(raw) => {
804            let normalized = raw.trim().to_lowercase();
805            if CONFIG_SECTIONS.contains(&normalized.as_str()) {
806                Ok(Some(normalized))
807            } else {
808                Err(format!(
809                    "unknown configuration module '{raw}': expected one of {}",
810                    CONFIG_SECTIONS.join(", ")
811                ))
812            }
813        }
814    }
815}
816
817/// Resolve the target section/key of an `UPDATE CONFIGS` intent.
818///
819/// An explicit module must name a known section. Without a module the key
820/// is looked up across sections: exactly one owner is required, so unknown
821/// keys and ambiguous keys both fail with an actionable message.
822fn resolve_config_key(module: Option<&str>, name: &str) -> Result<(String, String), String> {
823    if let Some(section) = resolve_config_section(module)? {
824        return Ok((section, name.to_string()));
825    }
826    let mut owners = Vec::new();
827    for section in CONFIG_SECTIONS {
828        if section_keys(section).is_some_and(|keys| keys.contains(&name)) {
829            owners.push(*section);
830        }
831    }
832    match owners.as_slice() {
833        [] => Err(format!(
834            "unknown configuration key '{name}': qualify it with a module (one of {})",
835            CONFIG_SECTIONS.join(", ")
836        )),
837        [section] => Ok((section.to_string(), name.to_string())),
838        _ => Err(format!(
839            "ambiguous configuration key '{name}': present in {} — qualify it with a module",
840            owners.join(", ")
841        )),
842    }
843}
844
845/// Render a configuration value for display rows: plain strings stay bare,
846/// anything else renders as compact JSON.
847fn display_config_value(value: &serde_json::Value) -> String {
848    match value {
849        serde_json::Value::String(text) => text.clone(),
850        serde_json::Value::Null => String::new(),
851        _ => value.to_string(),
852    }
853}
854
855/// Resolve a `SHOW CONFIGS` intent against live configuration.
856///
857/// Returns `section / key / value / requires_restart` rows, restricted to
858/// one section when the statement names a module.
859pub(crate) fn resolve_show_configs(
860    config: &crate::config::Config,
861    module: Option<&str>,
862) -> Result<linkrs_core::DataSet, String> {
863    let section = resolve_config_section(module)?;
864    let sections: Vec<&str> = match section.as_deref() {
865        Some(name) => vec![name],
866        None => CONFIG_SECTIONS.to_vec(),
867    };
868    let mut rows = Vec::new();
869    for section in sections {
870        let keys = section_keys(section)
871            .ok_or_else(|| format!("unknown configuration module '{section}'"))?;
872        for key in keys {
873            rows.push(vec![
874                linkrs_core::Value::string(section),
875                linkrs_core::Value::string(*key),
876                linkrs_core::Value::string(display_config_value(&get_config_value(
877                    config, section, key,
878                ))),
879                linkrs_core::Value::Bool(is_restart_required(section, key)),
880            ]);
881        }
882    }
883    Ok(linkrs_core::DataSet::from_rows(
884        rows,
885        vec![
886            "section".to_string(),
887            "key".to_string(),
888            "value".to_string(),
889            "requires_restart".to_string(),
890        ],
891    ))
892}
893
894/// Apply an `UPDATE CONFIGS` intent to live configuration.
895///
896/// Reuses the typed validation, restart detection, persistence, and
897/// rollback of the management endpoints instead of a second write path.
898/// Returns a one-row receipt (`updated / requires_restart / persisted`).
899pub(crate) fn apply_config_update_intent(
900    store: &parking_lot::RwLock<crate::config::Config>,
901    config_path: Option<&std::path::Path>,
902    module: Option<&str>,
903    name: &str,
904    value: &linkrs_core::Value,
905) -> Result<linkrs_core::DataSet, String> {
906    let (section, key) = resolve_config_key(module, name)?;
907    let json = crate::value::to_json(value.clone());
908    let (requires_restart, persisted) =
909        apply_config_update(store, config_path, &section, &key, &json)?;
910    Ok(linkrs_core::DataSet::from_rows(
911        vec![vec![
912            linkrs_core::Value::string(format!("{section}.{key}")),
913            linkrs_core::Value::Bool(requires_restart),
914            linkrs_core::Value::Bool(persisted),
915        ]],
916        vec![
917            "updated".to_string(),
918            "requires_restart".to_string(),
919            "persisted".to_string(),
920        ],
921    ))
922}
923
924/// Resolve configuration intents carried by a query result.
925///
926/// `ConfigUpdate` intents are applied to the live store and replaced by
927/// their receipt; `ShowConfigs` intents are replaced by live rows. Any
928/// other result passes through untouched, preserving metadata.
929pub(crate) fn resolve_query_config_intent(
930    result: linkrs_api::api_core::QueryResult,
931    store: &parking_lot::RwLock<crate::config::Config>,
932    config_path: Option<&std::path::Path>,
933) -> Result<linkrs_api::api_core::QueryResult, String> {
934    use linkrs_api::api_core::QueryResult;
935    let linkrs_api::api_core::QueryResult {
936        execution,
937        metadata,
938    } = result;
939    let execution = match execution {
940        linkrs_query::executor::base::ExecutionResult::ConfigUpdate {
941            module,
942            name,
943            value,
944        } => linkrs_query::executor::base::ExecutionResult::DataSet {
945            data: apply_config_update_intent(store, config_path, module.as_deref(), &name, &value)?,
946        },
947        linkrs_query::executor::base::ExecutionResult::ShowConfigs { module } => {
948            let config = store.read();
949            linkrs_query::executor::base::ExecutionResult::DataSet {
950                data: resolve_show_configs(&config, module.as_deref())?,
951            }
952        }
953        other => other,
954    };
955    Ok(QueryResult::new(execution, metadata))
956}
957
958#[cfg(test)]
959mod tests {
960    use super::*;
961    use parking_lot::RwLock;
962    use std::sync::Arc;
963
964    fn test_store() -> Arc<RwLock<crate::config::Config>> {
965        Arc::new(RwLock::new(crate::config::Config::default()))
966    }
967
968    #[test]
969    fn test_resolve_config_section_vocabulary() {
970        assert_eq!(resolve_config_section(None).unwrap(), None);
971        assert_eq!(
972            resolve_config_section(Some("DATABASE")).unwrap(),
973            Some("database".to_string())
974        );
975        let error = resolve_config_section(Some("storage")).unwrap_err();
976        assert!(
977            error.contains("unknown configuration module"),
978            "unexpected error: {error}"
979        );
980    }
981
982    #[test]
983    fn test_apply_intent_updates_live_config() {
984        let store = test_store();
985        let receipt = apply_config_update_intent(
986            &store,
987            None,
988            Some("database"),
989            "max_sessions",
990            &linkrs_core::Value::Int(512),
991        )
992        .expect("valid update should apply");
993        assert_eq!(
994            receipt.col_names,
995            vec![
996                "updated".to_string(),
997                "requires_restart".to_string(),
998                "persisted".to_string()
999            ]
1000        );
1001        assert_eq!(
1002            store.read().common.database.max_sessions,
1003            512,
1004            "live config should reflect the applied intent"
1005        );
1006        // Unqualified keys resolve when exactly one section owns them.
1007        apply_config_update_intent(
1008            &store,
1009            None,
1010            None,
1011            "max_sessions",
1012            &linkrs_core::Value::Int(256),
1013        )
1014        .expect("unqualified unique key should resolve");
1015        assert_eq!(store.read().common.database.max_sessions, 256);
1016    }
1017
1018    #[test]
1019    fn test_apply_intent_rejects_invalid_combination() {
1020        let store = test_store();
1021        let error = apply_config_update_intent(
1022            &store,
1023            None,
1024            Some("transaction"),
1025            "max_concurrent_transactions",
1026            &linkrs_core::Value::Int(0),
1027        )
1028        .expect_err("zero concurrent transactions must fail config validation");
1029        assert!(
1030            error.contains("config validation"),
1031            "unexpected error: {error}"
1032        );
1033        let after = store.read().common.transaction.max_concurrent_transactions;
1034        assert!(
1035            after > 0,
1036            "rejected update must not leave a zeroed live value"
1037        );
1038    }
1039
1040    #[test]
1041    fn test_apply_intent_rejects_unknown_key() {
1042        let store = test_store();
1043        let error = apply_config_update_intent(
1044            &store,
1045            None,
1046            Some("database"),
1047            "no_such_key",
1048            &linkrs_core::Value::Int(1),
1049        )
1050        .unwrap_err();
1051        assert!(
1052            error.contains("unknown configuration key"),
1053            "unexpected error: {error}"
1054        );
1055        let error = apply_config_update_intent(
1056            &store,
1057            None,
1058            Some("no_such_module"),
1059            "max_sessions",
1060            &linkrs_core::Value::Int(1),
1061        )
1062        .unwrap_err();
1063        assert!(
1064            error.contains("unknown configuration module"),
1065            "unexpected error: {error}"
1066        );
1067    }
1068
1069    #[test]
1070    fn test_apply_intent_rejects_type_mismatch() {
1071        let store = test_store();
1072        let before = store.read().common.database.max_sessions;
1073        let error = apply_config_update_intent(
1074            &store,
1075            None,
1076            Some("database"),
1077            "max_sessions",
1078            &linkrs_core::Value::string("not-a-number"),
1079        )
1080        .unwrap_err();
1081        assert!(error.contains("invalid value"), "unexpected error: {error}");
1082        assert_eq!(
1083            store.read().common.database.max_sessions,
1084            before,
1085            "failed update must not mutate live config"
1086        );
1087    }
1088
1089    #[test]
1090    fn test_resolve_show_configs_lists_live_values() {
1091        let store = test_store();
1092        store.write().common.database.max_sessions = 777;
1093        let rows = resolve_show_configs(&store.read(), None).expect("listing should succeed");
1094        assert_eq!(
1095            rows.col_names,
1096            vec![
1097                "section".to_string(),
1098                "key".to_string(),
1099                "value".to_string(),
1100                "requires_restart".to_string()
1101            ]
1102        );
1103        let entry = rows
1104            .rows
1105            .iter()
1106            .find(|row| {
1107                row.first()
1108                    .map(|v| v == &linkrs_core::Value::string("database"))
1109                    .unwrap_or(false)
1110                    && row
1111                        .get(1)
1112                        .map(|v| v == &linkrs_core::Value::string("max_sessions"))
1113                        .unwrap_or(false)
1114            })
1115            .expect("database.max_sessions row should be listed");
1116        assert_eq!(
1117            entry.get(2),
1118            Some(&linkrs_core::Value::string("777")),
1119            "listing should reflect live configuration"
1120        );
1121
1122        let scoped = resolve_show_configs(&store.read(), Some("database"))
1123            .expect("scoped listing should succeed");
1124        assert!(
1125            scoped
1126                .rows
1127                .iter()
1128                .all(|row| row.first() == Some(&linkrs_core::Value::string("database"))),
1129            "scoped listing should only contain the requested section"
1130        );
1131        let error = resolve_show_configs(&store.read(), Some("storage")).unwrap_err();
1132        assert!(
1133            error.contains("unknown configuration module"),
1134            "unexpected error: {error}"
1135        );
1136    }
1137}