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, HashSet};
6use std::fs::{self, File};
7use std::path::Path;
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::{Arc, Mutex, 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>>, HashSet<String>);
27
28/// A workspace's index at one generation: the id and stamp of each atom
29/// the index was built over, in order, beside the index and its tokens.
30/// The atoms themselves are not held here, so the live set stays unshared
31/// between writes and a write edits it in place rather than copying it.
32type Searchable = (
33    u64,
34    (Vec<(String, String)>, Arc<Index>, Arc<Vec<Vec<String>>>),
35);
36
37/// The id and stamp of each atom, the fingerprint an index is keyed on.
38fn fingerprint(atoms: &[Record]) -> Vec<(String, String)> {
39    atoms
40        .iter()
41        .map(|a| {
42            (
43                a.get("id")
44                    .and_then(Value::as_str)
45                    .unwrap_or("")
46                    .to_string(),
47                a.get("ts")
48                    .and_then(Value::as_str)
49                    .unwrap_or("")
50                    .to_string(),
51            )
52        })
53        .collect()
54}
55
56/// What a search runs over: the snapshot, the inverted index, and the tokens
57/// the index was built from, all shared.
58pub type SearchSet = (Arc<Vec<Record>>, Arc<Index>, Arc<Vec<Vec<String>>>);
59
60/// The key for one atom.
61#[must_use]
62pub fn atom_key(workspace: &str, id: &str) -> Vec<u8> {
63    let mut key = Vec::with_capacity(workspace.len() + id.len() + 1);
64    key.extend_from_slice(workspace.as_bytes());
65    key.push(0);
66    key.extend_from_slice(id.as_bytes());
67    key
68}
69
70/// The prefix every key in one workspace opens with.
71#[must_use]
72pub fn workspace_prefix(workspace: &str) -> Vec<u8> {
73    let mut key = Vec::with_capacity(workspace.len() + 1);
74    key.extend_from_slice(workspace.as_bytes());
75    key.push(0);
76    key
77}
78
79/// The atom database, plus the lock that makes it one writer.
80pub struct Store {
81    env: Env,
82    db: Database<Bytes, Bytes>,
83    /// Held open for as long as the store is: dropping it drops the lock.
84    _lock: File,
85    /// Bumped by every write, so a reader can tell a stale snapshot.
86    generation: AtomicU64,
87    /// One parsed live set per workspace, shared by concurrent readers.
88    live: RwLock<HashMap<String, Snapshot>>,
89    /// The inverted index over one workspace's live atoms, per generation,
90    /// kept beside the snapshot its ordinals index.
91    terms: RwLock<HashMap<String, Searchable>>,
92    /// One index build at a time: readers that find the cache stale wait for
93    /// the build in flight and take its result, rather than each building.
94    terms_build: Mutex<()>,
95}
96
97impl Store {
98    /// Open the database under `root`, taking the single-writer lock.
99    ///
100    /// # Errors
101    ///
102    /// Fails when the home cannot be created, when another process already
103    /// holds the lock, or when LMDB refuses the directory.
104    pub fn open(root: &Path) -> anyhow::Result<Self> {
105        fs::create_dir_all(root)?;
106        let lock = take_lock(&root.join("packsetd.lock"))?;
107        let db_path = root.join("memory.lmdb");
108        fs::create_dir_all(&db_path)?;
109        // SAFETY: LMDB maps the file; the contract is that no other process
110        // writes it, which the lock above is what enforces.
111        let env = unsafe {
112            EnvOpenOptions::new()
113                .map_size(MAP_SIZE)
114                .max_dbs(1)
115                .flags(EnvFlags::WRITE_MAP)
116                .open(&db_path)?
117        };
118        let mut wtxn = env.write_txn()?;
119        let db: Database<Bytes, Bytes> = env.create_database(&mut wtxn, None)?;
120        wtxn.commit()?;
121        Ok(Self {
122            env,
123            db,
124            _lock: lock,
125            generation: AtomicU64::new(0),
126            live: RwLock::new(HashMap::new()),
127            terms: RwLock::new(HashMap::new()),
128            terms_build: Mutex::new(()),
129        })
130    }
131
132    /// Every record in one workspace, or in all of them.
133    ///
134    /// # Errors
135    ///
136    /// Fails when the read transaction does.
137    pub fn scan(&self, workspace: Option<&str>) -> anyhow::Result<Vec<Record>> {
138        if workspace == Some("") {
139            return Ok(Vec::new());
140        }
141        let rtxn = self.env.read_txn()?;
142        let mut out = Vec::new();
143        match workspace {
144            Some(name) => {
145                let prefix = workspace_prefix(name);
146                for item in self.db.prefix_iter(&rtxn, &prefix)? {
147                    let (_, raw) = item?;
148                    push_record(&mut out, raw);
149                }
150            }
151            None => {
152                for item in self.db.iter(&rtxn)? {
153                    let (_, raw) = item?;
154                    push_record(&mut out, raw);
155                }
156            }
157        }
158        Ok(out)
159    }
160
161    /// Visit each record without collecting them. Status counts 30k expired
162    /// atoms this way instead of holding every JSON value at once.
163    ///
164    /// # Errors
165    ///
166    /// Fails when the read transaction does.
167    pub fn for_each(
168        &self,
169        workspace: Option<&str>,
170        mut visit: impl FnMut(&Record),
171    ) -> anyhow::Result<()> {
172        if workspace == Some("") {
173            return Ok(());
174        }
175        let rtxn = self.env.read_txn()?;
176        let mut each = |raw: &[u8]| {
177            if let Ok(Value::Object(record)) = serde_json::from_slice::<Value>(raw) {
178                visit(&record);
179            }
180        };
181        match workspace {
182            Some(name) => {
183                let prefix = workspace_prefix(name);
184                for item in self.db.prefix_iter(&rtxn, &prefix)? {
185                    let (_, raw) = item?;
186                    each(raw);
187                }
188            }
189            None => {
190                for item in self.db.iter(&rtxn)? {
191                    let (_, raw) = item?;
192                    each(raw);
193                }
194            }
195        }
196        Ok(())
197    }
198
199    /// One record by id, whatever its state.
200    ///
201    /// # Errors
202    ///
203    /// Fails when the read transaction does.
204    pub fn get(&self, workspace: &str, id: &str) -> anyhow::Result<Option<Record>> {
205        if workspace.is_empty() || id.is_empty() {
206            return Ok(None);
207        }
208        let rtxn = self.env.read_txn()?;
209        let raw = self.db.get(&rtxn, &atom_key(workspace, id))?;
210        Ok(raw.and_then(|bytes| {
211            serde_json::from_slice::<Value>(bytes)
212                .ok()
213                .and_then(|v| v.as_object().cloned())
214        }))
215    }
216
217    /// Write one record, replacing whatever shared its key.
218    ///
219    /// # Errors
220    ///
221    /// Fails when the record has no workspace or id, or when the write does.
222    pub fn upsert(&self, atom: &Record) -> anyhow::Result<()> {
223        self.upsert_many(std::slice::from_ref(atom))
224    }
225
226    /// Write several records in one transaction, so a link rewrite lands whole.
227    ///
228    /// # Errors
229    ///
230    /// Fails when a record has no workspace or id, or when the write does.
231    pub fn upsert_many(&self, atoms: &[Record]) -> anyhow::Result<()> {
232        let mut wtxn = self.env.write_txn()?;
233        for atom in atoms {
234            let mut payload = atom.clone();
235            if !matches!(payload.get("links"), Some(Value::Array(_))) {
236                payload.insert("links".into(), Value::Array(Vec::new()));
237            }
238            let workspace = payload
239                .get("workspace")
240                .and_then(Value::as_str)
241                .ok_or_else(|| anyhow::anyhow!("record has no workspace"))?;
242            let id = payload
243                .get("id")
244                .and_then(Value::as_str)
245                .ok_or_else(|| anyhow::anyhow!("record has no id"))?;
246            let key = atom_key(workspace, id);
247            let blob = serde_json::to_vec(&Value::Object(payload.clone()))?;
248            self.db.put(&mut wtxn, &key, &blob)?;
249        }
250        wtxn.commit()?;
251        // After the commit, never before: a reader that scans between a bump
252        // and its write would otherwise cache the older corpus as the newer.
253        let generation = self.generation.fetch_add(1, Ordering::AcqRel) + 1;
254        self.patch_live(atoms, generation);
255        Ok(())
256    }
257
258    /// Fold a committed write into the cached snapshot instead of dropping it.
259    /// Only cached workspaces are patched; the result equals a fresh scan.
260    fn patch_live(&self, written: &[Record], generation: u64) {
261        let now = packset_core::clock::utcnow();
262        let Ok(mut cache) = self.live.write() else {
263            return;
264        };
265        for (workspace, (seen, stored, shown, dangling)) in cache.iter_mut() {
266            // One behind is this write; anything else raced and the snapshot
267            // is not a base this write can be added to.
268            if *seen + 1 != generation {
269                continue;
270            }
271            for record in written {
272                if record.get("workspace").and_then(Value::as_str) != Some(workspace.as_str()) {
273                    continue;
274                }
275                let Some(id) = record.get("id").and_then(Value::as_str) else {
276                    continue;
277                };
278                let visible = shown_at(record, &now);
279                let at = stored
280                    .iter()
281                    .position(|a| a.get("id").and_then(Value::as_str) == Some(id));
282                match (at, visible) {
283                    (Some(i), true) => stored[i] = record.clone(),
284                    (Some(i), false) => {
285                        stored.remove(i);
286                    }
287                    (None, true) => stored.push(record.clone()),
288                    (None, false) => {}
289                }
290            }
291            *seen = generation;
292            patch_shown(shown, stored, written, &now, dangling);
293        }
294    }
295
296    /// The live and due records in one workspace, parsed once per write and
297    /// shared; readers take this over [`Store::current`].
298    ///
299    /// # Errors
300    ///
301    /// Fails when the scan does.
302    pub fn live(&self, workspace: &str) -> anyhow::Result<Arc<Vec<Record>>> {
303        self.live_versioned(workspace).map(|(shown, _)| shown)
304    }
305
306    /// [`Self::live`] with the generation the set belongs to, for a cache
307    /// keyed on it.
308    ///
309    /// # Errors
310    ///
311    /// Fails when the scan does.
312    pub fn live_versioned(&self, workspace: &str) -> anyhow::Result<(Arc<Vec<Record>>, u64)> {
313        let generation = self.generation.load(Ordering::Acquire);
314        if let Ok(cache) = self.live.read() {
315            if let Some((seen, _stored, shown, _dangling)) = cache.get(workspace) {
316                if *seen == generation {
317                    return Ok((Arc::clone(shown), generation));
318                }
319            }
320        }
321        // Built outside the write lock, so a slow parse does not hold up a
322        // reader whose own workspace is current.
323        let now = packset_core::clock::utcnow();
324        let stored: Vec<Record> = self
325            .scan(Some(workspace))?
326            .into_iter()
327            .filter(|atom| shown_at(atom, &now))
328            .collect();
329        let (shown, dangling) = shown_from(&stored);
330        let shared = Arc::new(shown);
331        // Cached only if nothing committed while the scan ran.
332        if self.generation.load(Ordering::Acquire) == generation {
333            if let Ok(mut cache) = self.live.write() {
334                cache.insert(
335                    workspace.to_string(),
336                    (generation, stored, Arc::clone(&shared), dangling),
337                );
338            }
339        }
340        Ok((shared, generation))
341    }
342
343    /// One workspace's live atoms and the index over them, as a matched pair;
344    /// cards are scored against the same corpus.
345    ///
346    /// A write patches the live set in place, so the cached set is usually
347    /// the new one with records rewritten at their positions and appended at
348    /// the end. The index follows the same way: a rewritten record is
349    /// re-indexed under its ordinal, an appended one is pushed, and only a
350    /// set whose order moved is rebuilt from scratch.
351    ///
352    /// # Errors
353    ///
354    /// Fails when the scan does.
355    pub fn searchable(&self, workspace: &str) -> anyhow::Result<SearchSet> {
356        if let Some(found) = self.searchable_cached(workspace)? {
357            return Ok(found);
358        }
359        // One build at a time; a reader that waited looks again first.
360        let _build = self.terms_build.lock().unwrap_or_else(|e| e.into_inner());
361        if let Some(found) = self.searchable_cached(workspace)? {
362            return Ok(found);
363        }
364        let (atoms, generation) = self.live_versioned(workspace)?;
365        // The stale set is taken out of the cache, so this thread holds its
366        // only reference and the index is edited rather than copied.
367        let previous = self
368            .terms
369            .write()
370            .ok()
371            .and_then(|mut cache| cache.remove(workspace))
372            .map(|(_, set)| set);
373        let (index, documents) = match previous {
374            Some((old_atoms, mut index, mut documents))
375                if old_atoms.len() <= atoms.len()
376                    && old_atoms.iter().zip(atoms.iter()).all(|((id, _), b)| {
377                        Some(id.as_str()) == b.get("id").and_then(Value::as_str)
378                    }) =>
379            {
380                let idx = Arc::make_mut(&mut index);
381                let docs = Arc::make_mut(&mut documents);
382                for (ordinal, ((_, old_ts), new)) in old_atoms.iter().zip(atoms.iter()).enumerate()
383                {
384                    if Some(old_ts.as_str()) != new.get("ts").and_then(Value::as_str) {
385                        let tokens = packset_core::search::atom_tokens(new);
386                        idx.replace(ordinal, &docs[ordinal], &tokens);
387                        docs[ordinal] = tokens;
388                    }
389                }
390                for atom in &atoms[old_atoms.len()..] {
391                    let tokens = packset_core::search::atom_tokens(atom);
392                    idx.push(&tokens);
393                    docs.push(tokens);
394                }
395                (index, documents)
396            }
397            _ => {
398                let documents: Vec<Vec<String>> = atoms
399                    .iter()
400                    .map(packset_core::search::atom_tokens)
401                    .collect();
402                let index = Index::build(documents.iter().map(Vec::as_slice));
403                (Arc::new(index), Arc::new(documents))
404            }
405        };
406        // Cached under the generation the set was read at: a reader at a
407        // later generation patches from it rather than rebuilding.
408        if let Ok(mut cache) = self.terms.write() {
409            cache.insert(
410                workspace.to_string(),
411                (
412                    generation,
413                    (
414                        fingerprint(&atoms),
415                        Arc::clone(&index),
416                        Arc::clone(&documents),
417                    ),
418                ),
419            );
420        }
421        Ok((atoms, index, documents))
422    }
423
424    /// The cached index when it matches the live set's generation.
425    fn searchable_cached(&self, workspace: &str) -> anyhow::Result<Option<SearchSet>> {
426        let (atoms, generation) = self.live_versioned(workspace)?;
427        if let Ok(cache) = self.terms.read() {
428            if let Some((seen, (_, index, documents))) = cache.get(workspace) {
429                if *seen == generation && atoms.len() == index.len() {
430                    return Ok(Some((atoms, Arc::clone(index), Arc::clone(documents))));
431                }
432            }
433        }
434        Ok(None)
435    }
436
437    /// The atoms that were live at `at`, from a store scan since the snapshot
438    /// drops closed windows.
439    ///
440    /// # Errors
441    ///
442    /// Fails when the scan does, or when `at` is not a timestamp.
443    pub fn as_of(&self, workspace: &str, at: &str) -> anyhow::Result<Vec<Record>> {
444        let at = packset_core::clock::canonicalize(at)
445            .ok_or_else(|| anyhow::anyhow!("as_of must be a timestamp"))?;
446        let stored: Vec<Record> = self
447            .scan(Some(workspace))?
448            .into_iter()
449            .filter(|atom| record::is_live_at(atom, &at))
450            .collect();
451        Ok(shown_from(&stored).0)
452    }
453
454    /// The live and due records in one workspace, as a copy the caller owns.
455    ///
456    /// # Errors
457    ///
458    /// Fails when the scan does.
459    pub fn current(&self, workspace: &str, set: Option<&str>) -> anyhow::Result<Vec<Record>> {
460        let live = self.live(workspace)?;
461        Ok(match set {
462            None => live.as_ref().clone(),
463            Some(name) => live
464                .iter()
465                .filter(|atom| atom.get("set").and_then(Value::as_str) == Some(name))
466                .cloned()
467                .collect(),
468        })
469    }
470
471    /// Distinct workspace names with their live counts. `global` is always in.
472    ///
473    /// # Errors
474    ///
475    /// Fails when the scan does.
476    pub fn workspaces(&self) -> anyhow::Result<Vec<(String, usize)>> {
477        let now = packset_core::clock::utcnow();
478        let mut counts: std::collections::BTreeMap<String, usize> =
479            std::collections::BTreeMap::new();
480        for atom in self.scan(None)? {
481            let Some(name) = atom.get("workspace").and_then(Value::as_str) else {
482                continue;
483            };
484            if name.is_empty() {
485                continue;
486            }
487            let slot = counts.entry(name.to_string()).or_insert(0);
488            if record::is_live(&atom, &now) {
489                *slot += 1;
490            }
491        }
492        counts.entry("global".into()).or_insert(0);
493        Ok(counts.into_iter().collect())
494    }
495
496    /// Tombstone one live record, optionally naming the deed that withdrew it.
497    ///
498    /// The whole record is carried onto the tombstone, so `why` lands beside
499    /// the text it retracts and a bitemporal read gets both at once.
500    ///
501    /// # Errors
502    ///
503    /// [`AtomError`] when the id is not in the current set, else the write's.
504    pub fn delete(&self, workspace: &str, id: &str, why: Option<&str>) -> anyhow::Result<Record> {
505        let mut tomb = self
506            .current(workspace, None)?
507            .into_iter()
508            .find(|atom| atom.get("id").and_then(Value::as_str) == Some(id))
509            .ok_or_else(|| anyhow::Error::new(AtomError(format!("no current atom {id}"))))?;
510        tomb.insert("tombstone".into(), Value::Bool(true));
511        tomb.insert("ts".into(), Value::String(packset_core::clock::utcnow()));
512        if let Some(accession) = why {
513            tomb.insert("retracted_by".into(), Value::String(accession.to_string()));
514        }
515        self.upsert(&tomb)?;
516        Ok(tomb)
517    }
518}
519
520/// The live set as a reader sees it: links narrowed to the ids present.
521/// Whether the live set shows a record: live, or on the review clock; a
522/// tombstone is neither, whatever `due_at` it kept from before it was
523/// forgotten.
524fn shown_at(atom: &Record, now: &str) -> bool {
525    !atom
526        .get("tombstone")
527        .and_then(Value::as_bool)
528        .unwrap_or(false)
529        && (record::is_live(atom, now) || record::is_due(atom, now))
530}
531
532/// The shown copy of a stored set, links cut to live ids, and the ids the
533/// cuts named: the targets a later arrival may restore.
534fn shown_from(stored: &[Record]) -> (Vec<Record>, HashSet<String>) {
535    let live: HashSet<&str> = stored
536        .iter()
537        .filter_map(|a| a.get("id").and_then(Value::as_str))
538        .collect();
539    let mut dangling = HashSet::new();
540    let mut shown = stored.to_vec();
541    for atom in &mut shown {
542        dangling.extend(cut_links(atom, &live));
543    }
544    (shown, dangling)
545}
546
547/// Fold one write into the shown copy without rebuilding it: the written
548/// records are replaced, removed or appended with their links cut to live
549/// ids. A record whose link was cut because its target was absent is
550/// re-derived when that target arrives, and every record naming a departed
551/// id is re-derived when it departs; `dangling` is the set of ids that cut
552/// links name, so an arrival nobody named costs no scan. Equal to
553/// `shown_from(stored)`; a shown copy another reader still holds is cloned
554/// once by `Arc::make_mut`, an unshared one is edited in place.
555fn patch_shown(
556    shown: &mut Arc<Vec<Record>>,
557    stored: &[Record],
558    written: &[Record],
559    now: &str,
560    dangling: &mut HashSet<String>,
561) {
562    let live: HashSet<&str> = stored
563        .iter()
564        .filter_map(|a| a.get("id").and_then(Value::as_str))
565        .collect();
566    let out = Arc::make_mut(shown);
567    let mut moved: Vec<String> = Vec::new();
568    for record in written {
569        let Some(id) = record.get("id").and_then(Value::as_str) else {
570            continue;
571        };
572        let at = out
573            .iter()
574            .position(|a| a.get("id").and_then(Value::as_str) == Some(id));
575        if live.contains(id) && shown_at(record, now) {
576            let mut copy = record.clone();
577            dangling.extend(cut_links(&mut copy, &live));
578            match at {
579                Some(i) => out[i] = copy,
580                None => {
581                    out.push(copy);
582                    if dangling.remove(id) {
583                        moved.push(id.to_string());
584                    }
585                }
586            }
587        } else {
588            if let Some(i) = at {
589                out.remove(i);
590            }
591            dangling.insert(id.to_string());
592            moved.push(id.to_string());
593        }
594    }
595    if moved.is_empty() {
596        return;
597    }
598    for atom in stored {
599        let names_moved = atom
600            .get("links")
601            .and_then(Value::as_array)
602            .is_some_and(|items| {
603                items
604                    .iter()
605                    .any(|item| moved.contains(&record::value_text(item)))
606            });
607        if !names_moved {
608            continue;
609        }
610        let Some(id) = atom.get("id").and_then(Value::as_str) else {
611            continue;
612        };
613        if let Some(i) = out
614            .iter()
615            .position(|a| a.get("id").and_then(Value::as_str) == Some(id))
616        {
617            let mut copy = atom.clone();
618            dangling.extend(cut_links(&mut copy, &live));
619            out[i] = copy;
620        }
621    }
622}
623
624/// Keep only the links that name a live id; the ids of the links cut are
625/// returned, since an arrival of one of them restores the link.
626fn cut_links(atom: &mut Record, live: &HashSet<&str>) -> Vec<String> {
627    let mut cut = Vec::new();
628    let kept: Vec<Value> = atom
629        .get("links")
630        .and_then(Value::as_array)
631        .map(|items| {
632            items
633                .iter()
634                .filter(|item| {
635                    let id = record::value_text(item);
636                    if live.contains(id.as_str()) {
637                        true
638                    } else {
639                        cut.push(id);
640                        false
641                    }
642                })
643                .cloned()
644                .collect()
645        })
646        .unwrap_or_default();
647    atom.insert("links".into(), Value::Array(kept));
648    cut
649}
650
651fn push_record(out: &mut Vec<Record>, raw: &[u8]) {
652    if let Ok(Value::Object(map)) = serde_json::from_slice::<Value>(raw) {
653        out.push(map);
654    }
655}
656
657/// Take the exclusive lock, or say who has it. One writer per `memory.lmdb`.
658fn take_lock(path: &Path) -> anyhow::Result<File> {
659    use std::os::fd::AsRawFd;
660    let file = fs::OpenOptions::new()
661        .create(true)
662        .append(true)
663        .open(path)?;
664    // SAFETY: a libc call on a fd this function owns.
665    let taken = unsafe { flock(file.as_raw_fd(), LOCK_EX | LOCK_NB) };
666    if taken != 0 {
667        anyhow::bail!("store home is already open");
668    }
669    Ok(file)
670}
671
672const LOCK_EX: i32 = 2;
673const LOCK_NB: i32 = 4;
674
675extern "C" {
676    fn flock(fd: i32, operation: i32) -> i32;
677}
678
679#[cfg(test)]
680mod tests {
681    use super::*;
682    use serde_json::json;
683
684    fn record(value: Value) -> Record {
685        value.as_object().unwrap().clone()
686    }
687
688    fn store() -> (tempfile::TempDir, Store) {
689        let dir = tempfile::tempdir().unwrap();
690        let store = Store::open(dir.path()).unwrap();
691        (dir, store)
692    }
693
694    #[test]
695    fn the_key_separates_on_a_nul() {
696        assert_eq!(atom_key("w", "a"), b"w\0a".to_vec());
697        assert_eq!(workspace_prefix("w"), b"w\0".to_vec());
698        // A workspace whose name is a prefix of another must not leak into it,
699        // which is what the separator buys.
700        assert!(!atom_key("wide", "a").starts_with(&workspace_prefix("w")));
701    }
702
703    #[test]
704    fn a_record_round_trips() {
705        let (_dir, store) = store();
706        let atom = record(json!({
707            "id": "one", "workspace": "w", "kind": "voice",
708            "text": "A claim.", "links": ["two"], "unmodelled": {"x": 1}
709        }));
710        store.upsert(&atom).unwrap();
711        let back = store.get("w", "one").unwrap().unwrap();
712        assert_eq!(back["text"], json!("A claim."));
713        assert_eq!(back["links"], json!(["two"]));
714        assert_eq!(back["unmodelled"], json!({"x": 1}), "fields survive");
715    }
716
717    #[test]
718    fn a_scan_is_scoped_to_one_workspace() {
719        let (_dir, store) = store();
720        for (ws, id) in [("w", "a"), ("w", "b"), ("wide", "c")] {
721            store
722                .upsert(&record(json!({"id": id, "workspace": ws, "text": id})))
723                .unwrap();
724        }
725        let mine = store.scan(Some("w")).unwrap();
726        assert_eq!(mine.len(), 2, "{mine:?}");
727        assert_eq!(store.scan(Some("wide")).unwrap().len(), 1);
728        assert_eq!(store.scan(None).unwrap().len(), 3);
729        assert!(store.scan(Some("")).unwrap().is_empty());
730    }
731
732    #[test]
733    fn current_drops_the_expired_and_keeps_the_due() {
734        let (_dir, store) = store();
735        store
736            .upsert(&record(
737                json!({"id": "live", "workspace": "w", "text": "a"}),
738            ))
739            .unwrap();
740        store
741            .upsert(&record(json!({
742                "id": "gone", "workspace": "w", "text": "b",
743                "valid_to": "2000-01-01T00:00:00.000Z"
744            })))
745            .unwrap();
746        // Expired for the live set but still on the review clock, which is a
747        // different question and keeps it in reach.
748        store
749            .upsert(&record(json!({
750                "id": "due", "workspace": "w", "text": "c",
751                "valid_to": "2000-01-01T00:00:00.000Z",
752                "due_at": "2000-01-01T00:00:00.000Z"
753            })))
754            .unwrap();
755        let ids: Vec<String> = store
756            .current("w", None)
757            .unwrap()
758            .iter()
759            .map(|a| a["id"].as_str().unwrap().to_string())
760            .collect();
761        assert!(ids.contains(&"live".to_string()), "{ids:?}");
762        assert!(ids.contains(&"due".to_string()), "{ids:?}");
763        assert!(!ids.contains(&"gone".to_string()), "{ids:?}");
764    }
765
766    #[test]
767    fn as_of_returns_what_was_live_then() {
768        let (_dir, store) = store();
769        store
770            .upsert(&record(json!({
771                "id": "then", "workspace": "w", "text": "old claim",
772                "valid_from": "2024-01-01T00:00:00.000Z",
773                "valid_to": "2024-12-01T00:00:00.000Z"
774            })))
775            .unwrap();
776        store
777            .upsert(&record(json!({
778                "id": "now", "workspace": "w", "text": "new claim",
779                "valid_from": "2024-12-01T00:00:00.000Z"
780            })))
781            .unwrap();
782        store
783            .upsert(&record(json!({
784                "id": "tomb", "workspace": "w", "text": "deleted",
785                "valid_from": "2024-01-01T00:00:00.000Z",
786                "tombstone": true
787            })))
788            .unwrap();
789        let mid = store.as_of("w", "2024-06-01T00:00:00.000Z").unwrap();
790        let mid_ids: Vec<&str> = mid.iter().filter_map(|a| a["id"].as_str()).collect();
791        assert_eq!(mid_ids, vec!["then"], "{mid:?}");
792        let offset = store.as_of("w", "2024-06-01T00:00:00+00:00").unwrap();
793        let offset_ids: Vec<&str> = offset.iter().filter_map(|a| a["id"].as_str()).collect();
794        assert_eq!(offset_ids, mid_ids, "offset and Z as_of agree");
795        let today = store.as_of("w", "2025-06-01T00:00:00.000Z").unwrap();
796        let today_ids: Vec<&str> = today.iter().filter_map(|a| a["id"].as_str()).collect();
797        assert_eq!(today_ids, vec!["now"], "{today:?}");
798        assert!(
799            store
800                .current("w", None)
801                .unwrap()
802                .iter()
803                .all(|a| a["id"] != json!("then")),
804            "live-now still drops the closed window"
805        );
806    }
807
808    #[test]
809    fn current_narrows_links_to_what_it_returned() {
810        let (_dir, store) = store();
811        store
812            .upsert(&record(json!({
813                "id": "a", "workspace": "w", "text": "a", "links": ["b", "gone"]
814            })))
815            .unwrap();
816        store
817            .upsert(&record(json!({"id": "b", "workspace": "w", "text": "b"})))
818            .unwrap();
819        let live = store.current("w", None).unwrap();
820        let a = live.iter().find(|x| x["id"] == json!("a")).unwrap();
821        assert_eq!(a["links"], json!(["b"]), "a dangling link is not returned");
822    }
823
824    #[test]
825    fn a_set_scope_filters_the_current_view() {
826        let (_dir, store) = store();
827        store
828            .upsert(&record(
829                json!({"id": "a", "workspace": "w", "text": "a", "set": "review"}),
830            ))
831            .unwrap();
832        store
833            .upsert(&record(json!({"id": "b", "workspace": "w", "text": "b"})))
834            .unwrap();
835        assert_eq!(store.current("w", Some("review")).unwrap().len(), 1);
836        assert_eq!(store.current("w", None).unwrap().len(), 2);
837    }
838
839    #[test]
840    fn workspaces_count_the_live_and_always_name_global() {
841        let (_dir, store) = store();
842        store
843            .upsert(&record(json!({"id": "a", "workspace": "w", "text": "a"})))
844            .unwrap();
845        store
846            .upsert(&record(json!({
847                "id": "b", "workspace": "w", "text": "b", "tombstone": true
848            })))
849            .unwrap();
850        let found = store.workspaces().unwrap();
851        assert!(found.contains(&("w".to_string(), 1)), "{found:?}");
852        assert!(
853            found.iter().any(|(name, _)| name == "global"),
854            "an empty seat still has somewhere to write: {found:?}"
855        );
856    }
857
858    #[test]
859    fn deleting_leaves_a_tombstone_rather_than_a_hole() {
860        let (_dir, store) = store();
861        store
862            .upsert(&record(json!({"id": "a", "workspace": "w", "text": "a"})))
863            .unwrap();
864        let tomb = store.delete("w", "a", None).unwrap();
865        assert_eq!(tomb["tombstone"], json!(true));
866        // The record is still there to be read; it has left the live set.
867        assert!(store.get("w", "a").unwrap().is_some());
868        assert!(store.current("w", None).unwrap().is_empty());
869        assert!(
870            store.delete("w", "a", None).is_err(),
871            "twice is not current"
872        );
873    }
874
875    #[test]
876    fn a_retraction_carries_its_deed_onto_the_tombstone() {
877        let (_dir, store) = store();
878        store
879            .upsert(&record(
880                json!({"id": "a", "workspace": "w", "text": "the claim"}),
881            ))
882            .unwrap();
883        let tomb = store.delete("w", "a", Some("deed-patch-overlay")).unwrap();
884        assert_eq!(tomb["retracted_by"], json!("deed-patch-overlay"));
885        // Both halves read back together: what was withdrawn, and on what.
886        assert_eq!(tomb["text"], json!("the claim"));
887        let stored = store.get("w", "a").unwrap().unwrap();
888        assert_eq!(stored["retracted_by"], json!("deed-patch-overlay"));
889    }
890
891    #[test]
892    fn a_second_writer_is_refused_the_home() {
893        let dir = tempfile::tempdir().unwrap();
894        let _first = Store::open(dir.path()).unwrap();
895        let second = Store::open(dir.path());
896        assert!(second.is_err(), "one writer is the whole design");
897    }
898}
899
900#[cfg(test)]
901mod snapshot_tests {
902    use super::*;
903    use serde_json::json;
904
905    fn record(value: Value) -> Record {
906        value.as_object().unwrap().clone()
907    }
908
909    fn store() -> (tempfile::TempDir, Store) {
910        let dir = tempfile::tempdir().unwrap();
911        let store = Store::open(dir.path()).unwrap();
912        (dir, store)
913    }
914
915    #[test]
916    fn a_write_is_visible_to_the_next_read() {
917        let (_dir, store) = store();
918        assert!(store.live("w").unwrap().is_empty());
919        store
920            .upsert(&record(json!({"id": "a", "workspace": "w", "text": "a"})))
921            .unwrap();
922        assert_eq!(store.live("w").unwrap().len(), 1, "the snapshot went stale");
923        store
924            .upsert(&record(json!({"id": "b", "workspace": "w", "text": "b"})))
925            .unwrap();
926        assert_eq!(store.live("w").unwrap().len(), 2);
927    }
928
929    #[test]
930    fn a_repeated_read_hands_back_the_same_snapshot() {
931        let (_dir, store) = store();
932        store
933            .upsert(&record(json!({"id": "a", "workspace": "w", "text": "a"})))
934            .unwrap();
935        let first = store.live("w").unwrap();
936        let second = store.live("w").unwrap();
937        assert!(
938            Arc::ptr_eq(&first, &second),
939            "two readers should share one parse"
940        );
941        store
942            .upsert(&record(json!({"id": "b", "workspace": "w", "text": "b"})))
943            .unwrap();
944        let third = store.live("w").unwrap();
945        assert!(!Arc::ptr_eq(&first, &third), "a write invalidates it");
946    }
947
948    #[test]
949    fn a_tombstone_leaves_the_snapshot() {
950        let (_dir, store) = store();
951        store
952            .upsert(&record(json!({"id": "a", "workspace": "w", "text": "a"})))
953            .unwrap();
954        assert_eq!(store.live("w").unwrap().len(), 1);
955        store.delete("w", "a", None).unwrap();
956        assert!(
957            store.live("w").unwrap().is_empty(),
958            "a delete must invalidate too"
959        );
960    }
961
962    #[test]
963    fn one_workspace_write_does_not_serve_another_stale() {
964        let (_dir, store) = store();
965        store
966            .upsert(&record(json!({"id": "a", "workspace": "one", "text": "a"})))
967            .unwrap();
968        assert_eq!(store.live("one").unwrap().len(), 1);
969        assert!(store.live("two").unwrap().is_empty());
970        store
971            .upsert(&record(json!({"id": "b", "workspace": "two", "text": "b"})))
972            .unwrap();
973        assert_eq!(store.live("two").unwrap().len(), 1);
974        assert_eq!(store.live("one").unwrap().len(), 1, "still correct");
975    }
976
977    #[test]
978    fn the_index_follows_appends_rewrites_and_removals() {
979        let (_dir, store) = store();
980        for (id, text) in [
981            ("a", "alpha beta"),
982            ("b", "beta gamma"),
983            ("c", "gamma delta"),
984        ] {
985            store
986                .upsert(&record(json!({"id": id, "workspace": "w", "text": text})))
987                .unwrap();
988            assert_index_matches_a_fresh_build(&store, "w");
989        }
990        // A rewrite keeps its ordinal; the terms it dropped leave the index.
991        store
992            .upsert(&record(json!({
993                "id": "b", "workspace": "w", "text": "epsilon zeta", "ts": "2030-01-01T00:00:00.000Z"
994            })))
995            .unwrap();
996        assert_index_matches_a_fresh_build(&store, "w");
997        // A removal moves the order, so the set is rebuilt.
998        store.delete("w", "a", None).unwrap();
999        assert_index_matches_a_fresh_build(&store, "w");
1000        store
1001            .upsert(&record(
1002                json!({"id": "d", "workspace": "w", "text": "delta eta"}),
1003            ))
1004            .unwrap();
1005        assert_index_matches_a_fresh_build(&store, "w");
1006    }
1007
1008    /// The index served after a write scores every term as an index built
1009    /// from scratch over the same atoms would.
1010    fn assert_index_matches_a_fresh_build(store: &Store, workspace: &str) {
1011        let (atoms, index, documents) = store.searchable(workspace).unwrap();
1012        let fresh_docs: Vec<Vec<String>> = atoms
1013            .iter()
1014            .map(packset_core::search::atom_tokens)
1015            .collect();
1016        assert_eq!(*documents, fresh_docs, "the cached tokens drifted");
1017        let fresh = Index::build(fresh_docs.iter().map(Vec::as_slice));
1018        assert_eq!(index.len(), fresh.len());
1019        assert!((index.average_length() - fresh.average_length()).abs() < 1e-9);
1020        for term in fresh_docs.iter().flatten().chain(
1021            ["alpha", "beta", "gamma", "zeta"]
1022                .iter()
1023                .map(|t| t.to_string())
1024                .collect::<Vec<_>>()
1025                .iter(),
1026        ) {
1027            assert!(
1028                (index.idf(term) - fresh.idf(term)).abs() < 1e-9,
1029                "idf of {term} drifted: {} vs {}",
1030                index.idf(term),
1031                fresh.idf(term)
1032            );
1033            assert_eq!(
1034                index.occurrences_of(term),
1035                fresh.occurrences_of(term),
1036                "occurrences of {term} drifted"
1037            );
1038        }
1039    }
1040
1041    /// The whole safety argument for patching: whatever the snapshot says
1042    /// after a write has to be what a scan of the database would say.
1043    fn assert_matches_a_fresh_scan(store: &Store, workspace: &str) {
1044        let patched: Vec<Value> = store
1045            .live(workspace)
1046            .unwrap()
1047            .iter()
1048            .map(|a| Value::Object(a.clone()))
1049            .collect();
1050        // Force the next read to derive from the database rather than the
1051        // cache, and compare what comes back.
1052        store.live.write().unwrap().clear();
1053        let fresh: Vec<Value> = store
1054            .live(workspace)
1055            .unwrap()
1056            .iter()
1057            .map(|a| Value::Object(a.clone()))
1058            .collect();
1059        assert_eq!(
1060            patched, fresh,
1061            "the patched snapshot drifted from the store"
1062        );
1063    }
1064
1065    #[test]
1066    fn a_patched_snapshot_says_what_a_fresh_scan_says() {
1067        let (_dir, store) = store();
1068        // Read first, so there is a cached snapshot for the writes to fold
1069        // into rather than nothing to patch.
1070        assert!(store.live("w").unwrap().is_empty());
1071
1072        store
1073            .upsert(&record(json!({"id": "a", "workspace": "w", "text": "a"})))
1074            .unwrap();
1075        assert_matches_a_fresh_scan(&store, "w");
1076
1077        // An update in place.
1078        store
1079            .upsert(&record(
1080                json!({"id": "a", "workspace": "w", "text": "changed"}),
1081            ))
1082            .unwrap();
1083        assert_eq!(store.live("w").unwrap()[0]["text"], json!("changed"));
1084        assert_matches_a_fresh_scan(&store, "w");
1085
1086        // A record that leaves the live set has to leave the snapshot.
1087        store
1088            .upsert(&record(json!({
1089                "id": "a", "workspace": "w", "text": "changed",
1090                "valid_to": "2000-01-01T00:00:00.000Z"
1091            })))
1092            .unwrap();
1093        assert!(store.live("w").unwrap().is_empty());
1094        assert_matches_a_fresh_scan(&store, "w");
1095
1096        // And one that is expired but still due stays, because the review
1097        // clock is a separate question.
1098        store
1099            .upsert(&record(json!({
1100                "id": "b", "workspace": "w", "text": "b",
1101                "valid_to": "2000-01-01T00:00:00.000Z",
1102                "due_at": "2000-01-01T00:00:00.000Z"
1103            })))
1104            .unwrap();
1105        assert_eq!(store.live("w").unwrap().len(), 1);
1106        assert_matches_a_fresh_scan(&store, "w");
1107    }
1108
1109    #[test]
1110    fn a_patch_narrows_links_the_way_a_scan_does() {
1111        let (_dir, store) = store();
1112        assert!(store.live("w").unwrap().is_empty());
1113        store
1114            .upsert(&record(json!({
1115                "id": "a", "workspace": "w", "text": "a", "links": ["b"]
1116            })))
1117            .unwrap();
1118        // `b` does not exist, so the link must not be reported.
1119        assert_eq!(store.live("w").unwrap()[0]["links"], json!([]));
1120        assert_matches_a_fresh_scan(&store, "w");
1121
1122        store
1123            .upsert(&record(json!({"id": "b", "workspace": "w", "text": "b"})))
1124            .unwrap();
1125        assert_matches_a_fresh_scan(&store, "w");
1126    }
1127
1128    #[test]
1129    fn a_write_to_one_workspace_leaves_another_alone() {
1130        let (_dir, store) = store();
1131        store
1132            .upsert(&record(json!({"id": "a", "workspace": "one", "text": "a"})))
1133            .unwrap();
1134        assert_eq!(store.live("one").unwrap().len(), 1);
1135        assert!(store.live("two").unwrap().is_empty());
1136        store
1137            .upsert(&record(json!({"id": "b", "workspace": "two", "text": "b"})))
1138            .unwrap();
1139        assert_matches_a_fresh_scan(&store, "one");
1140        assert_matches_a_fresh_scan(&store, "two");
1141    }
1142
1143    #[test]
1144    fn a_delete_is_visible_and_matches_a_scan() {
1145        let (_dir, store) = store();
1146        store
1147            .upsert(&record(json!({"id": "a", "workspace": "w", "text": "a"})))
1148            .unwrap();
1149        assert_eq!(store.live("w").unwrap().len(), 1);
1150        store.delete("w", "a", None).unwrap();
1151        assert!(store.live("w").unwrap().is_empty());
1152        assert_matches_a_fresh_scan(&store, "w");
1153    }
1154
1155    #[test]
1156    fn readers_racing_a_writer_never_see_a_snapshot_that_skips_a_write() {
1157        // Generation read before the scan, bumped before the write: a snapshot
1158        // built across a write is stale, never mislabelled.
1159        let (dir, store) = store();
1160        let store = Arc::new(store);
1161        let _ = dir;
1162        let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
1163
1164        let writer = {
1165            let store = Arc::clone(&store);
1166            let stop = Arc::clone(&stop);
1167            std::thread::spawn(move || {
1168                for i in 0..200 {
1169                    store
1170                        .upsert(&record(json!({
1171                            "id": format!("a{i}"), "workspace": "w", "text": "x"
1172                        })))
1173                        .unwrap();
1174                }
1175                stop.store(true, std::sync::atomic::Ordering::Release);
1176            })
1177        };
1178
1179        let readers: Vec<_> = (0..4)
1180            .map(|_| {
1181                let store = Arc::clone(&store);
1182                let stop = Arc::clone(&stop);
1183                std::thread::spawn(move || {
1184                    let mut high = 0usize;
1185                    while !stop.load(std::sync::atomic::Ordering::Acquire) {
1186                        let seen = store.live("w").unwrap().len();
1187                        assert!(seen >= high, "went backwards: {seen} after {high}");
1188                        high = seen;
1189                    }
1190                })
1191            })
1192            .collect();
1193
1194        writer.join().unwrap();
1195        for reader in readers {
1196            reader.join().unwrap();
1197        }
1198        assert_eq!(store.live("w").unwrap().len(), 200, "every write landed");
1199    }
1200}