Skip to main content

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}