use crate::{
agents::Agent,
commit::CommitResponse,
db::Db,
errors::AtomicResult,
storelike::{Query, QueryResult},
sync::engine::{ingest_commit, CommitIngestOpts},
Storelike,
};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum IngestPolicy {
Hub {
source_id: Option<String>,
response_origin: Option<String>,
},
#[default]
Peer,
LocalCache,
}
#[derive(Clone)]
pub struct AtomicNode {
db: Db,
}
impl AtomicNode {
pub fn from_db(db: Db) -> Self {
Self { db }
}
pub fn db(&self) -> &Db {
&self.db
}
pub fn agent(&self) -> Option<Agent> {
self.db.get_default_agent().ok()
}
pub fn set_agent(&self, agent: Agent) {
self.db.set_default_agent(agent);
}
pub async fn query(&self, q: &Query) -> AtomicResult<QueryResult> {
self.db.query(q).await
}
pub fn search(
&self,
query: &str,
opts: &crate::client::search::SearchOpts,
) -> AtomicResult<Vec<crate::search::SearchHit>> {
self.db.search_hits(query, opts)
}
pub async fn apply_commit(
&self,
commit_json: &str,
policy: IngestPolicy,
) -> AtomicResult<CommitResponse> {
match policy {
IngestPolicy::Hub {
source_id,
response_origin,
} => {
ingest_commit(
&self.db,
commit_json,
&CommitIngestOpts::hub(source_id, response_origin),
)
.await
}
IngestPolicy::Peer => {
ingest_commit(&self.db, commit_json, &CommitIngestOpts::peer()).await
}
IngestPolicy::LocalCache => {
let commit_resource = crate::parse::parse_json_ad_resource(
commit_json,
&self.db,
&crate::parse::ParseOpts {
save: crate::parse::SaveOpts::DontSave,
..Default::default()
},
)
.await?;
let commit = crate::Commit::from_resource(commit_resource)?;
let opts = crate::commit::CommitOpts {
update_index: true,
..crate::commit::CommitOpts::no_validations_no_index()
};
self.db.apply_commit(commit, &opts).await
}
}
}
}
#[cfg(all(test, feature = "db-redb"))]
mod tests {
use super::*;
use crate::{
agents::ForAgent, client::commit_to_wire_json, db::DbEvent, urls, Resource, Subject, Value,
};
async fn open_test_node(label: &str) -> AtomicNode {
let db = Db::init_redb(Some("https://localhost".into()))
.await
.unwrap_or_else(|e| panic!("{label}: open failed: {e}"));
db.populate().await.unwrap();
AtomicNode::from_db(db)
}
#[tokio::test]
async fn two_nodes_genesis_then_peer_ingest() {
let alice_node = open_test_node("alice").await;
let (alice, drive) = alice_node.db().setup("Alice").await.unwrap();
let drive = Subject::from(drive);
assert_eq!(
alice_node.agent().map(|a| a.subject),
Some(alice.subject.clone())
);
let bob_node = open_test_node("bob").await;
bob_node.db().setup("Bob").await.unwrap();
let mut bob_events = bob_node.db().subscribe_events();
let mut draft = Resource::new("did:ad:placeholder".into());
draft
.set_unsafe(urls::NAME.into(), Value::String("Peer Doc".into()))
.unwrap();
draft
.set_unsafe(urls::PARENT.into(), Value::AtomicUrl(drive.clone()))
.unwrap();
let response = draft.save_as_genesis(alice_node.db()).await.unwrap();
let subject = response.commit.subject.clone();
assert!(subject.as_str().starts_with("did:ad:"), "got {subject}");
assert!(
bob_node.db().get_resource(&subject).await.is_err(),
"bob must not see alice's write before ingesting it"
);
let alice_drive = alice_node.db().get_resource(&drive).await.unwrap();
bob_node
.db()
.add_resource_opts(&alice_drive, false, true, true)
.await
.unwrap();
let wire = commit_to_wire_json(&response.commit, alice_node.db())
.await
.unwrap();
let ingested = bob_node
.apply_commit(&wire, IngestPolicy::Peer)
.await
.expect("bob must accept alice's signed genesis commit under Peer policy");
assert_eq!(ingested.commit.signer, alice.subject);
let got = bob_node
.db()
.get_resource_extended(&subject, false, &ForAgent::Sudo)
.await
.unwrap()
.to_single();
assert_eq!(got.get(urls::NAME).unwrap().to_string(), "Peer Doc");
let result = bob_node
.query(&Query {
property: Some(urls::PARENT.into()),
value: Some(Value::AtomicUrl(drive.clone())),
for_agent: ForAgent::Sudo,
..Query::new()
})
.await
.unwrap();
assert_eq!(result.subjects, vec![subject.clone()]);
let mut changed = Vec::new();
while let Ok(event) = bob_events.try_recv() {
if let DbEvent::Changed { subject, .. } = event {
changed.push(subject.pure_id());
}
}
assert!(
changed.contains(&subject.pure_id()),
"expected a Changed event for {subject}, got {changed:?}"
);
}
#[tokio::test]
async fn local_cache_skips_validation_peer_does_not() {
let hub = open_test_node("hub").await;
let (_alice, drive) = hub.db().setup("Alice").await.unwrap();
let drive = Subject::from(drive);
let mut draft = Resource::new("did:ad:placeholder".into());
draft
.set_unsafe(urls::NAME.into(), Value::String("Cached".into()))
.unwrap();
draft
.set_unsafe(urls::PARENT.into(), Value::AtomicUrl(drive))
.unwrap();
let response = draft.save_as_genesis(hub.db()).await.unwrap();
let mut wire: serde_json::Value =
serde_json::from_str(&response.commit_resource.to_json_ad(None).unwrap()).unwrap();
wire[urls::SIGNATURE] = serde_json::Value::String("AAAA".into());
let tampered = wire.to_string();
let cache = open_test_node("cache").await;
cache
.apply_commit(&tampered, IngestPolicy::LocalCache)
.await
.expect("LocalCache applies without validating the signature");
cache
.db()
.get_resource_extended(&response.commit.subject, false, &ForAgent::Sudo)
.await
.expect("cached resource is readable");
let peer = open_test_node("peer").await;
peer.apply_commit(&tampered, IngestPolicy::Peer)
.await
.expect_err("Peer validates the signature and must reject the tampered commit");
}
}