truffle_core/synced_store/mod.rs
1//! SyncedStore — device-owned state synchronization across the mesh.
2//!
3//! Each device owns exactly one slice of type `T` per store. Writes update
4//! the local slice and broadcast the change. Remote slices are received and
5//! cached automatically via a background sync task.
6//!
7//! # Quick start
8//!
9//! ```ignore
10//! use std::sync::Arc;
11//! use truffle_core::synced_store::SyncedStore;
12//!
13//! let node = Arc::new(node);
14//! let store: Arc<SyncedStore<MyState>> = SyncedStore::new(node, "my-state");
15//!
16//! store.set(MyState { value: 42 }).await;
17//! let my_data = store.local();
18//! let all_data = store.all().await;
19//! ```
20
21pub mod backend;
22pub mod sync;
23pub mod types;
24
25#[cfg(test)]
26mod tests;
27
28pub use backend::{FileBackend, MemoryBackend, StoreBackend};
29pub use types::{Slice, StoreEvent, SyncMessage};
30
31use std::collections::HashMap;
32use std::sync::atomic::{AtomicU64, Ordering};
33use std::sync::Arc;
34use std::time::{SystemTime, UNIX_EPOCH};
35
36use serde::de::DeserializeOwned;
37use serde::Serialize;
38use tokio::sync::{broadcast, watch, RwLock};
39use tokio::task::JoinHandle;
40
41use crate::network::NetworkProvider;
42use crate::node::Node;
43
44// ---------------------------------------------------------------------------
45// StoreInner — shared state between public API and sync task
46// ---------------------------------------------------------------------------
47
48/// Internal shared state for a SyncedStore. Accessed by both the public API
49/// and the background sync task.
50pub(crate) struct StoreInner<T> {
51 /// Store identifier (e.g., "sessions", "config").
52 pub(crate) store_id: String,
53 /// This device's stable node ID.
54 pub(crate) device_id: String,
55 /// Local slice (our data).
56 pub(crate) local: RwLock<Option<Slice<T>>>,
57 /// Remote peers' slices, keyed by device_id.
58 pub(crate) remotes: RwLock<HashMap<String, Slice<T>>>,
59 /// Current local version counter.
60 pub(crate) version: AtomicU64,
61 /// Event broadcast channel.
62 pub(crate) event_tx: broadcast::Sender<StoreEvent<T>>,
63 /// Persistence backend.
64 pub(crate) backend: Arc<dyn StoreBackend>,
65}
66
67// ---------------------------------------------------------------------------
68// SyncedStore — public API
69// ---------------------------------------------------------------------------
70
71/// A synchronized key-value store with device-owned slices.
72///
73/// Each device owns one slice of type `T`. Writes update only the local
74/// slice and broadcast the change. Remote slices are received and cached
75/// automatically.
76///
77/// Created via [`SyncedStore::new`] or [`Node::synced_store`].
78pub struct SyncedStore<T> {
79 pub(crate) inner: Arc<StoreInner<T>>,
80 /// Latest pending outbound update for the sync task. A `watch` channel
81 /// coalesces: the store has last-write-wins state semantics, so when
82 /// `set()` outpaces the network only the newest version needs to go on
83 /// the wire — intermediate versions carry no information and an
84 /// unbounded queue of them would just grow memory.
85 broadcast_tx: watch::Sender<Option<SyncMessage>>,
86 /// Handle to the background sync task.
87 task_handle: tokio::sync::Mutex<Option<JoinHandle<()>>>,
88}
89
90impl<T: Serialize + DeserializeOwned + Clone + Send + Sync + 'static> SyncedStore<T> {
91 /// Create a new SyncedStore and start the background sync task.
92 ///
93 /// Uses the default [`MemoryBackend`] (no persistence). For durable
94 /// storage across restarts, use [`new_with_backend`](Self::new_with_backend).
95 ///
96 /// The store syncs on namespace `"ss:{store_id}"`. The caller must hold
97 /// an `Arc<Node<N>>` because the background task needs to outlive this
98 /// constructor call.
99 pub fn new<N: NetworkProvider + 'static>(node: Arc<Node<N>>, store_id: &str) -> Arc<Self> {
100 Self::new_with_backend(node, store_id, Arc::new(MemoryBackend))
101 }
102
103 /// Create a new SyncedStore with a custom persistence backend.
104 ///
105 /// On startup, attempts to load the local device's persisted slice. If
106 /// found, the in-memory state and version counter are restored so the
107 /// store picks up where it left off.
108 pub fn new_with_backend<N: NetworkProvider + 'static>(
109 node: Arc<Node<N>>,
110 store_id: &str,
111 backend: Arc<dyn StoreBackend>,
112 ) -> Arc<Self> {
113 // RFC 017 §8: synced store slices are keyed by the stable
114 // `device_id` (ULID), which is propagated between peers via the
115 // WebSocket hello handshake.
116 let device_id = node.local_info().device_id;
117 let (event_tx, _) = broadcast::channel(256);
118 let (broadcast_tx, broadcast_rx) = watch::channel(None);
119
120 // Attempt to restore persisted local slice.
121 let (local, initial_version) = match backend.load(store_id, &device_id) {
122 Some((data, version)) => match serde_json::from_slice::<T>(&data) {
123 Ok(typed) => {
124 let now = SystemTime::now()
125 .duration_since(UNIX_EPOCH)
126 .unwrap_or_default()
127 .as_millis() as u64;
128 let slice = Slice {
129 device_id: device_id.clone(),
130 data: typed,
131 version,
132 updated_at: now,
133 };
134 tracing::info!(
135 store = store_id,
136 version = version,
137 "synced_store: restored local slice from backend"
138 );
139 (Some(slice), version)
140 }
141 Err(e) => {
142 tracing::warn!(
143 store = store_id,
144 "synced_store: failed to deserialize persisted data, starting fresh: {e}"
145 );
146 (None, 0)
147 }
148 },
149 None => (None, 0),
150 };
151
152 let inner = Arc::new(StoreInner {
153 store_id: store_id.to_string(),
154 device_id,
155 local: RwLock::new(local),
156 remotes: RwLock::new(HashMap::new()),
157 version: AtomicU64::new(initial_version),
158 event_tx,
159 backend,
160 });
161
162 let task_handle = sync::spawn_sync_task(node, inner.clone(), broadcast_rx);
163
164 Arc::new(Self {
165 inner,
166 broadcast_tx,
167 task_handle: tokio::sync::Mutex::new(Some(task_handle)),
168 })
169 }
170
171 // ── Write ───────────────────────────────────────────────────────
172
173 /// Update this device's data. Increments version and broadcasts.
174 ///
175 /// Rapid successive calls coalesce on the wire: peers are guaranteed to
176 /// observe the newest version, not every intermediate one (the store is
177 /// last-write-wins state, not an event log).
178 pub async fn set(&self, data: T) {
179 let version = self.inner.version.fetch_add(1, Ordering::SeqCst) + 1;
180 let now = SystemTime::now()
181 .duration_since(UNIX_EPOCH)
182 .unwrap_or_default()
183 .as_millis() as u64;
184
185 let slice = Slice {
186 device_id: self.inner.device_id.clone(),
187 data: data.clone(),
188 version,
189 updated_at: now,
190 };
191
192 {
193 let mut local = self.inner.local.write().await;
194 *local = Some(slice);
195 }
196
197 // Persist to backend.
198 if let Ok(serialized) = serde_json::to_vec(&data) {
199 self.inner.backend.save(
200 &self.inner.store_id,
201 &self.inner.device_id,
202 &serialized,
203 version,
204 );
205 }
206
207 // Request the sync task to broadcast. `send_replace` drops any
208 // not-yet-sent older version — only the newest matters.
209 if let Ok(serialized) = serde_json::to_value(&data) {
210 let msg = SyncMessage::Update {
211 device_id: self.inner.device_id.clone(),
212 data: serialized,
213 version,
214 updated_at: now,
215 };
216 self.broadcast_tx.send_replace(Some(msg));
217 }
218
219 let _ = self.inner.event_tx.send(StoreEvent::LocalChanged(data));
220 }
221
222 // ── Read ────────────────────────────────────────────────────────
223
224 /// This device's current data, or `None` if `set()` hasn't been called.
225 pub async fn local(&self) -> Option<T> {
226 let local = self.inner.local.read().await;
227 local.as_ref().map(|s| s.data.clone())
228 }
229
230 /// A specific peer's slice, or `None` if we haven't received their data.
231 pub async fn get(&self, device_id: &str) -> Option<Slice<T>> {
232 let remotes = self.inner.remotes.read().await;
233 remotes.get(device_id).cloned()
234 }
235
236 /// All slices (local + remote).
237 pub async fn all(&self) -> HashMap<String, Slice<T>> {
238 let mut result = HashMap::new();
239
240 {
241 let local = self.inner.local.read().await;
242 if let Some(slice) = local.as_ref() {
243 result.insert(slice.device_id.clone(), slice.clone());
244 }
245 }
246
247 {
248 let remotes = self.inner.remotes.read().await;
249 for (id, slice) in remotes.iter() {
250 result.insert(id.clone(), slice.clone());
251 }
252 }
253
254 result
255 }
256
257 /// Device IDs that have data in this store (local + remote).
258 pub async fn device_ids(&self) -> Vec<String> {
259 let mut ids = Vec::new();
260
261 {
262 let local = self.inner.local.read().await;
263 if let Some(slice) = local.as_ref() {
264 ids.push(slice.device_id.clone());
265 }
266 }
267
268 {
269 let remotes = self.inner.remotes.read().await;
270 ids.extend(remotes.keys().cloned());
271 }
272
273 ids
274 }
275
276 /// Subscribe to store change events.
277 pub fn subscribe(&self) -> broadcast::Receiver<StoreEvent<T>> {
278 self.inner.event_tx.subscribe()
279 }
280
281 /// The store identifier.
282 pub fn store_id(&self) -> &str {
283 &self.inner.store_id
284 }
285
286 /// This device's ID.
287 pub fn device_id(&self) -> &str {
288 &self.inner.device_id
289 }
290
291 /// Current local version number.
292 pub fn version(&self) -> u64 {
293 self.inner.version.load(Ordering::SeqCst)
294 }
295
296 // ── Lifecycle ───────────────────────────────────────────────────
297
298 /// Stop the background sync task.
299 pub async fn stop(&self) {
300 let mut handle = self.task_handle.lock().await;
301 if let Some(h) = handle.take() {
302 h.abort();
303 tracing::info!(
304 store = self.inner.store_id.as_str(),
305 "synced_store: stopped"
306 );
307 }
308 }
309}
310
311impl<T> Drop for SyncedStore<T> {
312 fn drop(&mut self) {
313 // Best-effort abort on drop.
314 if let Ok(mut handle) = self.task_handle.try_lock() {
315 if let Some(h) = handle.take() {
316 h.abort();
317 }
318 }
319 }
320}