1use 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
17pub const MAP_SIZE: usize = 1024 * 1024 * 1024;
20
21pub type Record = Map<String, Value>;
23
24type Snapshot = (u64, Vec<Record>, Arc<Vec<Record>>);
27
28type Searchable = (u64, SearchSet);
30
31pub type SearchSet = (Arc<Vec<Record>>, Arc<Index>, Arc<Vec<Vec<String>>>);
34
35#[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#[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
54pub struct Store {
56 env: Env,
57 db: Database<Bytes, Bytes>,
58 _lock: File,
60 generation: AtomicU64,
62 live: RwLock<HashMap<String, Snapshot>>,
64 terms: RwLock<HashMap<String, Searchable>>,
67}
68
69impl Store {
70 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 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 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 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 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 pub fn upsert(&self, atom: &Record) -> anyhow::Result<()> {
194 self.upsert_many(std::slice::from_ref(atom))
195 }
196
197 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 let generation = self.generation.fetch_add(1, Ordering::AcqRel) + 1;
225 self.patch_live(atoms, generation);
226 Ok(())
227 }
228
229 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 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 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 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 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 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 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 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 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 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 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 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
453fn 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
466fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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}