use tracing::{info, warn};
use super::error::AppError;
use super::store::{KS_DIDS, Store};
use crate::did_ops::{
DidRecord, content_log_key, content_witness_key, did_key, owner_key, watcher_sync_key,
};
#[derive(Debug, PartialEq, Eq)]
pub struct PurgeReport {
pub deleted: u64,
pub skipped_no_domain: u64,
pub skipped_other_domain: u64,
pub failed: u64,
}
pub async fn purge_domain_dids(
store: &Store,
target_domain: &str,
reason: &str,
) -> Result<PurgeReport, AppError> {
let ks = store.keyspace(KS_DIDS)?;
let raw = ks.prefix_iter_raw(b"did:".to_vec()).await?;
let mut deleted = 0u64;
let mut skipped_no_domain = 0u64;
let mut skipped_other_domain = 0u64;
let mut failed = 0u64;
for (_key, value) in raw {
let record: DidRecord = match serde_json::from_slice(&value) {
Ok(r) => r,
Err(e) => {
warn!(error = %e, "skipping malformed DidRecord during domain purge");
continue;
}
};
if record.domain.is_empty() {
warn!(
mnemonic = %record.mnemonic,
"DidRecord has no domain field — skipping. Run M-01 migration for full coverage."
);
skipped_no_domain += 1;
continue;
}
if record.domain != target_domain {
skipped_other_domain += 1;
continue;
}
let mut batch = store.batch();
batch.remove(&ks, did_key(&record.mnemonic));
batch.remove(&ks, content_log_key(&record.mnemonic));
batch.remove(&ks, content_witness_key(&record.mnemonic));
batch.remove(&ks, owner_key(&record.owner, &record.mnemonic));
batch.remove(&ks, watcher_sync_key(&record.mnemonic));
let host = record
.did_id
.as_deref()
.and_then(|d| super::domain::safety::extract_did_host(d).ok())
.unwrap_or_default();
for name in &record.agent_names {
batch.remove(&ks, crate::did_ops::agent_name_key(&host, &name.name));
}
match batch.commit().await {
Ok(()) => {
deleted += 1;
info!(
mnemonic = %record.mnemonic,
domain = %target_domain,
owner = %record.owner,
reason,
"DID record purged"
);
}
Err(e) => {
warn!(
mnemonic = %record.mnemonic,
error = %e,
"DID record purge failed mid-batch — leaving in place"
);
failed += 1;
}
}
}
info!(
domain = %target_domain,
reason,
deleted,
skipped_no_domain,
skipped_other_domain,
"domain purge complete"
);
Ok(PurgeReport {
failed,
deleted,
skipped_no_domain,
skipped_other_domain,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::server::config::StoreConfig;
async fn fjall_store() -> Store {
let dir = tempfile::tempdir().expect("tempdir");
let cfg = StoreConfig {
data_dir: dir.path().to_path_buf(),
..StoreConfig::default()
};
std::mem::forget(dir);
Store::open(&cfg).await.expect("open fjall")
}
fn record(mnemonic: &str, domain: &str) -> DidRecord {
DidRecord {
owner: format!("did:example:owner-{mnemonic}"),
mnemonic: mnemonic.into(),
created_at: 1,
updated_at: 1,
version_count: 1,
did_id: Some(format!("did:webvh:Q1:{domain}:{mnemonic}")),
content_size: 0,
disabled: false,
deleted_at: None,
method: "webvh".into(),
domain: domain.into(),
services: None,
agent_names: Vec::new(),
}
}
async fn seed_record(store: &Store, rec: &DidRecord) {
let ks = store.keyspace(KS_DIDS).unwrap();
ks.insert(did_key(&rec.mnemonic), rec).await.unwrap();
ks.insert_raw(content_log_key(&rec.mnemonic), b"log".to_vec())
.await
.unwrap();
}
#[tokio::test]
async fn purge_deletes_only_target_domain() {
let store = fjall_store().await;
seed_record(&store, &record("alice", "a.example")).await;
seed_record(&store, &record("bob", "a.example")).await;
seed_record(&store, &record("carol", "b.example")).await;
let report = purge_domain_dids(&store, "a.example", "admin-immediate")
.await
.unwrap();
assert_eq!(report.deleted, 2);
assert_eq!(report.skipped_other_domain, 1);
assert_eq!(report.skipped_no_domain, 0);
let ks = store.keyspace(KS_DIDS).unwrap();
assert!(
ks.get::<DidRecord>(did_key("alice"))
.await
.unwrap()
.is_none()
);
assert!(ks.get::<DidRecord>(did_key("bob")).await.unwrap().is_none());
assert!(
ks.get::<DidRecord>(did_key("carol"))
.await
.unwrap()
.is_some()
);
}
#[tokio::test]
async fn purge_empty_store_is_noop() {
let store = fjall_store().await;
let report = purge_domain_dids(&store, "a.example", "x").await.unwrap();
assert_eq!(report.deleted, 0);
assert_eq!(report.skipped_no_domain, 0);
assert_eq!(report.skipped_other_domain, 0);
}
#[tokio::test]
async fn purge_skips_legacy_records_without_domain() {
let store = fjall_store().await;
let mut legacy = record("legacy", "");
legacy.domain = String::new();
seed_record(&store, &legacy).await;
seed_record(&store, &record("modern", "a.example")).await;
let report = purge_domain_dids(&store, "a.example", "x").await.unwrap();
assert_eq!(report.deleted, 1, "modern record deleted");
assert_eq!(report.skipped_no_domain, 1, "legacy record skipped");
}
}