Skip to main content

packset_daemon/
store.rs

1//! Atoms in LMDB: key `workspace\0id`, value the record as JSON. A seat's
2//! existing `memory.lmdb` opens here unchanged; the NUL makes a workspace
3//! scan a prefix scan.
4
5use std::collections::HashMap;
6use std::fs::{self, File};
7use std::path::Path;
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::{Arc, RwLock};
10
11use heed::types::Bytes;
12use heed::{Database, Env, EnvFlags, EnvOpenOptions};
13use packset_core::bm25::Index;
14use packset_core::record::{self, AtomError};
15use serde_json::{Map, Value};
16
17/// The map size the environment opens with. Growing it is compatible;
18/// shrinking it below what is stored is not.
19pub const MAP_SIZE: usize = 1024 * 1024 * 1024;
20
21/// One atom record.
22pub type Record = Map<String, Value>;
23
24/// One workspace's live set at a write count: as stored (what a write folds
25/// into) and as shown (links narrowed to ids present).
26type Snapshot = (u64, Vec<Record>, Arc<Vec<Record>>);
27
28/// A workspace's [`SearchSet`] at one generation.
29type Searchable = (u64, SearchSet);
30
31/// What a search runs over: the snapshot, the inverted index, and the tokens
32/// the index was built from, all shared.
33pub type SearchSet = (Arc<Vec<Record>>, Arc<Index>, Arc<Vec<Vec<String>>>);
34
35/// The key for one atom.
36#[must_use]
37pub fn atom_key(workspace: &str, id: &str) -> Vec<u8> {
38    let mut key = Vec::with_capacity(workspace.len() + id.len() + 1);
39    key.extend_from_slice(workspace.as_bytes());
40    key.push(0);
41    key.extend_from_slice(id.as_bytes());
42    key
43}
44
45/// The prefix every key in one workspace opens with.
46#[must_use]
47pub fn workspace_prefix(workspace: &str) -> Vec<u8> {
48    let mut key = Vec::with_capacity(workspace.len() + 1);
49    key.extend_from_slice(workspace.as_bytes());
50    key.push(0);
51    key
52}
53
54/// The atom database, plus the lock that makes it one writer.
55pub struct Store {
56    env: Env,
57    db: Database<Bytes, Bytes>,
58    /// Held open for as long as the store is: dropping it drops the lock.
59    _lock: File,
60    /// Bumped by every write, so a reader can tell a stale snapshot.
61    generation: AtomicU64,
62    /// One parsed live set per workspace, shared by concurrent readers.
63    live: RwLock<HashMap<String, Snapshot>>,
64    /// The inverted index over one workspace's live atoms, per generation,
65    /// kept beside the snapshot its ordinals index.
66    terms: RwLock<HashMap<String, Searchable>>,
67}
68
69impl Store {
70    /// Open the database under `root`, taking the single-writer lock.
71    ///
72    /// # Errors
73    ///
74    /// Fails when the home cannot be created, when another process already
75    /// holds the lock, or when LMDB refuses the directory.
76    pub fn open(root: &Path) -> anyhow::Result<Self> {
77        fs::create_dir_all(root)?;
78        let lock = take_lock(&root.join("packsetd.lock"))?;
79        let db_path = root.join("memory.lmdb");
80        fs::create_dir_all(&db_path)?;
81        // SAFETY: LMDB maps the file; the contract is that no other process
82        // writes it, which the lock above is what enforces.
83        let env = unsafe {
84            EnvOpenOptions::new()
85                .map_size(MAP_SIZE)
86                .max_dbs(1)
87                .flags(EnvFlags::WRITE_MAP)
88                .open(&db_path)?
89        };
90        let mut wtxn = env.write_txn()?;
91        let db: Database<Bytes, Bytes> = env.create_database(&mut wtxn, None)?;
92        wtxn.commit()?;
93        Ok(Self {
94            env,
95            db,
96            _lock: lock,
97            generation: AtomicU64::new(0),
98            live: RwLock::new(HashMap::new()),
99            terms: RwLock::new(HashMap::new()),
100        })
101    }
102
103    /// Every record in one workspace, or in all of them.
104    ///
105    /// # Errors
106    ///
107    /// Fails when the read transaction does.
108    pub fn scan(&self, workspace: Option<&str>) -> anyhow::Result<Vec<Record>> {
109        if workspace == Some("") {
110            return Ok(Vec::new());
111        }
112        let rtxn = self.env.read_txn()?;
113        let mut out = Vec::new();
114        match workspace {
115            Some(name) => {
116                let prefix = workspace_prefix(name);
117                for item in self.db.prefix_iter(&rtxn, &prefix)? {
118                    let (_, raw) = item?;
119                    push_record(&mut out, raw);
120                }
121            }
122            None => {
123                for item in self.db.iter(&rtxn)? {
124                    let (_, raw) = item?;
125                    push_record(&mut out, raw);
126                }
127            }
128        }
129        Ok(out)
130    }
131
132    /// Visit each record without collecting them. Status counts 30k expired
133    /// atoms this way instead of holding every JSON value at once.
134    ///
135    /// # Errors
136    ///
137    /// Fails when the read transaction does.
138    pub fn for_each(
139        &self,
140        workspace: Option<&str>,
141        mut visit: impl FnMut(&Record),
142    ) -> anyhow::Result<()> {
143        if workspace == Some("") {
144            return Ok(());
145        }
146        let rtxn = self.env.read_txn()?;
147        let mut each = |raw: &[u8]| {
148            if let Ok(Value::Object(record)) = serde_json::from_slice::<Value>(raw) {
149                visit(&record);
150            }
151        };
152        match workspace {
153            Some(name) => {
154                let prefix = workspace_prefix(name);
155                for item in self.db.prefix_iter(&rtxn, &prefix)? {
156                    let (_, raw) = item?;
157                    each(raw);
158                }
159            }
160            None => {
161                for item in self.db.iter(&rtxn)? {
162                    let (_, raw) = item?;
163                    each(raw);
164                }
165            }
166        }
167        Ok(())
168    }
169
170    /// One record by id, whatever its state.
171    ///
172    /// # Errors
173    ///
174    /// Fails when the read transaction does.
175    pub fn get(&self, workspace: &str, id: &str) -> anyhow::Result<Option<Record>> {
176        if workspace.is_empty() || id.is_empty() {
177            return Ok(None);
178        }
179        let rtxn = self.env.read_txn()?;
180        let raw = self.db.get(&rtxn, &atom_key(workspace, id))?;
181        Ok(raw.and_then(|bytes| {
182            serde_json::from_slice::<Value>(bytes)
183                .ok()
184                .and_then(|v| v.as_object().cloned())
185        }))
186    }
187
188    /// Write one record, replacing whatever shared its key.
189    ///
190    /// # Errors
191    ///
192    /// Fails when the record has no workspace or id, or when the write does.
193    pub fn upsert(&self, atom: &Record) -> anyhow::Result<()> {
194        self.upsert_many(std::slice::from_ref(atom))
195    }
196
197    /// Write several records in one transaction, so a link rewrite lands whole.
198    ///
199    /// # Errors
200    ///
201    /// Fails when a record has no workspace or id, or when the write does.
202    pub fn upsert_many(&self, atoms: &[Record]) -> anyhow::Result<()> {
203        let mut wtxn = self.env.write_txn()?;
204        for atom in atoms {
205            let mut payload = atom.clone();
206            if !matches!(payload.get("links"), Some(Value::Array(_))) {
207                payload.insert("links".into(), Value::Array(Vec::new()));
208            }
209            let workspace = payload
210                .get("workspace")
211                .and_then(Value::as_str)
212                .ok_or_else(|| anyhow::anyhow!("record has no workspace"))?;
213            let id = payload
214                .get("id")
215                .and_then(Value::as_str)
216                .ok_or_else(|| anyhow::anyhow!("record has no id"))?;
217            let key = atom_key(workspace, id);
218            let blob = serde_json::to_vec(&Value::Object(payload.clone()))?;
219            self.db.put(&mut wtxn, &key, &blob)?;
220        }
221        wtxn.commit()?;
222        // After the commit, never before: a reader that scans between a bump
223        // and its write would otherwise cache the older corpus as the newer.
224        let generation = self.generation.fetch_add(1, Ordering::AcqRel) + 1;
225        self.patch_live(atoms, generation);
226        Ok(())
227    }
228
229    /// Fold a committed write into the cached snapshot instead of dropping it.
230    /// Only cached workspaces are patched; the result equals a fresh scan.
231    fn patch_live(&self, written: &[Record], generation: u64) {
232        let now = packset_core::clock::utcnow();
233        let Ok(mut cache) = self.live.write() else {
234            return;
235        };
236        for (workspace, (seen, stored, shown)) in cache.iter_mut() {
237            // One behind is this write; anything else raced and the snapshot
238            // is not a base this write can be added to.
239            if *seen + 1 != generation {
240                continue;
241            }
242            for record in written {
243                if record.get("workspace").and_then(Value::as_str) != Some(workspace.as_str()) {
244                    continue;
245                }
246                let Some(id) = record.get("id").and_then(Value::as_str) else {
247                    continue;
248                };
249                let visible = record::is_live(record, &now) || record::is_due(record, &now);
250                let at = stored
251                    .iter()
252                    .position(|a| a.get("id").and_then(Value::as_str) == Some(id));
253                match (at, visible) {
254                    (Some(i), true) => stored[i] = record.clone(),
255                    (Some(i), false) => {
256                        stored.remove(i);
257                    }
258                    (None, true) => stored.push(record.clone()),
259                    (None, false) => {}
260                }
261            }
262            *seen = generation;
263            *shown = Arc::new(shown_from(stored));
264        }
265    }
266
267    /// The live and due records in one workspace, parsed once per write and
268    /// shared; readers take this over [`Store::current`].
269    ///
270    /// # Errors
271    ///
272    /// Fails when the scan does.
273    pub fn live(&self, workspace: &str) -> anyhow::Result<Arc<Vec<Record>>> {
274        let generation = self.generation.load(Ordering::Acquire);
275        if let Ok(cache) = self.live.read() {
276            if let Some((seen, _stored, shown)) = cache.get(workspace) {
277                if *seen == generation {
278                    return Ok(Arc::clone(shown));
279                }
280            }
281        }
282        // Built outside the write lock, so a slow parse does not hold up a
283        // reader whose own workspace is current.
284        let now = packset_core::clock::utcnow();
285        let stored: Vec<Record> = self
286            .scan(Some(workspace))?
287            .into_iter()
288            .filter(|atom| record::is_live(atom, &now) || record::is_due(atom, &now))
289            .collect();
290        let shared = Arc::new(shown_from(&stored));
291        // Cached only if nothing committed while the scan ran.
292        if self.generation.load(Ordering::Acquire) == generation {
293            if let Ok(mut cache) = self.live.write() {
294                cache.insert(
295                    workspace.to_string(),
296                    (generation, stored, Arc::clone(&shared)),
297                );
298            }
299        }
300        Ok(shared)
301    }
302
303    /// One workspace's live atoms and the index over them, as a matched pair;
304    /// cards are scored against the same corpus.
305    ///
306    /// # Errors
307    ///
308    /// Fails when the scan does.
309    pub fn searchable(&self, workspace: &str) -> anyhow::Result<SearchSet> {
310        let generation = self.generation.load(Ordering::Acquire);
311        if let Ok(cache) = self.terms.read() {
312            if let Some((seen, (atoms, index, documents))) = cache.get(workspace) {
313                if *seen == generation {
314                    return Ok((Arc::clone(atoms), Arc::clone(index), Arc::clone(documents)));
315                }
316            }
317        }
318        let atoms = self.live(workspace)?;
319        // Tokenising is most of what a rebuild costs. When the live set is the
320        // cached one with atoms appended, which is what a write does, only the
321        // new atoms are tokenised and the index is rebuilt from cached tokens.
322        let previous = self
323            .terms
324            .read()
325            .ok()
326            .and_then(|cache| cache.get(workspace).map(|(_, set)| set.clone()));
327        let documents: Arc<Vec<Vec<String>>> = Arc::new(match previous {
328            Some((old_atoms, _, old_documents))
329                if old_atoms.len() <= atoms.len()
330                    && old_atoms
331                        .iter()
332                        .zip(atoms.iter())
333                        .all(|(a, b)| a.get("id") == b.get("id") && a.get("ts") == b.get("ts")) =>
334            {
335                let mut documents = (*old_documents).clone();
336                documents.extend(
337                    atoms[old_atoms.len()..]
338                        .iter()
339                        .map(packset_core::search::atom_tokens),
340                );
341                documents
342            }
343            _ => atoms
344                .iter()
345                .map(packset_core::search::atom_tokens)
346                .collect(),
347        });
348        let index = Arc::new(Index::build(documents.iter().map(Vec::as_slice)));
349        // Same rule as the snapshot: cached only if nothing committed while
350        // this was built, since an index over a superseded pack served under
351        // the newer generation would never be rebuilt.
352        if self.generation.load(Ordering::Acquire) == generation {
353            if let Ok(mut cache) = self.terms.write() {
354                cache.insert(
355                    workspace.to_string(),
356                    (
357                        generation,
358                        (
359                            Arc::clone(&atoms),
360                            Arc::clone(&index),
361                            Arc::clone(&documents),
362                        ),
363                    ),
364                );
365            }
366        }
367        Ok((atoms, index, documents))
368    }
369
370    /// The atoms that were live at `at`, from a store scan since the snapshot
371    /// drops closed windows.
372    ///
373    /// # Errors
374    ///
375    /// Fails when the scan does, or when `at` is not a timestamp.
376    pub fn as_of(&self, workspace: &str, at: &str) -> anyhow::Result<Vec<Record>> {
377        let at = packset_core::clock::canonicalize(at)
378            .ok_or_else(|| anyhow::anyhow!("as_of must be a timestamp"))?;
379        let stored: Vec<Record> = self
380            .scan(Some(workspace))?
381            .into_iter()
382            .filter(|atom| record::is_live_at(atom, &at))
383            .collect();
384        Ok(shown_from(&stored))
385    }
386
387    /// The live and due records in one workspace, as a copy the caller owns.
388    ///
389    /// # Errors
390    ///
391    /// Fails when the scan does.
392    pub fn current(&self, workspace: &str, set: Option<&str>) -> anyhow::Result<Vec<Record>> {
393        let live = self.live(workspace)?;
394        Ok(match set {
395            None => live.as_ref().clone(),
396            Some(name) => live
397                .iter()
398                .filter(|atom| atom.get("set").and_then(Value::as_str) == Some(name))
399                .cloned()
400                .collect(),
401        })
402    }
403
404    /// Distinct workspace names with their live counts. `global` is always in.
405    ///
406    /// # Errors
407    ///
408    /// Fails when the scan does.
409    pub fn workspaces(&self) -> anyhow::Result<Vec<(String, usize)>> {
410        let now = packset_core::clock::utcnow();
411        let mut counts: std::collections::BTreeMap<String, usize> =
412            std::collections::BTreeMap::new();
413        for atom in self.scan(None)? {
414            let Some(name) = atom.get("workspace").and_then(Value::as_str) else {
415                continue;
416            };
417            if name.is_empty() {
418                continue;
419            }
420            let slot = counts.entry(name.to_string()).or_insert(0);
421            if record::is_live(&atom, &now) {
422                *slot += 1;
423            }
424        }
425        counts.entry("global".into()).or_insert(0);
426        Ok(counts.into_iter().collect())
427    }
428
429    /// Tombstone one live record, optionally naming the deed that withdrew it.
430    ///
431    /// The whole record is carried onto the tombstone, so `why` lands beside
432    /// the text it retracts and a bitemporal read gets both at once.
433    ///
434    /// # Errors
435    ///
436    /// [`AtomError`] when the id is not in the current set, else the write's.
437    pub fn delete(&self, workspace: &str, id: &str, why: Option<&str>) -> anyhow::Result<Record> {
438        let mut tomb = self
439            .current(workspace, None)?
440            .into_iter()
441            .find(|atom| atom.get("id").and_then(Value::as_str) == Some(id))
442            .ok_or_else(|| anyhow::Error::new(AtomError(format!("no current atom {id}"))))?;
443        tomb.insert("tombstone".into(), Value::Bool(true));
444        tomb.insert("ts".into(), Value::String(packset_core::clock::utcnow()));
445        if let Some(accession) = why {
446            tomb.insert("retracted_by".into(), Value::String(accession.to_string()));
447        }
448        self.upsert(&tomb)?;
449        Ok(tomb)
450    }
451}
452
453/// The live set as a reader sees it: links narrowed to the ids present.
454fn shown_from(stored: &[Record]) -> Vec<Record> {
455    let mut shown = stored.to_vec();
456    record::filter_live_links(&mut shown);
457    shown
458}
459
460fn push_record(out: &mut Vec<Record>, raw: &[u8]) {
461    if let Ok(Value::Object(map)) = serde_json::from_slice::<Value>(raw) {
462        out.push(map);
463    }
464}
465
466/// Take the exclusive lock, or say who has it. One writer per `memory.lmdb`.
467fn take_lock(path: &Path) -> anyhow::Result<File> {
468    use std::os::fd::AsRawFd;
469    let file = fs::OpenOptions::new()
470        .create(true)
471        .append(true)
472        .open(path)?;
473    // SAFETY: a libc call on a fd this function owns.
474    let taken = unsafe { flock(file.as_raw_fd(), LOCK_EX | LOCK_NB) };
475    if taken != 0 {
476        anyhow::bail!("store home is already open");
477    }
478    Ok(file)
479}
480
481const LOCK_EX: i32 = 2;
482const LOCK_NB: i32 = 4;
483
484extern "C" {
485    fn flock(fd: i32, operation: i32) -> i32;
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491    use serde_json::json;
492
493    fn record(value: Value) -> Record {
494        value.as_object().unwrap().clone()
495    }
496
497    fn store() -> (tempfile::TempDir, Store) {
498        let dir = tempfile::tempdir().unwrap();
499        let store = Store::open(dir.path()).unwrap();
500        (dir, store)
501    }
502
503    #[test]
504    fn the_key_separates_on_a_nul() {
505        assert_eq!(atom_key("w", "a"), b"w\0a".to_vec());
506        assert_eq!(workspace_prefix("w"), b"w\0".to_vec());
507        // A workspace whose name is a prefix of another must not leak into it,
508        // which is what the separator buys.
509        assert!(!atom_key("wide", "a").starts_with(&workspace_prefix("w")));
510    }
511
512    #[test]
513    fn a_record_round_trips() {
514        let (_dir, store) = store();
515        let atom = record(json!({
516            "id": "one", "workspace": "w", "kind": "voice",
517            "text": "A claim.", "links": ["two"], "unmodelled": {"x": 1}
518        }));
519        store.upsert(&atom).unwrap();
520        let back = store.get("w", "one").unwrap().unwrap();
521        assert_eq!(back["text"], json!("A claim."));
522        assert_eq!(back["links"], json!(["two"]));
523        assert_eq!(back["unmodelled"], json!({"x": 1}), "fields survive");
524    }
525
526    #[test]
527    fn a_scan_is_scoped_to_one_workspace() {
528        let (_dir, store) = store();
529        for (ws, id) in [("w", "a"), ("w", "b"), ("wide", "c")] {
530            store
531                .upsert(&record(json!({"id": id, "workspace": ws, "text": id})))
532                .unwrap();
533        }
534        let mine = store.scan(Some("w")).unwrap();
535        assert_eq!(mine.len(), 2, "{mine:?}");
536        assert_eq!(store.scan(Some("wide")).unwrap().len(), 1);
537        assert_eq!(store.scan(None).unwrap().len(), 3);
538        assert!(store.scan(Some("")).unwrap().is_empty());
539    }
540
541    #[test]
542    fn current_drops_the_expired_and_keeps_the_due() {
543        let (_dir, store) = store();
544        store
545            .upsert(&record(
546                json!({"id": "live", "workspace": "w", "text": "a"}),
547            ))
548            .unwrap();
549        store
550            .upsert(&record(json!({
551                "id": "gone", "workspace": "w", "text": "b",
552                "valid_to": "2000-01-01T00:00:00.000Z"
553            })))
554            .unwrap();
555        // Expired for the live set but still on the review clock, which is a
556        // different question and keeps it in reach.
557        store
558            .upsert(&record(json!({
559                "id": "due", "workspace": "w", "text": "c",
560                "valid_to": "2000-01-01T00:00:00.000Z",
561                "due_at": "2000-01-01T00:00:00.000Z"
562            })))
563            .unwrap();
564        let ids: Vec<String> = store
565            .current("w", None)
566            .unwrap()
567            .iter()
568            .map(|a| a["id"].as_str().unwrap().to_string())
569            .collect();
570        assert!(ids.contains(&"live".to_string()), "{ids:?}");
571        assert!(ids.contains(&"due".to_string()), "{ids:?}");
572        assert!(!ids.contains(&"gone".to_string()), "{ids:?}");
573    }
574
575    #[test]
576    fn as_of_returns_what_was_live_then() {
577        let (_dir, store) = store();
578        store
579            .upsert(&record(json!({
580                "id": "then", "workspace": "w", "text": "old claim",
581                "valid_from": "2024-01-01T00:00:00.000Z",
582                "valid_to": "2024-12-01T00:00:00.000Z"
583            })))
584            .unwrap();
585        store
586            .upsert(&record(json!({
587                "id": "now", "workspace": "w", "text": "new claim",
588                "valid_from": "2024-12-01T00:00:00.000Z"
589            })))
590            .unwrap();
591        store
592            .upsert(&record(json!({
593                "id": "tomb", "workspace": "w", "text": "deleted",
594                "valid_from": "2024-01-01T00:00:00.000Z",
595                "tombstone": true
596            })))
597            .unwrap();
598        let mid = store.as_of("w", "2024-06-01T00:00:00.000Z").unwrap();
599        let mid_ids: Vec<&str> = mid.iter().filter_map(|a| a["id"].as_str()).collect();
600        assert_eq!(mid_ids, vec!["then"], "{mid:?}");
601        let offset = store.as_of("w", "2024-06-01T00:00:00+00:00").unwrap();
602        let offset_ids: Vec<&str> = offset.iter().filter_map(|a| a["id"].as_str()).collect();
603        assert_eq!(offset_ids, mid_ids, "offset and Z as_of agree");
604        let today = store.as_of("w", "2025-06-01T00:00:00.000Z").unwrap();
605        let today_ids: Vec<&str> = today.iter().filter_map(|a| a["id"].as_str()).collect();
606        assert_eq!(today_ids, vec!["now"], "{today:?}");
607        assert!(
608            store
609                .current("w", None)
610                .unwrap()
611                .iter()
612                .all(|a| a["id"] != json!("then")),
613            "live-now still drops the closed window"
614        );
615    }
616
617    #[test]
618    fn current_narrows_links_to_what_it_returned() {
619        let (_dir, store) = store();
620        store
621            .upsert(&record(json!({
622                "id": "a", "workspace": "w", "text": "a", "links": ["b", "gone"]
623            })))
624            .unwrap();
625        store
626            .upsert(&record(json!({"id": "b", "workspace": "w", "text": "b"})))
627            .unwrap();
628        let live = store.current("w", None).unwrap();
629        let a = live.iter().find(|x| x["id"] == json!("a")).unwrap();
630        assert_eq!(a["links"], json!(["b"]), "a dangling link is not returned");
631    }
632
633    #[test]
634    fn a_set_scope_filters_the_current_view() {
635        let (_dir, store) = store();
636        store
637            .upsert(&record(
638                json!({"id": "a", "workspace": "w", "text": "a", "set": "review"}),
639            ))
640            .unwrap();
641        store
642            .upsert(&record(json!({"id": "b", "workspace": "w", "text": "b"})))
643            .unwrap();
644        assert_eq!(store.current("w", Some("review")).unwrap().len(), 1);
645        assert_eq!(store.current("w", None).unwrap().len(), 2);
646    }
647
648    #[test]
649    fn workspaces_count_the_live_and_always_name_global() {
650        let (_dir, store) = store();
651        store
652            .upsert(&record(json!({"id": "a", "workspace": "w", "text": "a"})))
653            .unwrap();
654        store
655            .upsert(&record(json!({
656                "id": "b", "workspace": "w", "text": "b", "tombstone": true
657            })))
658            .unwrap();
659        let found = store.workspaces().unwrap();
660        assert!(found.contains(&("w".to_string(), 1)), "{found:?}");
661        assert!(
662            found.iter().any(|(name, _)| name == "global"),
663            "an empty seat still has somewhere to write: {found:?}"
664        );
665    }
666
667    #[test]
668    fn deleting_leaves_a_tombstone_rather_than_a_hole() {
669        let (_dir, store) = store();
670        store
671            .upsert(&record(json!({"id": "a", "workspace": "w", "text": "a"})))
672            .unwrap();
673        let tomb = store.delete("w", "a", None).unwrap();
674        assert_eq!(tomb["tombstone"], json!(true));
675        // The record is still there to be read; it has left the live set.
676        assert!(store.get("w", "a").unwrap().is_some());
677        assert!(store.current("w", None).unwrap().is_empty());
678        assert!(
679            store.delete("w", "a", None).is_err(),
680            "twice is not current"
681        );
682    }
683
684    #[test]
685    fn a_retraction_carries_its_deed_onto_the_tombstone() {
686        let (_dir, store) = store();
687        store
688            .upsert(&record(
689                json!({"id": "a", "workspace": "w", "text": "the claim"}),
690            ))
691            .unwrap();
692        let tomb = store.delete("w", "a", Some("deed-patch-overlay")).unwrap();
693        assert_eq!(tomb["retracted_by"], json!("deed-patch-overlay"));
694        // Both halves read back together: what was withdrawn, and on what.
695        assert_eq!(tomb["text"], json!("the claim"));
696        let stored = store.get("w", "a").unwrap().unwrap();
697        assert_eq!(stored["retracted_by"], json!("deed-patch-overlay"));
698    }
699
700    #[test]
701    fn a_second_writer_is_refused_the_home() {
702        let dir = tempfile::tempdir().unwrap();
703        let _first = Store::open(dir.path()).unwrap();
704        let second = Store::open(dir.path());
705        assert!(second.is_err(), "one writer is the whole design");
706    }
707}
708
709#[cfg(test)]
710mod snapshot_tests {
711    use super::*;
712    use serde_json::json;
713
714    fn record(value: Value) -> Record {
715        value.as_object().unwrap().clone()
716    }
717
718    fn store() -> (tempfile::TempDir, Store) {
719        let dir = tempfile::tempdir().unwrap();
720        let store = Store::open(dir.path()).unwrap();
721        (dir, store)
722    }
723
724    #[test]
725    fn a_write_is_visible_to_the_next_read() {
726        let (_dir, store) = store();
727        assert!(store.live("w").unwrap().is_empty());
728        store
729            .upsert(&record(json!({"id": "a", "workspace": "w", "text": "a"})))
730            .unwrap();
731        assert_eq!(store.live("w").unwrap().len(), 1, "the snapshot went stale");
732        store
733            .upsert(&record(json!({"id": "b", "workspace": "w", "text": "b"})))
734            .unwrap();
735        assert_eq!(store.live("w").unwrap().len(), 2);
736    }
737
738    #[test]
739    fn a_repeated_read_hands_back_the_same_snapshot() {
740        let (_dir, store) = store();
741        store
742            .upsert(&record(json!({"id": "a", "workspace": "w", "text": "a"})))
743            .unwrap();
744        let first = store.live("w").unwrap();
745        let second = store.live("w").unwrap();
746        assert!(
747            Arc::ptr_eq(&first, &second),
748            "two readers should share one parse"
749        );
750        store
751            .upsert(&record(json!({"id": "b", "workspace": "w", "text": "b"})))
752            .unwrap();
753        let third = store.live("w").unwrap();
754        assert!(!Arc::ptr_eq(&first, &third), "a write invalidates it");
755    }
756
757    #[test]
758    fn a_tombstone_leaves_the_snapshot() {
759        let (_dir, store) = store();
760        store
761            .upsert(&record(json!({"id": "a", "workspace": "w", "text": "a"})))
762            .unwrap();
763        assert_eq!(store.live("w").unwrap().len(), 1);
764        store.delete("w", "a", None).unwrap();
765        assert!(
766            store.live("w").unwrap().is_empty(),
767            "a delete must invalidate too"
768        );
769    }
770
771    #[test]
772    fn one_workspace_write_does_not_serve_another_stale() {
773        let (_dir, store) = store();
774        store
775            .upsert(&record(json!({"id": "a", "workspace": "one", "text": "a"})))
776            .unwrap();
777        assert_eq!(store.live("one").unwrap().len(), 1);
778        assert!(store.live("two").unwrap().is_empty());
779        store
780            .upsert(&record(json!({"id": "b", "workspace": "two", "text": "b"})))
781            .unwrap();
782        assert_eq!(store.live("two").unwrap().len(), 1);
783        assert_eq!(store.live("one").unwrap().len(), 1, "still correct");
784    }
785
786    /// The whole safety argument for patching: whatever the snapshot says
787    /// after a write has to be what a scan of the database would say.
788    fn assert_matches_a_fresh_scan(store: &Store, workspace: &str) {
789        let patched: Vec<Value> = store
790            .live(workspace)
791            .unwrap()
792            .iter()
793            .map(|a| Value::Object(a.clone()))
794            .collect();
795        // Force the next read to derive from the database rather than the
796        // cache, and compare what comes back.
797        store.live.write().unwrap().clear();
798        let fresh: Vec<Value> = store
799            .live(workspace)
800            .unwrap()
801            .iter()
802            .map(|a| Value::Object(a.clone()))
803            .collect();
804        assert_eq!(
805            patched, fresh,
806            "the patched snapshot drifted from the store"
807        );
808    }
809
810    #[test]
811    fn a_patched_snapshot_says_what_a_fresh_scan_says() {
812        let (_dir, store) = store();
813        // Read first, so there is a cached snapshot for the writes to fold
814        // into rather than nothing to patch.
815        assert!(store.live("w").unwrap().is_empty());
816
817        store
818            .upsert(&record(json!({"id": "a", "workspace": "w", "text": "a"})))
819            .unwrap();
820        assert_matches_a_fresh_scan(&store, "w");
821
822        // An update in place.
823        store
824            .upsert(&record(
825                json!({"id": "a", "workspace": "w", "text": "changed"}),
826            ))
827            .unwrap();
828        assert_eq!(store.live("w").unwrap()[0]["text"], json!("changed"));
829        assert_matches_a_fresh_scan(&store, "w");
830
831        // A record that leaves the live set has to leave the snapshot.
832        store
833            .upsert(&record(json!({
834                "id": "a", "workspace": "w", "text": "changed",
835                "valid_to": "2000-01-01T00:00:00.000Z"
836            })))
837            .unwrap();
838        assert!(store.live("w").unwrap().is_empty());
839        assert_matches_a_fresh_scan(&store, "w");
840
841        // And one that is expired but still due stays, because the review
842        // clock is a separate question.
843        store
844            .upsert(&record(json!({
845                "id": "b", "workspace": "w", "text": "b",
846                "valid_to": "2000-01-01T00:00:00.000Z",
847                "due_at": "2000-01-01T00:00:00.000Z"
848            })))
849            .unwrap();
850        assert_eq!(store.live("w").unwrap().len(), 1);
851        assert_matches_a_fresh_scan(&store, "w");
852    }
853
854    #[test]
855    fn a_patch_narrows_links_the_way_a_scan_does() {
856        let (_dir, store) = store();
857        assert!(store.live("w").unwrap().is_empty());
858        store
859            .upsert(&record(json!({
860                "id": "a", "workspace": "w", "text": "a", "links": ["b"]
861            })))
862            .unwrap();
863        // `b` does not exist, so the link must not be reported.
864        assert_eq!(store.live("w").unwrap()[0]["links"], json!([]));
865        assert_matches_a_fresh_scan(&store, "w");
866
867        store
868            .upsert(&record(json!({"id": "b", "workspace": "w", "text": "b"})))
869            .unwrap();
870        assert_matches_a_fresh_scan(&store, "w");
871    }
872
873    #[test]
874    fn a_write_to_one_workspace_leaves_another_alone() {
875        let (_dir, store) = store();
876        store
877            .upsert(&record(json!({"id": "a", "workspace": "one", "text": "a"})))
878            .unwrap();
879        assert_eq!(store.live("one").unwrap().len(), 1);
880        assert!(store.live("two").unwrap().is_empty());
881        store
882            .upsert(&record(json!({"id": "b", "workspace": "two", "text": "b"})))
883            .unwrap();
884        assert_matches_a_fresh_scan(&store, "one");
885        assert_matches_a_fresh_scan(&store, "two");
886    }
887
888    #[test]
889    fn a_delete_is_visible_and_matches_a_scan() {
890        let (_dir, store) = store();
891        store
892            .upsert(&record(json!({"id": "a", "workspace": "w", "text": "a"})))
893            .unwrap();
894        assert_eq!(store.live("w").unwrap().len(), 1);
895        store.delete("w", "a", None).unwrap();
896        assert!(store.live("w").unwrap().is_empty());
897        assert_matches_a_fresh_scan(&store, "w");
898    }
899
900    #[test]
901    fn readers_racing_a_writer_never_see_a_snapshot_that_skips_a_write() {
902        // Generation read before the scan, bumped before the write: a snapshot
903        // built across a write is stale, never mislabelled.
904        let (dir, store) = store();
905        let store = Arc::new(store);
906        let _ = dir;
907        let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
908
909        let writer = {
910            let store = Arc::clone(&store);
911            let stop = Arc::clone(&stop);
912            std::thread::spawn(move || {
913                for i in 0..200 {
914                    store
915                        .upsert(&record(json!({
916                            "id": format!("a{i}"), "workspace": "w", "text": "x"
917                        })))
918                        .unwrap();
919                }
920                stop.store(true, std::sync::atomic::Ordering::Release);
921            })
922        };
923
924        let readers: Vec<_> = (0..4)
925            .map(|_| {
926                let store = Arc::clone(&store);
927                let stop = Arc::clone(&stop);
928                std::thread::spawn(move || {
929                    let mut high = 0usize;
930                    while !stop.load(std::sync::atomic::Ordering::Acquire) {
931                        let seen = store.live("w").unwrap().len();
932                        assert!(seen >= high, "went backwards: {seen} after {high}");
933                        high = seen;
934                    }
935                })
936            })
937            .collect();
938
939        writer.join().unwrap();
940        for reader in readers {
941            reader.join().unwrap();
942        }
943        assert_eq!(store.live("w").unwrap().len(), 200, "every write landed");
944    }
945}