use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
use affinidi_messaging_sdk::protocols::tsp::RelationshipState;
use affinidi_messaging_sdk::{PersistentRelationshipStore, RelationshipKv, RelationshipStore};
use affinidi_tdk::messaging::errors::ATMError;
use base64::Engine as _;
use base64::prelude::BASE64_URL_SAFE_NO_PAD as B64;
use serde::{Deserialize, Serialize};
use tokio::sync::{Notify, RwLock};
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct TspRelationships {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
entries: BTreeMap<String, String>,
}
impl TspRelationships {
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
fn from_map(map: &HashMap<Vec<u8>, Vec<u8>>) -> Self {
Self {
entries: map
.iter()
.map(|(k, v)| (B64.encode(k), B64.encode(v)))
.collect(),
}
}
fn to_map(&self) -> HashMap<Vec<u8>, Vec<u8>> {
self.entries
.iter()
.filter_map(|(k, v)| Some((B64.decode(k).ok()?, B64.decode(v).ok()?)))
.collect()
}
}
#[derive(Clone)]
struct SharedKv {
map: Arc<RwLock<HashMap<Vec<u8>, Vec<u8>>>>,
dirty: Arc<Notify>,
}
#[async_trait::async_trait]
impl RelationshipKv for SharedKv {
async fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>, ATMError> {
Ok(self.map.read().await.get(key).cloned())
}
async fn put(&self, key: &[u8], value: &[u8]) -> Result<(), ATMError> {
self.map.write().await.insert(key.to_vec(), value.to_vec());
self.dirty.notify_one();
Ok(())
}
async fn delete(&self, key: &[u8]) -> Result<(), ATMError> {
self.map.write().await.remove(key);
self.dirty.notify_one();
Ok(())
}
async fn scan_prefix(&self, prefix: &[u8]) -> Result<Vec<(Vec<u8>, Vec<u8>)>, ATMError> {
Ok(self
.map
.read()
.await
.iter()
.filter(|(k, _)| k.starts_with(prefix))
.map(|(k, v)| (k.clone(), v.clone()))
.collect())
}
}
#[derive(Clone)]
pub struct TspStoreHandle {
shared: SharedKv,
}
impl TspStoreHandle {
pub fn new() -> Self {
Self {
shared: SharedKv {
map: Arc::new(RwLock::new(HashMap::new())),
dirty: Arc::new(Notify::new()),
},
}
}
pub fn relationship_store(&self) -> Arc<dyn RelationshipStore> {
Arc::new(PersistentRelationshipStore::new(self.shared.clone()))
}
pub fn dirty(&self) -> Arc<Notify> {
self.shared.dirty.clone()
}
pub async fn hydrate(&self, persisted: &TspRelationships) {
*self.shared.map.write().await = persisted.to_map();
}
pub async fn snapshot(&self) -> TspRelationships {
let guard = self.shared.map.read().await;
TspRelationships::from_map(&guard)
}
pub async fn adopt_into(
&self,
mut tdk: affinidi_tdk::TDK,
) -> Result<affinidi_tdk::TDK, ATMError> {
if tdk.atm.is_none() {
return Ok(tdk);
}
let atm = affinidi_tdk::messaging::ATM::new(
affinidi_tdk::messaging::config::ATMConfig::builder()
.with_relationship_store(self.relationship_store())
.build()?,
tdk.get_shared_state(),
)
.await?;
tdk.atm = Some(atm);
Ok(tdk)
}
pub async fn state_for(&self, our_vid: &str, their_vid: &str) -> RelationshipState {
PersistentRelationshipStore::new(self.shared.clone())
.get(our_vid, their_vid)
.await
.unwrap_or_default()
}
}
impl Default for TspStoreHandle {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn snapshot_then_hydrate_round_trips() {
let a = TspStoreHandle::new();
let store = a.relationship_store();
store
.set(
"did:webvh:example:us",
"did:webvh:example:them",
affinidi_messaging_sdk::protocols::tsp::RelationshipState::Pending,
)
.await
.expect("set");
let snap = a.snapshot().await;
assert!(!snap.is_empty(), "a recorded relationship must persist");
let b = TspStoreHandle::new();
b.hydrate(&snap).await;
let restored = b.relationship_store();
let state = restored
.get("did:webvh:example:us", "did:webvh:example:them")
.await
.expect("get");
assert_eq!(
state,
affinidi_messaging_sdk::protocols::tsp::RelationshipState::Pending,
"the relationship state must survive a snapshot/hydrate cycle"
);
}
#[tokio::test]
async fn adopted_tdk_atm_records_into_the_shared_store() {
use affinidi_messaging_sdk::protocols::tsp::RelationshipState;
use affinidi_tdk::common::config::TDKConfig;
use affinidi_tdk::common::profiles::TDKProfile;
use affinidi_tdk::messaging::profiles::ATMProfile;
const US: &str = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK";
const THEM: &str = "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH";
let tdk = affinidi_tdk::TDK::new(
TDKConfig::builder()
.with_load_environment(false)
.build()
.expect("config"),
None,
)
.await
.expect("tdk");
let handle = TspStoreHandle::new();
let invite = affinidi_messaging_sdk::protocols::tsp::ControlMessage::invite();
let record = |tdk: affinidi_tdk::TDK| {
let invite = invite.clone();
async move {
let atm = tdk.atm.clone().expect("atm");
let p = TDKProfile::new(
"p",
US,
Some("did:key:z6Mkf5rGMoatrSj1f4CyvuHBeXJELe9RPdzo2PKGNCKVtZxP"),
vec![],
);
let profile = ATMProfile::from_tdk_profile(&atm, &p)
.await
.expect("profile");
atm.tsp()
.record_incoming_control(&std::sync::Arc::new(profile), THEM, &invite)
.await
.expect("record");
}
};
record(tdk.clone()).await;
assert_eq!(
handle.state_for(US, THEM).await,
RelationshipState::None,
"the SDK default store is private to the TDK's ATM"
);
let tdk = handle.adopt_into(tdk).await.expect("adopt");
record(tdk).await;
assert_eq!(
handle.state_for(US, THEM).await,
RelationshipState::InviteReceived,
"after adopt_into the sender's writes land in the shared store"
);
}
#[tokio::test]
async fn empty_store_is_empty_mirror() {
let h = TspStoreHandle::new();
assert!(h.snapshot().await.is_empty());
assert_eq!(
serde_json::to_string(&h.snapshot().await).expect("serialise"),
"{}",
"an unused store must round-trip to an empty object"
);
}
#[tokio::test]
async fn undecodable_row_is_skipped_not_fatal() {
let mut snap = TspRelationships::default();
snap.entries
.insert("not valid base64!!".to_string(), "also bad".to_string());
let map = snap.to_map();
assert!(map.is_empty(), "an undecodable row is skipped");
}
}