use std::sync::Arc;
#[cfg(feature = "permission")]
pub async fn attach_permission_hot_reload<F, L>(
pool: Arc<crate::database::DbPool>,
stream: Arc<dyn confers::ChangeStream>,
keep: F,
load: L,
) -> tokio::task::JoinHandle<Result<(), String>>
where
F: Fn(&confers::ChangeEvent) -> bool + Send + Sync + 'static,
L: Fn(&confers::ChangeEvent) -> Result<crate::access::permission::PermissionConfig, String>
+ Send
+ Sync
+ 'static,
{
let mut rx = match stream.subscribe().await {
Ok(rx) => rx,
Err(e) => {
return tokio::spawn(async move { Err(format!("confers subscribe failed: {e}")) });
}
};
tokio::spawn(async move {
use futures::StreamExt;
while let Some(event) = rx.next().await {
if !keep(&event) {
continue;
}
let apply_result = match load(&event) {
Ok(config) => pool
.set_permission_config(config)
.await
.map_err(|e| format!("permission hot reload failed: {e}")),
Err(e) => {
Err(format!("skip invalid permission event: {e}"))
}
};
if let Err(e) = apply_result {
eprintln!("[dbnexus] permission hot reload: {e}");
}
if let Err(e) = confers::ChangeStream::ack(&*stream, event.version).await {
eprintln!("[dbnexus] confers ack failed: {e}");
}
}
Ok(())
})
}
#[cfg(feature = "permission")]
pub fn parse_permission_config(
text: &str,
) -> Result<crate::access::permission::PermissionConfig, String> {
let trimmed = text.trim_start();
if trimmed.starts_with('{') {
serde_json::from_str(text).map_err(|e| e.to_string())
} else {
crate::access::permission::PermissionConfig::from_yaml_str(text).map_err(|e| e.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_permission_config_yaml() {
let yaml = r#"
roles:
admin:
tables:
- name: "*"
operations: ["select", "insert"]
"#;
let config = parse_permission_config(yaml).unwrap();
assert!(config.roles.contains_key("admin"));
}
#[test]
fn test_parse_permission_config_json() {
let json =
r#"{"roles": {"analyst": {"tables": [{"name": "orders", "operations": ["select"]}]}}}"#;
let config = parse_permission_config(json).unwrap();
assert!(config.roles.contains_key("analyst"));
}
#[tokio::test]
async fn test_hot_reload_swaps_permission_config() {
use confers::{ChangeEvent, ChangeSource, ChangeStream};
let url = std::env::temp_dir().join(format!("dbnexus_hr_{}.db", std::process::id()));
let db_url = format!("sqlite:{}?mode=rwc", url.display());
let pool = Arc::new(crate::database::DbPool::new(&db_url).await.unwrap());
let stream = Arc::new(confers::InMemoryChangeStream::new()) as Arc<dyn ChangeStream>;
let yaml_new = r#"
roles:
admin:
tables:
- name: "*"
operations: ["select"]
analyst:
tables:
- name: "orders"
operations: ["select"]
"#;
let handle = attach_permission_hot_reload(
pool.clone(),
stream.clone(),
|event| event.key == "permission",
move |event| {
let text = event
.new_value
.as_ref()
.map(|v| match v {
confers::ConfigValue::String(s) => s.clone(),
other => serde_json::to_string(other).unwrap_or_default(),
})
.unwrap_or_default();
parse_permission_config(&text)
},
)
.await;
stream
.publish(ChangeEvent {
version: 0,
key: "permission".to_string(),
old_value: None,
new_value: Some(confers::ConfigValue::String(yaml_new.to_string())),
source: ChangeSource::File,
})
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let session = pool.get_session("analyst").await;
assert!(
session.is_ok(),
"热重载后 analyst 角色应可用,实际: {:?}",
session.err().map(|e| e.to_string())
);
handle.abort();
let _ = std::fs::remove_file(&url);
}
}