qualia_client_core/wellfair/
sync_transport.rs1use super::sync_protocol::SyncOperation;
19use std::sync::{Arc, Mutex};
20
21pub trait SyncTransport {
23 fn publish(&self, ops: &[SyncOperation]) -> Result<(), String>;
26
27 fn pull(&self, since: u64) -> Result<Vec<SyncOperation>, String>;
30}
31
32#[derive(Clone, Default)]
35pub struct InMemoryRelay {
36 inner: Arc<Mutex<Vec<SyncOperation>>>,
37}
38
39impl InMemoryRelay {
40 pub fn new() -> Self {
41 Self::default()
42 }
43
44 pub fn len(&self) -> usize {
46 self.inner.lock().map(|v| v.len()).unwrap_or(0)
47 }
48
49 pub fn is_empty(&self) -> bool {
50 self.len() == 0
51 }
52}
53
54impl SyncTransport for InMemoryRelay {
55 fn publish(&self, ops: &[SyncOperation]) -> Result<(), String> {
56 let mut store = self
57 .inner
58 .lock()
59 .map_err(|_| "relay lock poisoned".to_string())?;
60 for op in ops {
61 if !store.iter().any(|e| e.operation_id == op.operation_id) {
62 store.push(op.clone());
63 }
64 }
65 Ok(())
66 }
67
68 fn pull(&self, since: u64) -> Result<Vec<SyncOperation>, String> {
69 let store = self
70 .inner
71 .lock()
72 .map_err(|_| "relay lock poisoned".to_string())?;
73 let start = (since as usize).min(store.len());
74 Ok(store[start..].to_vec())
75 }
76}
77
78#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
80pub struct SyncOpsBody {
81 pub ops: Vec<SyncOperation>,
82}
83
84#[cfg(not(target_arch = "wasm32"))]
87pub struct HttpRelayTransport {
88 base_url: String,
89 timeout: std::time::Duration,
90}
91
92#[cfg(not(target_arch = "wasm32"))]
93impl HttpRelayTransport {
94 pub fn new(base_url: impl Into<String>) -> Self {
95 Self {
96 base_url: base_url.into().trim_end_matches('/').to_string(),
97 timeout: std::time::Duration::from_secs(8),
98 }
99 }
100
101 fn client(&self) -> Result<reqwest::blocking::Client, String> {
102 reqwest::blocking::Client::builder()
103 .timeout(self.timeout)
104 .build()
105 .map_err(|e| e.to_string())
106 }
107}
108
109#[cfg(not(target_arch = "wasm32"))]
110impl SyncTransport for HttpRelayTransport {
111 fn publish(&self, ops: &[SyncOperation]) -> Result<(), String> {
112 let url = format!("{}/sync/publish", self.base_url);
113 let body = SyncOpsBody { ops: ops.to_vec() };
114 let resp = self
115 .client()?
116 .post(&url)
117 .json(&body)
118 .send()
119 .map_err(|e| e.to_string())?;
120 if !resp.status().is_success() {
121 return Err(format!("relay publish failed: HTTP {}", resp.status()));
122 }
123 Ok(())
124 }
125
126 fn pull(&self, since: u64) -> Result<Vec<SyncOperation>, String> {
127 let url = format!("{}/sync/pull?since={since}", self.base_url);
128 let resp = self.client()?.get(&url).send().map_err(|e| e.to_string())?;
129 if !resp.status().is_success() {
130 return Err(format!("relay pull failed: HTTP {}", resp.status()));
131 }
132 let parsed: SyncOpsBody = resp.json().map_err(|e| e.to_string())?;
133 Ok(parsed.ops)
134 }
135}
136
137#[cfg(not(target_arch = "wasm32"))]
145pub struct Libp2pSyncTransport {
146 client: qualia_core_db::p2p::sync_node::BlockingSyncClient,
147}
148
149#[cfg(not(target_arch = "wasm32"))]
150impl Libp2pSyncTransport {
151 pub fn connect(peer_id: &str, peer_addr: &str) -> Result<Self, String> {
154 let client = qualia_core_db::p2p::sync_node::BlockingSyncClient::connect(
155 qualia_core_db::p2p::sync_ops::SyncOpRelay::new(),
156 peer_id,
157 peer_addr,
158 )?;
159 Ok(Self { client })
160 }
161
162 pub fn local_peer_id(&self) -> String {
164 self.client.local_peer_id().to_string()
165 }
166}
167
168#[cfg(not(target_arch = "wasm32"))]
170fn op_to_frame(op: &SyncOperation) -> Result<Vec<u8>, String> {
171 let mut buf = Vec::new();
172 ciborium::into_writer(op, &mut buf).map_err(|e| format!("encode operation: {e}"))?;
173 Ok(buf)
174}
175
176#[cfg(not(target_arch = "wasm32"))]
177impl SyncTransport for Libp2pSyncTransport {
178 fn publish(&self, ops: &[SyncOperation]) -> Result<(), String> {
179 if ops.is_empty() {
180 return Ok(());
181 }
182 let frames = ops.iter().map(op_to_frame).collect::<Result<Vec<_>, _>>()?;
183 self.client.publish_frames(frames)?;
184 Ok(())
185 }
186
187 fn pull(&self, since: u64) -> Result<Vec<SyncOperation>, String> {
188 let (frames, _next_cursor) = self.client.pull_frames(since)?;
189 Ok(frames
192 .iter()
193 .filter_map(|f| ciborium::from_reader::<SyncOperation, _>(&f[..]).ok())
194 .collect())
195 }
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201 use crate::wellfair::sync_protocol::{AdmitOutcome, SyncInbox, SyncOperation};
202
203 fn signed(id: &str, kind: &str, summary: &str, lamport: u64) -> SyncOperation {
204 SyncOperation::new(
205 id,
206 format!("urn:wellfair:{kind}:{id}"),
207 kind,
208 "did:wf:remote",
209 "Restricted",
210 summary,
211 lamport,
212 1_700_000_000,
213 )
214 .with_signature("deadbeef")
215 }
216
217 #[test]
218 fn in_memory_relay_dedups_and_pulls_from_cursor() {
219 let relay = InMemoryRelay::new();
220 relay
221 .publish(&[signed("a", "ledger_entry", "1", 1)])
222 .unwrap();
223 relay
224 .publish(&[signed("b", "ledger_entry", "2", 2)])
225 .unwrap();
226 relay
228 .publish(&[signed("a", "ledger_entry", "1", 1)])
229 .unwrap();
230 assert_eq!(relay.len(), 2);
231
232 assert_eq!(relay.pull(0).unwrap().len(), 2);
234 assert_eq!(relay.pull(2).unwrap().len(), 0);
235 relay
236 .publish(&[signed("c", "ledger_entry", "3", 3)])
237 .unwrap();
238 let fresh = relay.pull(2).unwrap();
239 assert_eq!(fresh.len(), 1);
240 assert_eq!(fresh[0].operation_id, "c");
241 }
242
243 #[test]
244 fn two_nodes_converge_over_one_relay() {
245 let relay = InMemoryRelay::new();
248 let dir_a = tempfile::tempdir().unwrap();
249 let dir_b = tempfile::tempdir().unwrap();
250 let inbox_a = SyncInbox::open(dir_a.path()).unwrap();
251 let inbox_b = SyncInbox::open(dir_b.path()).unwrap();
252
253 let a_op = signed("a1", "contribution", "a", 1);
254 let b_op = signed("b1", "contribution", "b", 2);
255 inbox_a.admit(&a_op, 1).unwrap();
257 relay.publish(&[a_op.clone()]).unwrap();
258 inbox_b.admit(&b_op, 1).unwrap();
259 relay.publish(&[b_op.clone()]).unwrap();
260
261 for op in relay.pull(0).unwrap() {
263 inbox_a.admit(&op, 2).unwrap();
264 inbox_b.admit(&op, 2).unwrap();
265 }
266
267 let set_a = inbox_a.validated_operations().unwrap();
268 let set_b = inbox_b.validated_operations().unwrap();
269 assert_eq!(
270 set_a, set_b,
271 "nodes must converge to the same validated set"
272 );
273 assert_eq!(set_a.len(), 2);
274 }
275
276 #[test]
277 fn hostile_relay_ops_are_rejected_not_admitted() {
278 let relay = InMemoryRelay::new();
281 let dir = tempfile::tempdir().unwrap();
282 let inbox = SyncInbox::open(dir.path()).unwrap();
283
284 let mut unsigned = signed("h1", "ledger_entry", "x", 1);
286 unsigned.signature = None;
287 let mut tampered = signed("h2", "ledger_entry", "orig", 1);
289 tampered.payload_summary = "changed".into();
290 let mut bad_ver = signed("h3", "ledger_entry", "x", 1);
292 bad_ver.protocol_version = 99;
293 let mut classified = signed("h4", "sanctuary_note", "x", 1);
295 classified.sensitivity = "Classified".into();
296
297 relay
298 .publish(&[unsigned, tampered, bad_ver, classified])
299 .unwrap();
300
301 let mut rejected = 0;
302 for op in relay.pull(0).unwrap() {
303 if matches!(inbox.admit(&op, 5).unwrap(), AdmitOutcome::Rejected(_)) {
304 rejected += 1;
305 }
306 }
307 assert_eq!(rejected, 4, "all hostile ops must be rejected");
308 assert!(inbox.validated_operations().unwrap().is_empty());
309 }
310
311 #[test]
312 fn replayed_pull_is_idempotent() {
313 let relay = InMemoryRelay::new();
315 let dir = tempfile::tempdir().unwrap();
316 let inbox = SyncInbox::open(dir.path()).unwrap();
317 relay
318 .publish(&[signed("r1", "ledger_entry", "x", 1)])
319 .unwrap();
320
321 for _ in 0..3 {
322 for op in relay.pull(0).unwrap() {
323 inbox.admit(&op, 9).unwrap();
324 }
325 }
326 assert_eq!(inbox.validated_operations().unwrap().len(), 1);
327 }
328
329 #[test]
330 fn partition_then_rejoin_converges() {
331 let left = InMemoryRelay::new();
334 let right = InMemoryRelay::new();
335 left.publish(&[signed("L", "contribution", "l", 1)])
336 .unwrap();
337 right
338 .publish(&[signed("R", "contribution", "r", 2)])
339 .unwrap();
340
341 let dir_a = tempfile::tempdir().unwrap();
342 let dir_b = tempfile::tempdir().unwrap();
343 let inbox_a = SyncInbox::open(dir_a.path()).unwrap();
344 let inbox_b = SyncInbox::open(dir_b.path()).unwrap();
345
346 let mut all = left.pull(0).unwrap();
348 all.extend(right.pull(0).unwrap());
349 for op in &all {
350 inbox_a.admit(op, 1).unwrap();
351 inbox_b.admit(op, 1).unwrap();
352 }
353 assert_eq!(
354 inbox_a.validated_operations().unwrap(),
355 inbox_b.validated_operations().unwrap()
356 );
357 assert_eq!(inbox_a.validated_operations().unwrap().len(), 2);
358 }
359
360 #[cfg(not(target_arch = "wasm32"))]
365 #[test]
366 fn libp2p_transport_round_trips_real_operations_and_inbox_admits() {
367 use qualia_core_db::p2p::sync_node::Libp2pSyncNode;
368 use qualia_core_db::p2p::sync_ops::SyncOpRelay;
369
370 let rt_a = tokio::runtime::Builder::new_multi_thread()
373 .worker_threads(1)
374 .enable_all()
375 .build()
376 .unwrap();
377 let relay_a = SyncOpRelay::new();
378 let a = {
379 let _guard = rt_a.enter();
380 Libp2pSyncNode::spawn(relay_a.clone())
381 };
382 let a_addr = rt_a
383 .block_on(a.listen("/ip4/127.0.0.1/tcp/0"))
384 .expect("A listen");
385
386 let transport = Libp2pSyncTransport::connect(&a.peer_id.to_string(), &a_addr.to_string())
388 .expect("connect");
389
390 let ops = vec![
392 signed("t1", "contribution", "alpha", 1),
393 signed("t2", "contribution", "beta", 2),
394 ];
395 transport.publish(&ops).expect("publish");
396 assert_eq!(relay_a.len(), 2, "A's relay holds both op frames");
397
398 let pulled = transport.pull(0).expect("pull");
399 assert_eq!(
400 pulled, ops,
401 "operations round-trip losslessly through CBOR + libp2p"
402 );
403
404 let dir = tempfile::tempdir().unwrap();
406 let inbox = SyncInbox::open(dir.path()).unwrap();
407 for op in &pulled {
408 assert_eq!(inbox.admit(op, 1).unwrap(), AdmitOutcome::Validated);
409 }
410 assert_eq!(inbox.validated_operations().unwrap().len(), 2);
411 }
412}