1use 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
17pub const MAP_SIZE: usize = 1024 * 1024 * 1024;
20
21pub type Record = Map<String, Value>;
23
24type Snapshot = (u64, Vec<Record>, Arc<Vec<Record>>, HashSet<String>);
27
28type Searchable = (
33 u64,
34 (Vec<(String, String)>, Arc<Index>, Arc<Vec<Vec<String>>>),
35);
36
37fn 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
56pub type SearchSet = (Arc<Vec<Record>>, Arc<Index>, Arc<Vec<Vec<String>>>);
59
60#[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#[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
79pub struct Store {
81 env: Env,
82 db: Database<Bytes, Bytes>,
83 _lock: File,
85 generation: AtomicU64,
87 live: RwLock<HashMap<String, Snapshot>>,
89 terms: RwLock<HashMap<String, Searchable>>,
92 terms_build: Mutex<()>,
95}
96
97impl Store {
98 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 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 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 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 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 pub fn upsert(&self, atom: &Record) -> anyhow::Result<()> {
223 self.upsert_many(std::slice::from_ref(atom))
224 }
225
226 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 let generation = self.generation.fetch_add(1, Ordering::AcqRel) + 1;
254 self.patch_live(atoms, generation);
255 Ok(())
256 }
257
258 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 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 pub fn live(&self, workspace: &str) -> anyhow::Result<Arc<Vec<Record>>> {
303 self.live_versioned(workspace).map(|(shown, _)| shown)
304 }
305
306 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 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 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 pub fn searchable(&self, workspace: &str) -> anyhow::Result<SearchSet> {
356 if let Some(found) = self.searchable_cached(workspace)? {
357 return Ok(found);
358 }
359 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 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 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 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 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 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 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 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
520fn 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
532fn 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
547fn 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
624fn 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
657fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}