1use std::path::Path;
4
5use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition};
6use serde::{Deserialize, Serialize};
7
8use crate::error::{Error, RedbExt, Result};
9use crate::hlc::Hlc;
10use crate::id::ReplicaId;
11use crate::state::PathState;
12use crate::vv::VersionVector;
13
14pub const FORMAT_VERSION: u32 = 1;
17
18const PATHS: TableDefinition<&str, &[u8]> = TableDefinition::new("paths");
19const META: TableDefinition<&str, &[u8]> = TableDefinition::new("meta");
20const META_KEY: &str = "meta";
21
22#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
24pub struct Meta {
25 pub format_version: u32,
26 pub replica_id: ReplicaId,
27 pub counter: u64,
29 pub hlc: Hlc,
30 pub vv: VersionVector,
32 pub root: String,
34}
35
36pub struct ReplicaStore {
38 db: Database,
39}
40
41#[derive(Deserialize)]
42struct FormatProbe {
43 format_version: u32,
44}
45
46impl ReplicaStore {
47 pub fn open(path: &Path, root: &str) -> Result<Self> {
48 Self::open_with_id(path, root, None)
49 }
50
51 pub fn open_with_id(path: &Path, root: &str, id: Option<ReplicaId>) -> Result<Self> {
53 if let Some(parent) = path.parent() {
54 std::fs::create_dir_all(parent)?;
55 }
56 let db = Database::create(path).db()?;
57 let wtx = db.begin_write().db()?;
58 {
59 wtx.open_table(PATHS).db()?;
60 let mut meta_table = wtx.open_table(META).db()?;
61 let existing = meta_table.get(META_KEY).db()?.map(|g| g.value().to_vec());
62 match existing {
63 None => {
64 let meta = Meta {
65 format_version: FORMAT_VERSION,
66 replica_id: id.unwrap_or_default(),
67 counter: 0,
68 hlc: Hlc::default(),
69 vv: VersionVector::new(),
70 root: root.to_owned(),
71 };
72 meta_table
73 .insert(META_KEY, serde_json::to_vec(&meta)?.as_slice())
74 .db()?;
75 }
76 Some(bytes) => {
77 let probe: FormatProbe = serde_json::from_slice(&bytes)?;
78 if probe.format_version > FORMAT_VERSION {
79 return Err(Error::FormatTooNew {
80 found: probe.format_version,
81 supported: FORMAT_VERSION,
82 });
83 }
84 let meta: Meta = serde_json::from_slice(&bytes)?;
85 if meta.root != root {
86 return Err(Error::RootMismatch {
87 stored: meta.root,
88 requested: root.to_owned(),
89 });
90 }
91 }
92 }
93 }
94 wtx.commit().db()?;
95 Ok(Self { db })
96 }
97
98 pub fn meta(&self) -> Result<Meta> {
99 let rtx = self.db.begin_read().db()?;
100 let table = rtx.open_table(META).db()?;
101 let guard = table
102 .get(META_KEY)
103 .db()?
104 .ok_or_else(|| Error::Corrupt("missing meta record".into()))?;
105 Ok(serde_json::from_slice(guard.value())?)
106 }
107
108 pub fn get(&self, path: &str) -> Result<Option<PathState>> {
109 let rtx = self.db.begin_read().db()?;
110 let table = rtx.open_table(PATHS).db()?;
111 match table.get(path).db()? {
112 Some(guard) => Ok(Some(serde_json::from_slice(guard.value())?)),
113 None => Ok(None),
114 }
115 }
116
117 pub fn all(&self) -> Result<Vec<(String, PathState)>> {
118 let rtx = self.db.begin_read().db()?;
119 let table = rtx.open_table(PATHS).db()?;
120 let mut out = Vec::new();
121 for item in table.iter().db()? {
122 let (k, v) = item.db()?;
123 out.push((k.value().to_owned(), serde_json::from_slice(v.value())?));
124 }
125 Ok(out)
126 }
127
128 pub fn any_materialized(&self) -> Result<bool> {
129 Ok(self.all()?.iter().any(|(_, s)| s.disk.hash.is_some()))
130 }
131
132 pub fn commit(&self, meta: &Meta, states: &[(String, PathState)]) -> Result<()> {
134 let wtx = self.db.begin_write().db()?;
135 {
136 let mut meta_table = wtx.open_table(META).db()?;
137 meta_table
138 .insert(META_KEY, serde_json::to_vec(meta)?.as_slice())
139 .db()?;
140 let mut paths = wtx.open_table(PATHS).db()?;
141 for (path, state) in states {
142 paths
143 .insert(path.as_str(), serde_json::to_vec(state)?.as_slice())
144 .db()?;
145 }
146 }
147 wtx.commit().db()?;
148 Ok(())
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155 use crate::entry::{Content, Entry};
156 use crate::hash::ContentHash;
157 use crate::state::DiskState;
158 use crate::vv::Dot;
159 use grain_id::GrainId;
160
161 fn state(id: ReplicaId, body: &str, on_disk: bool) -> PathState {
162 let e = Entry {
163 path: "a.txt".into(),
164 content: Content::File {
165 hash: ContentHash::of_bytes(body.as_bytes()),
166 len: 1,
167 },
168 hlc: Hlc::default(),
169 dot: Dot {
170 replica: id,
171 counter: 1,
172 },
173 context: VersionVector::new(),
174 author: GrainId::NIL,
175 };
176 let mut seen = VersionVector::new();
177 seen.add_dot(&e.dot);
178 let disk = DiskState {
179 hash: on_disk.then(|| ContentHash::of_bytes(body.as_bytes())),
180 ..DiskState::default()
181 };
182 PathState {
183 versions: vec![e],
184 seen,
185 disk,
186 }
187 }
188
189 #[test]
190 fn creates_meta_once_and_persists_commits() {
191 let dir = tempfile::tempdir().unwrap();
192 let path = dir.path().join("sync.redb");
193 let (id, s) = {
194 let store = ReplicaStore::open(&path, "/root").unwrap();
195 let mut meta = store.meta().unwrap();
196 assert_eq!(meta.format_version, FORMAT_VERSION);
197 assert_eq!(meta.counter, 0);
198 meta.counter = 7;
199 let s = state(meta.replica_id, "x", false);
200 store.commit(&meta, &[("a.txt".into(), s.clone())]).unwrap();
201 (meta.replica_id, s)
202 };
203 let store = ReplicaStore::open(&path, "/root").unwrap();
204 let meta = store.meta().unwrap();
205 assert_eq!((meta.replica_id, meta.counter), (id, 7));
206 assert_eq!(store.get("a.txt").unwrap(), Some(s.clone()));
207 assert_eq!(store.get("b.txt").unwrap(), None);
208 assert_eq!(store.all().unwrap(), vec![("a.txt".to_string(), s)]);
209 assert!(!store.any_materialized().unwrap());
210 }
211
212 #[test]
213 fn any_materialized_sees_files_on_disk() {
214 let dir = tempfile::tempdir().unwrap();
215 let store = ReplicaStore::open(&dir.path().join("s.redb"), "/r").unwrap();
216 let meta = store.meta().unwrap();
217 store
218 .commit(
219 &meta,
220 &[("a.txt".into(), state(meta.replica_id, "x", true))],
221 )
222 .unwrap();
223 assert!(store.any_materialized().unwrap());
224 }
225
226 #[test]
227 fn refuses_another_root() {
228 let dir = tempfile::tempdir().unwrap();
229 let path = dir.path().join("s.redb");
230 drop(ReplicaStore::open(&path, "/one").unwrap());
231 assert!(matches!(
232 ReplicaStore::open(&path, "/two"),
233 Err(Error::RootMismatch { .. })
234 ));
235 }
236
237 #[test]
238 fn refuses_a_newer_format() {
239 let dir = tempfile::tempdir().unwrap();
240 let path = dir.path().join("s.redb");
241 {
242 let store = ReplicaStore::open(&path, "/r").unwrap();
243 let mut meta = store.meta().unwrap();
244 meta.format_version = FORMAT_VERSION + 1;
245 store.commit(&meta, &[]).unwrap();
246 }
247 assert!(matches!(
248 ReplicaStore::open(&path, "/r"),
249 Err(Error::FormatTooNew { found, .. }) if found == FORMAT_VERSION + 1
250 ));
251 }
252
253 #[test]
254 fn open_with_id_uses_the_id_only_on_creation() {
255 let dir = tempfile::tempdir().unwrap();
256 let path = dir.path().join("s.redb");
257 let fixed = ReplicaId(uuid::Uuid::from_u128(5));
258 let store = ReplicaStore::open_with_id(&path, "/r", Some(fixed)).unwrap();
259 assert_eq!(store.meta().unwrap().replica_id, fixed);
260 drop(store);
261 let other = ReplicaId(uuid::Uuid::from_u128(6));
262 let store = ReplicaStore::open_with_id(&path, "/r", Some(other)).unwrap();
263 assert_eq!(store.meta().unwrap().replica_id, fixed);
264 }
265}