Skip to main content

nodedb_lite/nodedb/core/ops/
crdt.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! CRDT sync gate, pending-delta bookkeeping, remote-delta import/apply, and
4//! background sync startup.
5
6use std::sync::Arc;
7
8use nodedb_types::error::{NodeDbError, NodeDbResult};
9
10use crate::nodedb::core::types::{NodeDbLite, SyncGate};
11use crate::nodedb::lock_ext::LockExt;
12use crate::storage::engine::StorageEngine;
13
14impl<S: StorageEngine> NodeDbLite<S> {
15    /// Install a per-document [`SyncGate`]. Documents the gate rejects are kept
16    /// local-only (excluded from CRDT delta, FTS, and vector sync channels).
17    pub fn set_sync_gate(&self, gate: Arc<dyn SyncGate>) {
18        let mut slot = self
19            .sync_gate
20            .write()
21            .unwrap_or_else(std::sync::PoisonError::into_inner);
22        *slot = Some(gate);
23    }
24
25    /// Whether a document write may be synced. Defaults to `true` when no gate is
26    /// installed (sync-everything, the prior behavior).
27    pub(crate) fn should_sync_doc(
28        &self,
29        collection: &str,
30        fields: &std::collections::HashMap<String, nodedb_types::Value>,
31    ) -> bool {
32        match self.sync_gate.read() {
33            Ok(slot) => slot
34                .as_ref()
35                .map(|g| g.should_sync(collection, fields))
36                .unwrap_or(true),
37            Err(_) => true,
38        }
39    }
40
41    /// Access pending CRDT deltas (for sync client).
42    pub fn pending_crdt_deltas(
43        &self,
44    ) -> NodeDbResult<Vec<crate::engine::crdt::engine::PendingDelta>> {
45        let crdt = self.crdt.lock_or_recover();
46        Ok(crdt.pending_deltas().to_vec())
47    }
48
49    /// Acknowledge synced deltas (called after Origin ACK).
50    pub fn acknowledge_deltas(&self, acked_id: u64) -> NodeDbResult<()> {
51        let mut crdt = self.crdt.lock_or_recover();
52        crdt.acknowledge(acked_id);
53        Ok(())
54    }
55
56    /// Assign a stable stream seq to a pending CRDT delta (first-send only).
57    ///
58    /// No-op if the delta already has a non-zero seq. Called by the sync
59    /// transport to ensure the same seq is reused on reconnect re-sends.
60    pub fn set_crdt_pending_delta_seq(&self, mutation_id: u64, seq: u64) -> NodeDbResult<()> {
61        let mut crdt = self.crdt.lock_or_recover();
62        crdt.set_pending_delta_seq(mutation_id, seq);
63        Ok(())
64    }
65
66    /// Import remote deltas from Origin into `collection`'s Loro document.
67    ///
68    /// The collection must be supplied by the caller: each collection has its
69    /// own document, and the update bytes alone do not identify which one.
70    pub fn import_remote_deltas(&self, collection: &str, data: &[u8]) -> NodeDbResult<()> {
71        let guard = self
72            .fts_state
73            .try_admit_mutation("import_remote_deltas")
74            .map_err(NodeDbError::from)?;
75        let result = (|| {
76            let imported = self
77                .crdt
78                .lock_or_recover()
79                .import_remote(collection, data)
80                .map_err(NodeDbError::storage)?;
81            // Re-index the rows the delta changed. They came from a peer, so
82            // nothing is staged back.
83            self.reindex_documents_local(
84                collection,
85                imported.changed_rows.iter().map(String::as_str),
86            )?;
87            Ok(())
88        })();
89        guard.finish(result)
90    }
91
92    /// Apply a server-originated row post-image from Origin.
93    ///
94    /// Unlike [`Self::import_remote_deltas`], the payload here is a
95    /// MessagePack row image rather than Loro update bytes. Origin sends it
96    /// for writes that have no client-authored CRDT operation to replicate
97    /// (SQL DML, DDL-managed system rows). An empty payload with `delete` set
98    /// removes the row.
99    ///
100    /// A KV collection's row is the `{key, value…}` row every KV read
101    /// returns. It lands in the KV store as the body a local write of the
102    /// same row stores. Any other collection's row lands in its Loro
103    /// document.
104    ///
105    /// Neither write reaches the outbound queue: the write came FROM Origin,
106    /// so pushing it back would echo it into a loop.
107    pub async fn apply_remote_row(
108        &self,
109        collection: &str,
110        document_id: &str,
111        payload: &[u8],
112        delete: bool,
113    ) -> NodeDbResult<()> {
114        let guard = self.fts_state.admit_mutation().await;
115        let result = async {
116            if self.is_kv_collection(collection).await? {
117                return self
118                    .kv_apply_remote_row(collection, document_id, payload, delete)
119                    .await;
120            }
121            self.apply_remote_document_row(collection, document_id, payload, delete)
122        }
123        .await;
124        guard.finish(result)
125    }
126
127    /// Apply a remote row post-image to `collection`'s Loro document.
128    fn apply_remote_document_row(
129        &self,
130        collection: &str,
131        document_id: &str,
132        payload: &[u8],
133        delete: bool,
134    ) -> NodeDbResult<()> {
135        use crate::nodedb::convert::value_to_loro;
136        use nodedb_types::value::Value;
137
138        let mut crdt = self.crdt.lock_or_recover();
139        let mutation_id = if delete {
140            crdt.delete(collection, document_id)
141                .map_err(NodeDbError::storage)?
142        } else {
143            // Origin writes a row post-image as standard msgpack, not the
144            // tagged zerompk `Value` encoding.
145            let value = nodedb_types::value_from_msgpack(payload).map_err(|e| {
146                NodeDbError::serialization(
147                    "msgpack",
148                    format!("row push for '{collection}'/'{document_id}' does not decode: {e}"),
149                )
150            })?;
151            let Value::Object(fields) = value else {
152                return Err(NodeDbError::serialization(
153                    "msgpack",
154                    format!(
155                        "row push for '{collection}'/'{document_id}' is a {}, not a row map",
156                        value.type_name()
157                    ),
158                ));
159            };
160            let loro_fields: Vec<(&str, loro::LoroValue)> = fields
161                .iter()
162                .map(|(k, v)| (k.as_str(), value_to_loro(v)))
163                .collect();
164            crdt.upsert(collection, document_id, &loro_fields)
165                .map_err(NodeDbError::from)?
166        };
167        crdt.drop_pending(mutation_id);
168
169        drop(crdt);
170
171        // Keep the text index on the row as it now stands. The row came from
172        // Origin, so nothing is staged back to it.
173        self.reindex_documents_local(collection, [document_id])?;
174        Ok(())
175    }
176
177    /// Reject a specific delta (rollback optimistic local state).
178    pub fn reject_delta(&self, mutation_id: u64) -> NodeDbResult<()> {
179        let guard = self
180            .fts_state
181            .try_admit_mutation("reject_delta")
182            .map_err(NodeDbError::from)?;
183        let result = (|| {
184            let mut crdt = self.crdt.lock_or_recover();
185            let rejected = crdt.reject_delta(mutation_id);
186            drop(crdt);
187            if let Some(delta) = rejected {
188                self.reindex_documents_local(&delta.collection, [delta.document_id.as_str()])?;
189            }
190            Ok(())
191        })();
192        guard.finish(result)
193    }
194
195    /// Start background sync to Origin.
196    ///
197    /// Spawns a task that connects to the Origin WebSocket endpoint, pushes
198    /// pending deltas, and receives shape updates, reconnecting on its own.
199    ///
200    /// Returns immediately — the sync runs in the background. Returns `None`
201    /// when sync is already running on this database: a second loop would
202    /// push the same deltas twice, and the first would no longer be the one
203    /// [`stop_sync`](Self::stop_sync) stops.
204    ///
205    /// The task is registered with the database, so `stop_sync` and
206    /// [`shutdown`](Self::shutdown) both stop it.
207    ///
208    /// Requires the `sync` feature.
209    #[cfg(all(not(target_arch = "wasm32"), feature = "sync"))]
210    pub fn start_sync(
211        self: &Arc<Self>,
212        config: crate::sync::SyncConfig,
213    ) -> Option<Arc<crate::sync::SyncClient>> {
214        if self.tasks.has(crate::tasks::TaskKind::Sync) {
215            return None;
216        }
217
218        let client = Arc::new(crate::sync::SyncClient::new(config));
219        let delegate: Arc<dyn crate::sync::SyncDelegate> = Arc::clone(self) as _;
220        let client_clone = Arc::clone(&client);
221        let (stop_tx, mut stop) = crate::tasks::TaskRegistry::signal();
222        let handle = crate::runtime::spawn(async move {
223            // `run_sync_loop` reconnects forever and has no exit of its own,
224            // so the stop signal is what ends it. Racing the two cancels the
225            // loop at its current await point, which is why the transport
226            // aborts its own children on drop rather than relying on the
227            // normal return path.
228            tokio::select! {
229                _ = crate::sync::run_sync_loop(client_clone, delegate) => {}
230                _ = stop.stopped() => {}
231            }
232        });
233        self.tasks
234            .track(crate::tasks::TaskKind::Sync, stop_tx, handle);
235        Some(client)
236    }
237
238    /// Stop background sync, leaving the database open and usable.
239    ///
240    /// Returns `true` when sync was running. Sync can be restarted afterwards
241    /// with a fresh [`start_sync`](Self::start_sync) — the reconnect-with-a-
242    /// new-token path.
243    ///
244    /// Requires the `sync` feature.
245    #[cfg(all(not(target_arch = "wasm32"), feature = "sync"))]
246    pub async fn stop_sync(&self) -> bool {
247        self.tasks.stop(crate::tasks::TaskKind::Sync).await
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use std::collections::HashMap;
254
255    use nodedb_types::value::Value;
256
257    use crate::storage::pagedb_storage::PagedbStorageMem;
258
259    use super::*;
260
261    async fn open_db() -> Arc<NodeDbLite<PagedbStorageMem>> {
262        let storage = PagedbStorageMem::open_in_memory()
263            .await
264            .expect("open in-memory storage");
265        NodeDbLite::open(storage).await.expect("open NodeDbLite")
266    }
267
268    #[tokio::test]
269    async fn a_pushed_document_row_lands_in_its_loro_document() {
270        let db = open_db().await;
271        let mut fields = HashMap::new();
272        fields.insert("name".to_string(), Value::String("ada".into()));
273        let row = nodedb_types::value_to_msgpack(&Value::Object(fields)).expect("encode");
274
275        db.apply_remote_row("people", "p1", &row, false)
276            .await
277            .expect("apply row");
278
279        let stored = db.crdt.lock_or_recover().read("people", "p1");
280        let Some(loro::LoroValue::Map(map)) = stored else {
281            panic!("row p1 is not a map: {stored:?}");
282        };
283        assert_eq!(
284            map.get("name"),
285            Some(&loro::LoroValue::String("ada".to_string().into()))
286        );
287        assert!(db.pending_crdt_deltas().expect("pending").is_empty());
288    }
289
290    #[tokio::test]
291    async fn a_pushed_document_payload_that_is_not_a_row_map_is_refused() {
292        let db = open_db().await;
293        let scalar = nodedb_types::value_to_msgpack(&Value::Integer(1)).expect("encode");
294
295        let err = db
296            .apply_remote_row("people", "p1", &scalar, false)
297            .await
298            .expect_err("a scalar is not a row");
299        assert!(err.message().contains("not a row map"), "{err}");
300    }
301}