1use chrono::{DateTime, Duration, Utc};
22use parking_lot::Mutex;
23use serde::{Deserialize, Serialize};
24use serde_json::Value;
25use std::collections::HashMap;
26use std::fs::{File, OpenOptions};
27use std::io::{BufRead, BufReader, BufWriter, Write};
28use std::path::{Path, PathBuf};
29
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
37pub struct StateTransition {
38 pub key: String,
39 pub old_value: Option<Value>,
40 pub new_value: Option<Value>,
41 pub action_id: String,
42 pub timestamp: DateTime<Utc>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub ttl_secs: Option<u64>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub version: Option<u64>,
53}
54
55pub struct StateStore {
62 state: Mutex<HashMap<String, Value>>,
63 transitions: Mutex<Vec<StateTransition>>,
64 versions: Mutex<HashMap<String, u64>>,
70 journal: Mutex<Option<Journal>>,
75}
76
77struct Journal {
78 path: PathBuf,
79 writer: BufWriter<File>,
80}
81
82impl StateStore {
83 pub fn new() -> Self {
84 Self {
85 state: Mutex::new(HashMap::new()),
86 transitions: Mutex::new(Vec::new()),
87 versions: Mutex::new(HashMap::new()),
88 journal: Mutex::new(None),
89 }
90 }
91
92 pub fn durable(path: impl Into<PathBuf>) -> std::io::Result<Self> {
103 let path = path.into();
104 if let Some(parent) = path.parent() {
105 if !parent.as_os_str().is_empty() {
106 std::fs::create_dir_all(parent)?;
107 }
108 }
109 let store = Self::new();
110 store.replay_journal(&path)?;
111 let file = OpenOptions::new().create(true).append(true).open(&path)?;
112 *store.journal.lock() = Some(Journal {
113 path,
114 writer: BufWriter::new(file),
115 });
116 Ok(store)
117 }
118
119 fn replay_journal(&self, path: &Path) -> std::io::Result<()> {
120 if !path.exists() {
121 return Ok(());
122 }
123 let file = File::open(path)?;
124 let reader = BufReader::new(file);
125 let now = Utc::now();
126 let mut state = self.state.lock();
127 let mut transitions = self.transitions.lock();
128 let mut versions = self.versions.lock();
129 for line in reader.lines() {
130 let line = match line {
131 Ok(l) if l.trim().is_empty() => continue,
132 Ok(l) => l,
133 Err(_) => continue,
134 };
135 let Ok(t) = serde_json::from_str::<StateTransition>(&line) else {
136 tracing::warn!(
138 journal = %path.display(),
139 "skipping malformed StateStore journal line"
140 );
141 continue;
142 };
143 if let (Some(ttl), Some(value)) = (t.ttl_secs, &t.new_value) {
147 if now.signed_duration_since(t.timestamp) > Duration::seconds(ttl as i64) {
148 state.remove(&t.key);
149 } else {
150 state.insert(t.key.clone(), value.clone());
151 }
152 } else if let Some(value) = &t.new_value {
153 state.insert(t.key.clone(), value.clone());
154 } else {
155 state.remove(&t.key);
156 }
157 let entry = versions.entry(t.key.clone()).or_insert(0);
162 let restored = t.version.unwrap_or(*entry + 1);
163 *entry = (*entry).max(restored);
164 transitions.push(t);
165 }
166 Ok(())
167 }
168
169 fn append_journal(&self, transition: &StateTransition) {
170 let mut journal = self.journal.lock();
171 let Some(journal) = journal.as_mut() else {
172 return;
173 };
174 let Ok(json) = serde_json::to_string(transition) else {
178 return;
179 };
180 if let Err(e) = writeln!(journal.writer, "{json}") {
181 tracing::warn!(
182 journal = %journal.path.display(),
183 error = %e,
184 "StateStore journal append failed"
185 );
186 return;
187 }
188 let _ = journal.writer.flush();
189 }
190
191 pub fn sync(&self) -> std::io::Result<()> {
194 let mut journal = self.journal.lock();
195 let Some(journal) = journal.as_mut() else {
196 return Ok(());
197 };
198 journal.writer.flush()?;
199 journal.writer.get_ref().sync_all()
200 }
201
202 pub fn reap_expired(&self, now: DateTime<Utc>) -> std::io::Result<Vec<String>> {
218 let mut state = self.state.lock();
219 let mut transitions = self.transitions.lock();
220 let mut latest_by_key: HashMap<&str, &StateTransition> = HashMap::new();
224 for t in transitions.iter() {
225 latest_by_key.insert(t.key.as_str(), t);
226 }
227 let expired: Vec<String> = latest_by_key
228 .iter()
229 .filter_map(|(_k, t)| {
230 let ttl = t.ttl_secs?;
231 if t.new_value.is_none() {
232 return None;
233 }
234 let age = now.signed_duration_since(t.timestamp);
235 (age > Duration::seconds(ttl as i64)).then(|| t.key.clone())
236 })
237 .collect();
238 let mut reaped = Vec::new();
239 for key in expired {
240 if state.remove(&key).is_some() {
241 let version = self.bump_version(&key);
245 reaped.push(key.clone());
246 transitions.push(StateTransition {
247 key,
248 old_value: None,
249 new_value: None,
250 action_id: "reap".to_string(),
251 timestamp: now,
252 ttl_secs: None,
253 version: Some(version),
254 });
255 }
256 }
257 drop(state);
258 drop(transitions);
259 if !reaped.is_empty() {
260 self.compact_journal()?;
261 }
262 Ok(reaped)
263 }
264
265 pub(crate) fn compact_journal(&self) -> std::io::Result<()> {
277 let mut journal = self.journal.lock();
278 let Some(j) = journal.as_mut() else {
279 return Ok(());
280 };
281 let state = self.state.lock().clone();
282 let versions = self.versions.lock().clone();
283 let tmp_path = j.path.with_extension("jsonl.tmp");
284 {
285 let tmp_file = File::create(&tmp_path)?;
286 let mut writer = BufWriter::new(tmp_file);
287 for (key, value) in &state {
288 let t = StateTransition {
289 key: key.clone(),
290 old_value: None,
291 new_value: Some(value.clone()),
292 action_id: "compact".to_string(),
293 timestamp: Utc::now(),
294 ttl_secs: None,
295 version: versions.get(key).copied(),
298 };
299 let line = serde_json::to_string(&t)?;
300 writeln!(writer, "{line}")?;
301 }
302 writer.flush()?;
303 writer.get_ref().sync_all()?;
304 }
305 std::fs::rename(&tmp_path, &j.path)?;
306 let file = OpenOptions::new().create(true).append(true).open(&j.path)?;
307 j.writer = BufWriter::new(file);
308 Ok(())
309 }
310
311 pub fn get(&self, key: &str) -> Option<Value> {
312 self.state.lock().get(key).cloned()
313 }
314
315 pub fn get_or(&self, key: &str, default: Value) -> Value {
316 self.state.lock().get(key).cloned().unwrap_or(default)
317 }
318
319 pub fn exists(&self, key: &str) -> bool {
320 self.state.lock().contains_key(key)
321 }
322
323 pub fn set(&self, key: &str, value: Value, action_id: &str) -> StateTransition {
324 self.set_inner(key, value, action_id, None)
325 }
326
327 pub fn set_with_ttl(
339 &self,
340 key: &str,
341 value: Value,
342 action_id: &str,
343 ttl_secs: u64,
344 ) -> StateTransition {
345 self.set_inner(key, value, action_id, Some(ttl_secs))
346 }
347
348 fn set_inner(
349 &self,
350 key: &str,
351 value: Value,
352 action_id: &str,
353 ttl_secs: Option<u64>,
354 ) -> StateTransition {
355 let mut state = self.state.lock();
356 let old = state.get(key).cloned();
357 state.insert(key.to_string(), value.clone());
358 let version = self.bump_version(key);
359
360 let t = StateTransition {
361 key: key.to_string(),
362 old_value: old,
363 new_value: Some(value),
364 action_id: action_id.to_string(),
365 timestamp: Utc::now(),
366 ttl_secs,
367 version: Some(version),
368 };
369
370 self.transitions.lock().push(t.clone());
371 self.append_journal(&t);
372 t
373 }
374
375 fn bump_version(&self, key: &str) -> u64 {
378 let mut versions = self.versions.lock();
379 let v = versions.entry(key.to_string()).or_insert(0);
380 *v += 1;
381 *v
382 }
383
384 pub fn version(&self, key: &str) -> Option<u64> {
388 self.versions.lock().get(key).copied()
389 }
390
391 pub fn versions(&self) -> HashMap<String, u64> {
394 self.versions.lock().clone()
395 }
396
397 pub fn versioned_snapshot(&self) -> (HashMap<String, Value>, HashMap<String, u64>) {
404 let state = self.state.lock();
405 let versions = self.versions.lock();
406 (state.clone(), versions.clone())
407 }
408
409 pub fn delete(&self, key: &str, action_id: &str) -> Option<StateTransition> {
410 let mut state = self.state.lock();
411 let old = state.remove(key)?;
412 let version = self.bump_version(key);
413
414 let t = StateTransition {
415 key: key.to_string(),
416 old_value: Some(old),
417 new_value: None,
418 action_id: action_id.to_string(),
419 timestamp: Utc::now(),
420 ttl_secs: None,
421 version: Some(version),
422 };
423
424 self.transitions.lock().push(t.clone());
425 self.append_journal(&t);
426 Some(t)
427 }
428
429 pub fn snapshot(&self) -> HashMap<String, Value> {
431 self.state.lock().clone()
432 }
433
434 pub fn restore(&self, snapshot: HashMap<String, Value>, transition_count: usize) {
436 *self.state.lock() = snapshot;
437 self.transitions.lock().truncate(transition_count);
438 }
439
440 pub fn transition_count(&self) -> usize {
441 self.transitions.lock().len()
442 }
443
444 pub fn transitions(&self) -> Vec<StateTransition> {
445 self.transitions.lock().clone()
446 }
447
448 pub fn transitions_since(&self, index: usize) -> Vec<StateTransition> {
449 let transitions = self.transitions.lock();
450 let start = index.min(transitions.len());
451 transitions[start..].to_vec()
452 }
453
454 pub fn keys(&self) -> Vec<String> {
455 self.state.lock().keys().cloned().collect()
456 }
457
458 pub fn replace_all(&self, snapshot: HashMap<String, Value>) {
463 *self.state.lock() = snapshot;
464 self.transitions.lock().clear();
465 }
466
467 pub fn scoped<'a>(&'a self, tenant: Option<&'a str>) -> ScopedStateView<'a> {
480 ScopedStateView {
481 store: self,
482 tenant,
483 }
484 }
485}
486
487pub struct ScopedStateView<'a> {
518 store: &'a StateStore,
519 tenant: Option<&'a str>,
520}
521
522impl<'a> ScopedStateView<'a> {
523 fn full_key(&self, key: &str) -> String {
524 match self.tenant {
525 Some(t) if !t.is_empty() => format!("tenant:{t}:{key}"),
526 _ => key.to_string(),
527 }
528 }
529
530 fn strip_prefix<'k>(&self, full: &'k str) -> Option<&'k str> {
531 match self.tenant {
532 Some(t) if !t.is_empty() => {
533 let prefix = format!("tenant:{t}:");
534 full.strip_prefix(&prefix)
535 }
536 _ => Some(full),
537 }
538 }
539
540 pub fn get(&self, key: &str) -> Option<Value> {
541 self.store.get(&self.full_key(key))
542 }
543
544 pub fn get_or(&self, key: &str, default: Value) -> Value {
545 self.store.get_or(&self.full_key(key), default)
546 }
547
548 pub fn exists(&self, key: &str) -> bool {
549 self.store.exists(&self.full_key(key))
550 }
551
552 pub fn set(&self, key: &str, value: Value, action_id: &str) -> StateTransition {
553 self.store.set(&self.full_key(key), value, action_id)
554 }
555
556 pub fn set_with_ttl(
557 &self,
558 key: &str,
559 value: Value,
560 action_id: &str,
561 ttl_secs: u64,
562 ) -> StateTransition {
563 self.store
564 .set_with_ttl(&self.full_key(key), value, action_id, ttl_secs)
565 }
566
567 pub fn delete(&self, key: &str, action_id: &str) -> Option<StateTransition> {
568 self.store.delete(&self.full_key(key), action_id)
569 }
570
571 pub fn keys(&self) -> Vec<String> {
577 self.store
578 .keys()
579 .into_iter()
580 .filter_map(|k| {
581 if self.tenant.map(|t| !t.is_empty()).unwrap_or(false) {
582 self.strip_prefix(&k).map(str::to_string)
583 } else if k.starts_with("tenant:") {
584 None
585 } else {
586 Some(k)
587 }
588 })
589 .collect()
590 }
591}
592
593impl Default for StateStore {
594 fn default() -> Self {
595 Self::new()
596 }
597}
598
599impl car_ir::precondition::StateView for StateStore {
600 fn get_value(&self, key: &str) -> Option<Value> {
601 self.get(key)
602 }
603 fn key_exists(&self, key: &str) -> bool {
604 self.exists(key)
605 }
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611 use serde_json::json;
612
613 #[test]
614 fn set_and_get() {
615 let store = StateStore::new();
616 store.set("x", Value::from(42), "test");
617 assert_eq!(store.get("x"), Some(Value::from(42)));
618 }
619
620 #[test]
621 fn exists() {
622 let store = StateStore::new();
623 assert!(!store.exists("x"));
624 store.set("x", Value::from(1), "test");
625 assert!(store.exists("x"));
626 }
627
628 #[test]
629 fn delete() {
630 let store = StateStore::new();
631 store.set("x", Value::from(1), "test");
632 let t = store.delete("x", "test");
633 assert!(t.is_some());
634 assert!(!store.exists("x"));
635 }
636
637 #[test]
638 fn delete_nonexistent() {
639 let store = StateStore::new();
640 assert!(store.delete("x", "test").is_none());
641 }
642
643 #[test]
644 fn snapshot_and_restore() {
645 let store = StateStore::new();
646 store.set("x", Value::from(1), "a");
647 let snap = store.snapshot();
648 let tc = store.transition_count();
649
650 store.set("y", Value::from(2), "b");
651 assert!(store.exists("y"));
652
653 store.restore(snap, tc);
654 assert!(store.exists("x"));
655 assert!(!store.exists("y"));
656 assert_eq!(store.transition_count(), 1);
657 }
658
659 #[test]
660 fn transitions_logged() {
661 let store = StateStore::new();
662 store.set("a", Value::from(1), "act1");
663 store.set("b", Value::from(2), "act2");
664
665 let transitions = store.transitions();
666 assert_eq!(transitions.len(), 2);
667 assert_eq!(transitions[0].key, "a");
668 assert_eq!(transitions[1].key, "b");
669 }
670
671 #[test]
672 fn transitions_since() {
673 let store = StateStore::new();
674 store.set("a", Value::from(1), "act1");
675 let idx = store.transition_count();
676 store.set("b", Value::from(2), "act2");
677
678 let since = store.transitions_since(idx);
679 assert_eq!(since.len(), 1);
680 assert_eq!(since[0].key, "b");
681 }
682
683 #[test]
684 fn transition_records_old_value() {
685 let store = StateStore::new();
686 store.set("x", Value::from(1), "first");
687 store.set("x", Value::from(2), "second");
688
689 let transitions = store.transitions();
690 assert_eq!(transitions[1].old_value, Some(Value::from(1)));
691 assert_eq!(transitions[1].new_value, Some(Value::from(2)));
692 }
693
694 #[test]
695 fn keys() {
696 let store = StateStore::new();
697 store.set("a", Value::from(1), "t");
698 store.set("b", Value::from(2), "t");
699 let mut keys = store.keys();
700 keys.sort();
701 assert_eq!(keys, vec!["a", "b"]);
702 }
703
704 #[test]
705 fn transitions_since_after_restore_does_not_panic() {
706 let store = StateStore::new();
707 store.set("a", serde_json::json!(1), "test");
708 store.set("b", serde_json::json!(2), "test");
709 let count_before = store.transition_count(); store.restore(HashMap::new(), 0);
713
714 let result = store.transitions_since(count_before);
716 assert!(result.is_empty());
717 }
718
719 #[test]
720 fn transitions_since_normal_usage() {
721 let store = StateStore::new();
722 store.set("a", serde_json::json!(1), "test");
723 let mark = store.transition_count();
724 store.set("b", serde_json::json!(2), "test");
725 let since = store.transitions_since(mark);
726 assert_eq!(since.len(), 1);
727 assert_eq!(since[0].key, "b");
728 }
729
730 #[test]
731 fn replace_all_swaps_state_without_transitions() {
732 let store = StateStore::new();
733 store.set("old_key", serde_json::json!("old"), "setup");
734
735 let mut new_state = HashMap::new();
736 new_state.insert("new_key".to_string(), serde_json::json!("new"));
737 store.replace_all(new_state);
738
739 assert_eq!(store.get("new_key"), Some(serde_json::json!("new")));
740 assert_eq!(store.get("old_key"), None);
741 assert_eq!(store.transition_count(), 0);
743 }
744
745 #[test]
746 fn durable_store_survives_reopen() {
747 let dir = tempfile::tempdir().unwrap();
748 let path = dir.path().join("state.jsonl");
749 {
750 let store = StateStore::durable(&path).unwrap();
751 store.set("agent", serde_json::json!("planner"), "boot");
752 store.set("turns", serde_json::json!(42), "tick");
753 store.sync().unwrap();
754 }
755 let store = StateStore::durable(&path).unwrap();
756 assert_eq!(store.get("agent"), Some(serde_json::json!("planner")));
757 assert_eq!(store.get("turns"), Some(serde_json::json!(42)));
758 }
759
760 #[test]
761 fn durable_store_replays_deletes() {
762 let dir = tempfile::tempdir().unwrap();
763 let path = dir.path().join("state.jsonl");
764 {
765 let store = StateStore::durable(&path).unwrap();
766 store.set("transient", serde_json::json!("x"), "boot");
767 store.delete("transient", "rm");
768 store.sync().unwrap();
769 }
770 let store = StateStore::durable(&path).unwrap();
771 assert!(!store.exists("transient"));
772 }
773
774 #[test]
775 fn ttl_reap_drops_expired_and_keeps_fresh() {
776 let store = StateStore::new();
777 store.set_with_ttl("short", serde_json::json!(1), "set", 0);
778 store.set_with_ttl("long", serde_json::json!(2), "set", 3600);
779 store.set("forever", serde_json::json!(3), "set");
780 let reaped = store
782 .reap_expired(Utc::now() + Duration::seconds(10))
783 .unwrap();
784 assert_eq!(reaped, vec!["short".to_string()]);
785 assert!(!store.exists("short"));
786 assert_eq!(store.get("long"), Some(serde_json::json!(2)));
787 assert_eq!(store.get("forever"), Some(serde_json::json!(3)));
788 }
789
790 #[test]
791 fn durable_ttl_compacts_journal() {
792 let dir = tempfile::tempdir().unwrap();
793 let path = dir.path().join("state.jsonl");
794 {
795 let store = StateStore::durable(&path).unwrap();
796 for i in 0..50 {
797 store.set_with_ttl(&format!("k{i}"), serde_json::json!(i), "set", 0);
798 }
799 store.set("survivor", serde_json::json!("kept"), "set");
800 store.sync().unwrap();
801 let pre = std::fs::metadata(&path).unwrap().len();
802 let reaped = store
804 .reap_expired(Utc::now() + Duration::seconds(1))
805 .unwrap();
806 assert_eq!(reaped.len(), 50);
807 store.sync().unwrap();
808 let post = std::fs::metadata(&path).unwrap().len();
809 assert!(
812 post < pre,
813 "post={post} pre={pre} — compaction did not shrink"
814 );
815 }
816 let store = StateStore::durable(&path).unwrap();
818 assert!(!store.exists("k0"));
819 assert!(!store.exists("k49"));
820 assert_eq!(store.get("survivor"), Some(serde_json::json!("kept")));
821 assert_eq!(store.version("survivor"), Some(1));
825 }
826
827 #[test]
828 fn version_is_monotonic_and_survives_compaction() {
829 let dir = tempfile::tempdir().unwrap();
830 let path = dir.path().join("v.jsonl");
831 {
832 let store = StateStore::durable(&path).unwrap();
833 for i in 0..3 {
834 store.set("cfg", serde_json::json!(i), "set");
835 }
836 assert_eq!(store.version("cfg"), Some(3));
837 store.set_with_ttl("tmp", serde_json::json!(1), "set", 0);
839 store.sync().unwrap();
840 store.reap_expired(Utc::now() + Duration::seconds(1)).unwrap();
841 store.sync().unwrap();
842 }
843 let store = StateStore::durable(&path).unwrap();
846 assert_eq!(store.version("cfg"), Some(3));
847 }
848
849 #[test]
850 fn reap_bumps_version() {
851 let store = StateStore::new();
852 store.set("k", serde_json::json!("v"), "set");
853 assert_eq!(store.version("k"), Some(1));
854 store.set_with_ttl("k", serde_json::json!("v2"), "set", 0);
855 assert_eq!(store.version("k"), Some(2));
856 store.reap_expired(Utc::now() + Duration::seconds(1)).unwrap();
857 assert_eq!(store.version("k"), Some(3));
859 }
860
861 #[test]
862 fn ttl_then_rewrite_without_ttl_does_not_reap() {
863 let store = StateStore::new();
864 store.set_with_ttl("k", serde_json::json!("a"), "first", 0);
865 store.set("k", serde_json::json!("b"), "second"); let reaped = store
867 .reap_expired(Utc::now() + Duration::seconds(10))
868 .unwrap();
869 assert!(reaped.is_empty());
870 assert_eq!(store.get("k"), Some(serde_json::json!("b")));
871 }
872
873 #[test]
874 fn malformed_journal_line_is_skipped_not_fatal() {
875 let dir = tempfile::tempdir().unwrap();
876 let path = dir.path().join("state.jsonl");
877 {
879 std::fs::write(
880 &path,
881 "{\"key\":\"a\",\"old_value\":null,\"new_value\":1,\"action_id\":\"x\",\"timestamp\":\"2026-05-11T00:00:00Z\"}\n\
882 not-json\n\
883 {\"key\":\"b\",\"old_value\":null,\"new_value\":2,\"action_id\":\"x\",\"timestamp\":\"2026-05-11T00:00:00Z\"}\n",
884 )
885 .unwrap();
886 }
887 let store = StateStore::durable(&path).unwrap();
888 assert_eq!(store.get("a"), Some(serde_json::json!(1)));
889 assert_eq!(store.get("b"), Some(serde_json::json!(2)));
890 }
891
892 #[test]
895 fn scoped_view_writes_isolate_between_tenants() {
896 let store = StateStore::new();
897 store.scoped(Some("acme")).set("config", json!("A"), "act");
898 store
899 .scoped(Some("globex"))
900 .set("config", json!("G"), "act");
901
902 assert_eq!(store.scoped(Some("acme")).get("config"), Some(json!("A")));
904 assert_eq!(store.scoped(Some("globex")).get("config"), Some(json!("G")));
905 }
906
907 #[test]
908 fn scoped_view_isolates_existence_check() {
909 let store = StateStore::new();
910 store.scoped(Some("acme")).set("k", json!(1), "act");
911 assert!(store.scoped(Some("acme")).exists("k"));
912 assert!(!store.scoped(Some("globex")).exists("k"));
913 }
914
915 #[test]
916 fn scoped_view_keys_filters_to_tenant() {
917 let store = StateStore::new();
918 store.scoped(Some("acme")).set("a", json!(1), "act");
919 store.scoped(Some("acme")).set("b", json!(2), "act");
920 store.scoped(Some("globex")).set("g", json!(9), "act");
921 store.set("unscoped", json!(0), "act");
922
923 let mut acme_keys = store.scoped(Some("acme")).keys();
924 acme_keys.sort();
925 assert_eq!(acme_keys, vec!["a", "b"]);
926
927 let globex_keys = store.scoped(Some("globex")).keys();
928 assert_eq!(globex_keys, vec!["g"]);
929 }
930
931 #[test]
932 fn unscoped_view_skips_tenant_prefixed_keys() {
933 let store = StateStore::new();
939 store.set("legacy", json!("ok"), "act");
940 store.scoped(Some("acme")).set("hidden", json!(42), "act");
941
942 let unscoped = store.scoped(None).keys();
943 assert_eq!(unscoped, vec!["legacy"]);
944 assert!(store.scoped(None).get("hidden").is_none());
945 }
946
947 #[test]
948 fn scoped_view_delete_doesnt_touch_other_tenants() {
949 let store = StateStore::new();
950 store.scoped(Some("acme")).set("shared", json!(1), "act");
951 store.scoped(Some("globex")).set("shared", json!(2), "act");
952
953 store.scoped(Some("acme")).delete("shared", "act");
954 assert!(!store.scoped(Some("acme")).exists("shared"));
955 assert!(store.scoped(Some("globex")).exists("shared"));
956 }
957
958 #[test]
959 fn empty_tenant_string_treated_as_unscoped() {
960 let store = StateStore::new();
964 store.scoped(Some("")).set("k", json!(1), "act");
965 assert_eq!(store.get("k"), Some(json!(1)));
966 assert_eq!(store.scoped(None).get("k"), Some(json!(1)));
967 }
968}