Skip to main content

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}