Skip to main content

spacedb_crdt/
persist.rs

1//! [`CrdtStore`] — persisting convergent documents into the encrypted store.
2//!
3//! This is where Layer 1 (CRDT) meets Layer 0 (storage). Each document's CRDT
4//! state is stored as the value of a row in an **encrypted** `spacedb-store`
5//! [`Collection`] keyed by document id — so the on-disk bytes are AEAD ciphertext
6//! the engine cannot read (the zero-knowledge posture holds for CRDT data too).
7//!
8//! The write path is the mission's loop made concrete: a local mutation applies
9//! to the in-memory [`CrdtDoc`], and [`CrdtStore::save`] persists the new state in
10//! **one M1 write transaction**. The incremental update to ship to peers comes
11//! from the doc's own [`CrdtDoc::encode_update_since`] — the anti-entropy
12//! primitive — and [`CrdtStore::apply_remote`] is the receive side
13//! (load → merge → re-persist).
14//!
15//! **S2 stores a full state snapshot per save.** That is simple and correct; the
16//! op-log + convergent compaction that avoids re-encoding the whole document on
17//! every write is M2-S3.
18
19use std::sync::Arc;
20
21use spacedb_store::{Collection, Durability, KeyProvider, KvEngine, WriteTx};
22
23use crate::{CrdtDoc, CrdtResult};
24
25/// The encrypted collection that holds `doc_id → CRDT-state` rows.
26pub const CRDT_DOCS_COLLECTION: &str = "crdt_docs";
27
28/// The schema version bound into each persisted row's AEAD (see `spacedb-store`).
29const SCHEMA_VERSION: u32 = 1;
30
31/// Persists [`CrdtDoc`]s into an encrypted `spacedb-store` collection. Engine-
32/// agnostic like the underlying [`Collection`]: the methods take the engine and
33/// open their own transactions.
34pub struct CrdtStore {
35    docs: Collection<String, Vec<u8>>,
36}
37
38impl CrdtStore {
39    /// Open (or first-time provision) the CRDT document collection on `engine`.
40    pub fn open<E: KvEngine>(engine: &E, key_provider: Arc<dyn KeyProvider>) -> CrdtResult<Self> {
41        let docs =
42            Collection::open_or_create(engine, key_provider, CRDT_DOCS_COLLECTION, SCHEMA_VERSION)?;
43        Ok(Self { docs })
44    }
45
46    /// Persist `doc`'s full CRDT state under `doc_id`, encrypted, in a single
47    /// write transaction.
48    pub fn save<E: KvEngine>(&self, engine: &E, doc_id: &str, doc: &CrdtDoc) -> CrdtResult<()> {
49        let state = doc.encode_full();
50        let mut w = engine.begin_write(Durability::Immediate)?;
51        self.docs.put(&mut w, &doc_id.to_string(), &state)?;
52        w.commit()?;
53        Ok(())
54    }
55
56    /// Load the document stored under `doc_id` for replica `actor_id`. Returns a
57    /// fresh empty document if nothing has been persisted there yet (local-first:
58    /// you can always start writing).
59    pub fn load<E: KvEngine>(
60        &self,
61        engine: &E,
62        doc_id: &str,
63        actor_id: u64,
64    ) -> CrdtResult<CrdtDoc> {
65        let stored = {
66            let r = engine.begin_read()?;
67            self.docs.get(&r, &doc_id.to_string())?
68        };
69        let doc = CrdtDoc::new(actor_id);
70        if let Some(state) = stored {
71            doc.apply_update(&state)?;
72        }
73        Ok(doc)
74    }
75
76    /// Receive side of anti-entropy: merge a remote `update` into the persisted
77    /// document and re-persist it (load → merge → save). Returns the merged doc.
78    pub fn apply_remote<E: KvEngine>(
79        &self,
80        engine: &E,
81        doc_id: &str,
82        actor_id: u64,
83        update: &[u8],
84    ) -> CrdtResult<CrdtDoc> {
85        let doc = self.load(engine, doc_id, actor_id)?;
86        doc.apply_update(update)?;
87        self.save(engine, doc_id, &doc)?;
88        Ok(doc)
89    }
90
91    /// Whether a document has been persisted under `doc_id`.
92    pub fn contains<E: KvEngine>(&self, engine: &E, doc_id: &str) -> CrdtResult<bool> {
93        let r = engine.begin_read()?;
94        Ok(self.docs.get(&r, &doc_id.to_string())?.is_some())
95    }
96}