use tokio::sync::broadcast;
use crate::{
agents::{Agent, ForAgent},
commit::CommitResponse,
db::{Db, DbEvent},
errors::AtomicResult,
storelike::{Query, QueryResult, ResourceResponse},
sync::engine::{ingest_commit, CommitIngestOpts},
Resource, Storelike, Subject,
};
#[derive(Debug, Clone)]
pub enum NodeStorage {
Memory,
#[cfg(feature = "db-redb")]
RedbMemory,
#[cfg(all(feature = "db-redb", not(target_arch = "wasm32")))]
RedbFile {
path: std::path::PathBuf,
uploads_path: std::path::PathBuf,
},
#[cfg(all(feature = "db-redb", target_arch = "wasm32"))]
Opfs {
filename: String,
encryption_key: Option<[u8; 32]>,
},
}
#[derive(Debug, Clone)]
pub struct NodeConfig {
pub storage: NodeStorage,
pub base_domain: Option<String>,
pub agent: Option<Agent>,
}
impl NodeConfig {
pub fn memory() -> Self {
Self {
storage: NodeStorage::Memory,
base_domain: None,
agent: None,
}
}
pub fn with_base_domain(mut self, base_domain: impl Into<String>) -> Self {
self.base_domain = Some(base_domain.into());
self
}
pub fn with_agent(mut self, agent: Agent) -> Self {
self.agent = Some(agent);
self
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum IngestPolicy {
Hub {
source_id: Option<String>,
response_origin: Option<String>,
},
#[default]
Peer,
Replica,
LocalCache,
}
impl IngestPolicy {
pub fn hub() -> Self {
Self::Hub {
source_id: None,
response_origin: None,
}
}
}
pub enum ResourceEdit<'a> {
Update(&'a mut Resource),
Genesis(&'a mut Resource),
}
#[derive(Clone)]
pub struct AtomicNode {
db: Db,
}
impl AtomicNode {
pub async fn open(config: NodeConfig) -> AtomicResult<Self> {
let NodeConfig {
storage,
base_domain,
agent,
} = config;
let db = match storage {
NodeStorage::Memory => Db::init_memory(base_domain).await?,
#[cfg(feature = "db-redb")]
NodeStorage::RedbMemory => Db::init_redb(base_domain).await?,
#[cfg(all(feature = "db-redb", not(target_arch = "wasm32")))]
NodeStorage::RedbFile { path, uploads_path } => {
Db::init_redb_file(&path, base_domain, &uploads_path).await?
}
#[cfg(all(feature = "db-redb", target_arch = "wasm32"))]
NodeStorage::Opfs {
filename,
encryption_key,
} => Db::init_redb_opfs(base_domain, &filename, encryption_key.as_ref()).await?,
};
let node = Self::from_db(db);
if let Some(agent) = agent {
node.set_agent(agent);
}
Ok(node)
}
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 get(
&self,
subject: &Subject,
for_agent: &ForAgent,
) -> AtomicResult<ResourceResponse> {
self.db
.get_resource_extended(subject, false, for_agent)
.await
}
pub async fn query(&self, q: &Query) -> AtomicResult<QueryResult> {
self.db.query(q).await
}
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 {
source_id,
validate_loro_causality: true,
enforce_subject_ownership: true,
suppress_live_echo: false,
response_origin,
},
)
.await
}
IngestPolicy::Peer => {
ingest_commit(
&self.db,
commit_json,
&CommitIngestOpts {
source_id: None,
validate_loro_causality: false,
enforce_subject_ownership: false,
suppress_live_echo: true,
response_origin: None,
},
)
.await
}
IngestPolicy::Replica => {
crate::sync::ws_apply::apply_commit_json(&self.db, commit_json).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
}
}
}
pub async fn mutate(&self, edit: ResourceEdit<'_>) -> AtomicResult<CommitResponse> {
match edit {
ResourceEdit::Update(resource) => resource.save_locally(&self.db).await,
ResourceEdit::Genesis(resource) => resource.save_as_genesis(&self.db).await,
}
}
pub fn subscribe(&self) -> broadcast::Receiver<DbEvent> {
self.db.subscribe_events()
}
#[cfg(feature = "iroh")]
pub async fn sync_with_peer(
&self,
node_id: &str,
drive: &Subject,
) -> AtomicResult<crate::sync::peer::PeerSyncOutcome> {
crate::sync::peer::sync_drive_with_peer_outcome(node_id, drive.as_str(), &self.db).await
}
}
#[cfg(all(test, feature = "db-redb"))]
mod tests {
use super::*;
use crate::{client::commit_to_wire_json, urls, Value};
async fn open_test_node(label: &str) -> AtomicNode {
let node = AtomicNode::open(NodeConfig {
storage: NodeStorage::RedbMemory,
..NodeConfig::memory().with_base_domain("https://localhost")
})
.await
.unwrap_or_else(|e| panic!("{label}: open failed: {e}"));
node.db().populate().await.unwrap();
node
}
#[tokio::test]
async fn two_nodes_mutate_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.subscribe();
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 = alice_node
.mutate(ResourceEdit::Genesis(&mut draft))
.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 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
.get(&subject, &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 = hub.mutate(ResourceEdit::Genesis(&mut draft)).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
.get(&response.commit.subject, &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");
}
#[tokio::test]
async fn mutate_without_agent_is_an_error() {
let node = AtomicNode::open(NodeConfig::memory()).await.unwrap();
assert!(node.agent().is_none());
let mut draft = Resource::new("did:ad:placeholder".into());
let err = node
.mutate(ResourceEdit::Genesis(&mut draft))
.await
.expect_err("no agent, no signature");
assert!(err.to_string().contains("No agent set"), "got: {err}");
}
}