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 two stores are the built-in file stores a headless host uses: MLS
20//! material sealed under a store key, and protocol state in the format every
21//! binding writes. They live under a directory given as the first argument
22//! (a fresh temporary directory otherwise). The store key comes from
23//! `OFFLINE_PROTOCOL_STORE_KEY` (64 hex digits or base64) when it is set; the
24//! example otherwise mints one for the run, which is enough for the reopen
25//! below but not for a second run over the same directory.
26//!
27//! An application that brings its own storage (SQLite, its own encrypted
28//! store) swaps it in at exactly the `initialize_mls` call, and sealing sits
29//! above it, so what the protocol-state store receives is already ciphertext.
30
31use std::path::PathBuf;
32use std::sync::Arc;
33
34use offline_protocol::{
35 account_storage_namespace, DataValue, EnvStoreKey, FileProtocolStateStorage, OfflineProtocol,
36 ProtocolConfig, SealedFileMlsStorage, StaticStoreKey, StoreKeyProvider,
37};
38use rand_core::{OsRng, RngCore};
39
40const APP_ID: &str = "com.example.notes";
41const PROFILE: &str = "default";
42
43/// Opens the two stores for this account under `root`.
44fn open_stores(
45 root: &std::path::Path,
46 key: &dyn StoreKeyProvider,
47) -> Result<(Arc<SealedFileMlsStorage>, Arc<FileProtocolStateStorage>), Box<dyn std::error::Error>>
48{
49 let namespace = account_storage_namespace(APP_ID, PROFILE);
50 // Two roots: MLS material may outlive an installation, protocol state
51 // must not.
52 let secure = SealedFileMlsStorage::open(root.join("keys"), &namespace, key)?;
53 let records = FileProtocolStateStorage::open(root.join("state"), &namespace)?;
54 Ok((Arc::new(secure), Arc::new(records)))
55}
56
57fn main() -> Result<(), Box<dyn std::error::Error>> {
58 let root = match std::env::args_os().nth(1) {
59 Some(root) => PathBuf::from(root),
60 None => {
61 let mut suffix = [0u8; 8];
62 OsRng.fill_bytes(&mut suffix);
63 std::env::temp_dir().join(format!("replicated-notes-{}", hex::encode(suffix)))
64 }
65 };
66 let key: Box<dyn StoreKeyProvider> =
67 if std::env::var_os(EnvStoreKey::DEFAULT_VARIABLE).is_some() {
68 Box::new(EnvStoreKey::default())
69 } else {
70 let mut key = [0u8; 32];
71 OsRng.fill_bytes(&mut key);
72 Box::new(StaticStoreKey::new(key))
73 };
74 println!("stores: {}", root.display());
75
76 // Both stores are on disk, which is what makes the reopen at the end a
77 // reopen rather than a fresh start. The secure store matters as much as
78 // the record store: documents are sealed with a key that lives in it, so
79 // losing only that half looks exactly like data loss.
80 let (secure, records) = open_stores(&root, key.as_ref())?;
81
82 // `data.enabled` defaults to true, so nothing here switches the layer on.
83 let config = ProtocolConfig::builder(APP_ID, PROFILE).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 directories, opened afresh as a
148 // restarted process would. The records were sealed by the first engine
149 // and are read back by the second, which is the property an application
150 // depends on across a restart.
151 drop(protocol);
152 drop((secure, records));
153 let (secure, records) = open_stores(&root, key.as_ref())?;
154 let config = ProtocolConfig::builder(APP_ID, PROFILE).build()?;
155 let mut reopened = OfflineProtocol::new(config)?;
156 reopened.initialize_mls(secure, records)?;
157
158 println!("after reopen");
159 println!(
160 " title: {:?}",
161 reopened.data_map_get(space, "trip", "meta", "title")?
162 );
163 println!(
164 " packing: {}",
165 reopened.data_list_len(space, "trip", "packing")?
166 );
167 println!(
168 " notes: {:?}",
169 reopened.data_text_value(space, "trip", "notes")?
170 );
171 println!(
172 " opened: {}",
173 reopened.data_counter_value(space, "trip", "opened")?
174 );
175
176 Ok(())
177}