replicated_notes/replicated_notes.rs
1//! Replicated documents on one device: open a store, edit, persist, reopen.
2//!
3//! Run it with:
4//!
5//! ```text
6//! cargo run --package offline-protocol --example replicated_notes
7//! ```
8//!
9//! What it shows, in order: that a document needs no storage setup, that the
10//! four collection types behave the way an application expects, that a flush
11//! is what makes a change durable, and that the same records reopen into the
12//! same document after the engine is rebuilt.
13//!
14//! What it deliberately does not show is replication, which needs a second
15//! device, a confirmed MLS session, and a transport. The merge semantics that
16//! makes replication safe is a separate example that needs none of those:
17//! `cargo run --package offline-protocol-data --example offline_merge`.
18//!
19//! The backend below is an in-memory `ProtocolStateStorage`, written out in
20//! full because it is also the shape of the bring-your-own-storage seam: an
21//! application swaps SQLite, files, or its own encrypted store in at exactly
22//! this point, and sealing sits above it, so what `store` receives here is
23//! already ciphertext.
24
25use std::collections::HashMap;
26use std::sync::{Arc, Mutex};
27
28use offline_protocol::{
29 DataValue, OfflineProtocol, ProtocolConfig, ProtocolStateResult, ProtocolStateStorage,
30};
31use offline_protocol_mls::storage::InMemoryStorage;
32
33/// A protocol-state backend that keeps records in a map.
34///
35/// Four methods over `(key_type, key_id, bytes)`. The rules a real adapter
36/// has to honour are the ones the conformance suite checks: bytes are stored
37/// verbatim, a second write to one key replaces the first, and a key that was
38/// never written loads as `None` rather than as an error.
39#[derive(Default)]
40struct MemoryStateStorage {
41 records: Mutex<HashMap<(String, String), Vec<u8>>>,
42}
43
44impl ProtocolStateStorage for MemoryStateStorage {
45 fn store(&self, key_type: &str, key_id: &str, data: &[u8]) -> ProtocolStateResult<()> {
46 let mut records = self.records.lock().expect("storage mutex");
47 records.insert((key_type.to_string(), key_id.to_string()), data.to_vec());
48 Ok(())
49 }
50
51 fn load(&self, key_type: &str, key_id: &str) -> ProtocolStateResult<Option<Vec<u8>>> {
52 let records = self.records.lock().expect("storage mutex");
53 Ok(records
54 .get(&(key_type.to_string(), key_id.to_string()))
55 .cloned())
56 }
57
58 fn delete(&self, key_type: &str, key_id: &str) -> ProtocolStateResult<()> {
59 let mut records = self.records.lock().expect("storage mutex");
60 records.remove(&(key_type.to_string(), key_id.to_string()));
61 Ok(())
62 }
63
64 fn list_keys(&self, key_type: &str) -> ProtocolStateResult<Vec<String>> {
65 let records = self.records.lock().expect("storage mutex");
66 Ok(records
67 .keys()
68 .filter(|(stored_type, _)| stored_type == key_type)
69 .map(|(_, key_id)| key_id.clone())
70 .collect())
71 }
72}
73
74fn main() -> Result<(), Box<dyn std::error::Error>> {
75 // Both stores outlive the engine below, which is what makes the reopen at
76 // the end a reopen rather than a fresh start. The secure store matters as
77 // much as the record store: documents are sealed with a key that lives in
78 // it, so dropping only that half looks exactly like data loss.
79 let secure = Arc::new(InMemoryStorage::default());
80 let records = Arc::new(MemoryStateStorage::default());
81
82 // `data.enabled` defaults to true, so nothing here switches the layer on.
83 let config = ProtocolConfig::builder("com.example.notes", "default").build()?;
84 let mut protocol = OfflineProtocol::new(config)?;
85
86 // Documents are sealed at rest and the record key is minted here, so this
87 // call is the prerequisite for every method below.
88 protocol.initialize_mls(secure.clone(), records.clone())?;
89
90 // A space is an MLS scope: a peer's address for a 1:1 space, or the group
91 // id for a group. Nothing replicates in this example, so the name only
92 // has to be a valid one.
93 let space = "demo-space";
94
95 protocol.data_create_doc(space, "trip")?;
96
97 // A map: last writer wins per key, and different keys never conflict.
98 protocol.data_map_set(
99 space,
100 "trip",
101 "meta",
102 "title",
103 DataValue::Text {
104 value: "Coast road".to_string(),
105 },
106 )?;
107
108 // A list: concurrent insertions both survive, in a deterministic order.
109 for item in ["water", "map", "torch"] {
110 protocol.data_list_push(
111 space,
112 "trip",
113 "packing",
114 DataValue::Text {
115 value: item.to_string(),
116 },
117 )?;
118 }
119
120 // Text: character-level merge, so two people typing in one paragraph keep
121 // both sets of words. Positions are character offsets, not byte offsets.
122 protocol.data_text_insert(space, "trip", "notes", 0, "Meet at the bridge")?;
123
124 // A counter: increments add up rather than overwriting each other.
125 protocol.data_counter_increment(space, "trip", "opened", 1.0)?;
126
127 // Edits batch before they reach storage. This is the call that makes them
128 // durable, and `data_changed` fires after the same point, never before.
129 protocol.data_flush(space, "trip")?;
130
131 println!("spaces: {:?}", protocol.data_list_spaces()?);
132 println!("documents: {:?}", protocol.data_list_docs(space)?);
133 println!(
134 "compacted: {} bytes",
135 protocol.data_doc_size(space, "trip")?
136 );
137 println!(
138 "history: {} bytes",
139 protocol.data_export_raw(space, "trip")?.len()
140 );
141 println!("json: {}", protocol.data_doc_json(space, "trip")?);
142
143 // Both halves of the escape hatch are above: `data_doc_json` is the plain
144 // JSON of the current state, `data_export_raw` the engine-native history.
145 // An application can always take its data and leave.
146
147 // Reopen: a new engine over the same two stores. The records were sealed
148 // by the first one and are read back by the second, which is the property
149 // an application depends on across a restart.
150 drop(protocol);
151 let config = ProtocolConfig::builder("com.example.notes", "default").build()?;
152 let mut reopened = OfflineProtocol::new(config)?;
153 reopened.initialize_mls(secure, records)?;
154
155 println!("after reopen");
156 println!(
157 " title: {:?}",
158 reopened.data_map_get(space, "trip", "meta", "title")?
159 );
160 println!(
161 " packing: {}",
162 reopened.data_list_len(space, "trip", "packing")?
163 );
164 println!(
165 " notes: {:?}",
166 reopened.data_text_value(space, "trip", "notes")?
167 );
168 println!(
169 " opened: {}",
170 reopened.data_counter_value(space, "trip", "opened")?
171 );
172
173 Ok(())
174}