1pub mod crdt;
22
23use chrono::{DateTime, Duration, Utc};
24use parking_lot::Mutex;
25use serde::{Deserialize, Serialize};
26use serde_json::Value;
27use std::collections::HashMap;
28use std::fs::{File, OpenOptions};
29use std::io::{BufRead, BufReader, BufWriter, Write};
30use std::path::{Path, PathBuf};
31
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub struct StateTransition {
40 pub key: String,
41 pub old_value: Option<Value>,
42 pub new_value: Option<Value>,
43 pub action_id: String,
44 pub timestamp: DateTime<Utc>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub ttl_secs: Option<u64>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub version: Option<u64>,
55}
56
57pub struct StateStore {
64 state: Mutex<HashMap<String, Value>>,
65 transitions: Mutex<Vec<StateTransition>>,
66 versions: Mutex<HashMap<String, u64>>,
72 journal: Mutex<Option<Journal>>,
77}
78
79struct Journal {
80 path: PathBuf,
81 writer: BufWriter<File>,
82}
83
84impl StateStore {
85 pub fn new() -> Self {
86 Self {
87 state: Mutex::new(HashMap::new()),
88 transitions: Mutex::new(Vec::new()),
89 versions: Mutex::new(HashMap::new()),
90 journal: Mutex::new(None),
91 }
92 }
93
94 pub fn durable(path: impl Into<PathBuf>) -> std::io::Result<Self> {
105 let path = path.into();
106 if let Some(parent) = path.parent() {
107 if !parent.as_os_str().is_empty() {
108 std::fs::create_dir_all(parent)?;
109 }
110 }
111 let store = Self::new();
112 store.replay_journal(&path)?;
113 let file = OpenOptions::new().create(true).append(true).open(&path)?;
114 *store.journal.lock() = Some(Journal {
115 path,
116 writer: BufWriter::new(file),
117 });
118 Ok(store)
119 }
120
121 fn replay_journal(&self, path: &Path) -> std::io::Result<()> {
122 if !path.exists() {
123 return Ok(());
124 }
125 let file = File::open(path)?;
126 let reader = BufReader::new(file);
127 let now = Utc::now();
128 let mut state = self.state.lock();
129 let mut transitions = self.transitions.lock();
130 let mut versions = self.versions.lock();
131 for line in reader.lines() {
132 let line = match line {
133 Ok(l) if l.trim().is_empty() => continue,
134 Ok(l) => l,
135 Err(_) => continue,
136 };
137 let Ok(t) = serde_json::from_str::<StateTransition>(&line) else {
138 tracing::warn!(
140 journal = %path.display(),
141 "skipping malformed StateStore journal line"
142 );
143 continue;
144 };
145 if let (Some(ttl), Some(value)) = (t.ttl_secs, &t.new_value) {
149 if now.signed_duration_since(t.timestamp) > Duration::seconds(ttl as i64) {
150 state.remove(&t.key);
151 } else {
152 state.insert(t.key.clone(), value.clone());
153 }
154 } else if let Some(value) = &t.new_value {
155 state.insert(t.key.clone(), value.clone());
156 } else {
157 state.remove(&t.key);
158 }
159 let entry = versions.entry(t.key.clone()).or_insert(0);
164 let restored = t.version.unwrap_or(*entry + 1);
165 *entry = (*entry).max(restored);
166 transitions.push(t);
167 }
168 Ok(())
169 }
170
171 fn append_journal(&self, transition: &StateTransition) {
172 let mut journal = self.journal.lock();
173 let Some(journal) = journal.as_mut() else {
174 return;
175 };
176 let Ok(json) = serde_json::to_string(transition) else {
180 return;
181 };
182 if let Err(e) = writeln!(journal.writer, "{json}") {
183 tracing::warn!(
184 journal = %journal.path.display(),
185 error = %e,
186 "StateStore journal append failed"
187 );
188 return;
189 }
190 let _ = journal.writer.flush();
191 }
192
193 pub fn sync(&self) -> std::io::Result<()> {
196 let mut journal = self.journal.lock();
197 let Some(journal) = journal.as_mut() else {
198 return Ok(());
199 };
200 journal.writer.flush()?;
201 journal.writer.get_ref().sync_all()
202 }
203
204 pub fn reap_expired(&self, now: DateTime<Utc>) -> std::io::Result<Vec<String>> {
220 self.reap_expired_where(now, |_| true)
221 }
222
223 pub fn reap_expired_scoped(
231 &self,
232 now: DateTime<Utc>,
233 tenant: Option<&str>,
234 ) -> std::io::Result<Vec<String>> {
235 self.reap_expired_where(now, |k| key_in_tenant_namespace(k, tenant))
236 }
237
238 fn reap_expired_where(
241 &self,
242 now: DateTime<Utc>,
243 keep: impl Fn(&str) -> bool,
244 ) -> std::io::Result<Vec<String>> {
245 let mut state = self.state.lock();
246 let mut transitions = self.transitions.lock();
247 let mut latest_by_key: HashMap<&str, &StateTransition> = HashMap::new();
251 for t in transitions.iter() {
252 latest_by_key.insert(t.key.as_str(), t);
253 }
254 let expired: Vec<String> = latest_by_key
255 .values()
256 .filter_map(|t| {
257 if !keep(&t.key) {
258 return None;
259 }
260 let ttl = t.ttl_secs?;
261 t.new_value.as_ref()?;
262 let age = now.signed_duration_since(t.timestamp);
263 (age > Duration::seconds(ttl as i64)).then(|| t.key.clone())
264 })
265 .collect();
266 let mut reaped = Vec::new();
267 for key in expired {
268 if state.remove(&key).is_some() {
269 let version = self.bump_version(&key);
273 reaped.push(key.clone());
274 transitions.push(StateTransition {
275 key,
276 old_value: None,
277 new_value: None,
278 action_id: "reap".to_string(),
279 timestamp: now,
280 ttl_secs: None,
281 version: Some(version),
282 });
283 }
284 }
285 drop(state);
286 drop(transitions);
287 if !reaped.is_empty() {
288 self.compact_journal()?;
289 }
290 Ok(reaped)
291 }
292
293 pub(crate) fn compact_journal(&self) -> std::io::Result<()> {
305 let mut journal = self.journal.lock();
306 let Some(j) = journal.as_mut() else {
307 return Ok(());
308 };
309 let state = self.state.lock().clone();
310 let versions = self.versions.lock().clone();
311 let tmp_path = j.path.with_extension("jsonl.tmp");
312 {
313 let tmp_file = File::create(&tmp_path)?;
314 let mut writer = BufWriter::new(tmp_file);
315 for (key, value) in &state {
316 let t = StateTransition {
317 key: key.clone(),
318 old_value: None,
319 new_value: Some(value.clone()),
320 action_id: "compact".to_string(),
321 timestamp: Utc::now(),
322 ttl_secs: None,
323 version: versions.get(key).copied(),
326 };
327 let line = serde_json::to_string(&t)?;
328 writeln!(writer, "{line}")?;
329 }
330 writer.flush()?;
331 writer.get_ref().sync_all()?;
332 }
333 std::fs::rename(&tmp_path, &j.path)?;
334 let file = OpenOptions::new().create(true).append(true).open(&j.path)?;
335 j.writer = BufWriter::new(file);
336 Ok(())
337 }
338
339 pub fn get(&self, key: &str) -> Option<Value> {
340 self.state.lock().get(key).cloned()
341 }
342
343 pub fn get_or(&self, key: &str, default: Value) -> Value {
344 self.state.lock().get(key).cloned().unwrap_or(default)
345 }
346
347 pub fn exists(&self, key: &str) -> bool {
348 self.state.lock().contains_key(key)
349 }
350
351 pub fn set(&self, key: &str, value: Value, action_id: &str) -> StateTransition {
352 self.set_inner(key, value, action_id, None)
353 }
354
355 pub fn set_with_ttl(
367 &self,
368 key: &str,
369 value: Value,
370 action_id: &str,
371 ttl_secs: u64,
372 ) -> StateTransition {
373 self.set_inner(key, value, action_id, Some(ttl_secs))
374 }
375
376 fn set_inner(
377 &self,
378 key: &str,
379 value: Value,
380 action_id: &str,
381 ttl_secs: Option<u64>,
382 ) -> StateTransition {
383 let mut state = self.state.lock();
384 let old = state.get(key).cloned();
385 state.insert(key.to_string(), value.clone());
386 let version = self.bump_version(key);
387
388 let t = StateTransition {
389 key: key.to_string(),
390 old_value: old,
391 new_value: Some(value),
392 action_id: action_id.to_string(),
393 timestamp: Utc::now(),
394 ttl_secs,
395 version: Some(version),
396 };
397
398 self.transitions.lock().push(t.clone());
399 self.append_journal(&t);
400 t
401 }
402
403 fn bump_version(&self, key: &str) -> u64 {
406 let mut versions = self.versions.lock();
407 let v = versions.entry(key.to_string()).or_insert(0);
408 *v += 1;
409 *v
410 }
411
412 pub fn version(&self, key: &str) -> Option<u64> {
416 self.versions.lock().get(key).copied()
417 }
418
419 pub fn versions(&self) -> HashMap<String, u64> {
422 self.versions.lock().clone()
423 }
424
425 pub fn versioned_snapshot(&self) -> (HashMap<String, Value>, HashMap<String, u64>) {
432 let state = self.state.lock();
433 let versions = self.versions.lock();
434 (state.clone(), versions.clone())
435 }
436
437 pub fn delete(&self, key: &str, action_id: &str) -> Option<StateTransition> {
438 let mut state = self.state.lock();
439 let old = state.remove(key)?;
440 let version = self.bump_version(key);
441
442 let t = StateTransition {
443 key: key.to_string(),
444 old_value: Some(old),
445 new_value: None,
446 action_id: action_id.to_string(),
447 timestamp: Utc::now(),
448 ttl_secs: None,
449 version: Some(version),
450 };
451
452 self.transitions.lock().push(t.clone());
453 self.append_journal(&t);
454 Some(t)
455 }
456
457 pub fn snapshot(&self) -> HashMap<String, Value> {
459 self.state.lock().clone()
460 }
461
462 pub fn restore(&self, snapshot: HashMap<String, Value>, transition_count: usize) {
464 *self.state.lock() = snapshot;
465 self.transitions.lock().truncate(transition_count);
466 }
467
468 pub fn snapshot_scoped(&self, tenant: Option<&str>) -> HashMap<String, Value> {
476 let state = self.state.lock();
477 state
478 .iter()
479 .filter(|(k, _)| key_in_tenant_namespace(k, tenant))
480 .map(|(k, v)| (k.clone(), v.clone()))
481 .collect()
482 }
483
484 pub fn restore_scoped(
499 &self,
500 tenant: Option<&str>,
501 snapshot: HashMap<String, Value>,
502 transition_count: usize,
503 ) {
504 {
505 let mut state = self.state.lock();
506 state.retain(|k, _| !key_in_tenant_namespace(k, tenant));
507 state.extend(snapshot);
508 }
509 let mut transitions = self.transitions.lock();
510 if transition_count >= transitions.len() {
511 return;
512 }
513 let tail: Vec<StateTransition> = transitions
516 .drain(transition_count..)
517 .filter(|t| !key_in_tenant_namespace(&t.key, tenant))
518 .collect();
519 transitions.extend(tail);
520 }
521
522 pub fn transition_count(&self) -> usize {
523 self.transitions.lock().len()
524 }
525
526 pub fn transitions(&self) -> Vec<StateTransition> {
527 self.transitions.lock().clone()
528 }
529
530 pub fn transitions_since(&self, index: usize) -> Vec<StateTransition> {
531 let transitions = self.transitions.lock();
532 let start = index.min(transitions.len());
533 transitions[start..].to_vec()
534 }
535
536 pub fn keys(&self) -> Vec<String> {
537 self.state.lock().keys().cloned().collect()
538 }
539
540 pub fn replace_all(&self, snapshot: HashMap<String, Value>) {
545 *self.state.lock() = snapshot;
546 self.transitions.lock().clear();
547 }
548
549 pub fn scoped<'a>(&'a self, tenant: Option<&'a str>) -> ScopedStateView<'a> {
562 ScopedStateView {
563 store: self,
564 tenant,
565 }
566 }
567}
568
569fn key_in_tenant_namespace(key: &str, tenant: Option<&str>) -> bool {
577 match tenant {
578 Some(t) if !t.is_empty() => key.starts_with(&format!("tenant:{t}:")),
579 _ => !key.starts_with("tenant:"),
580 }
581}
582
583pub struct ScopedStateView<'a> {
614 store: &'a StateStore,
615 tenant: Option<&'a str>,
616}
617
618impl<'a> ScopedStateView<'a> {
619 fn full_key(&self, key: &str) -> String {
620 match self.tenant {
621 Some(t) if !t.is_empty() => format!("tenant:{t}:{key}"),
622 _ => key.to_string(),
623 }
624 }
625
626 fn strip_prefix<'k>(&self, full: &'k str) -> Option<&'k str> {
627 match self.tenant {
628 Some(t) if !t.is_empty() => {
629 let prefix = format!("tenant:{t}:");
630 full.strip_prefix(&prefix)
631 }
632 _ => Some(full),
633 }
634 }
635
636 pub fn get(&self, key: &str) -> Option<Value> {
637 self.store.get(&self.full_key(key))
638 }
639
640 pub fn get_or(&self, key: &str, default: Value) -> Value {
641 self.store.get_or(&self.full_key(key), default)
642 }
643
644 pub fn snapshot(&self) -> HashMap<String, Value> {
648 self.store.snapshot_scoped(self.tenant)
649 }
650
651 pub fn restore(&self, snapshot: HashMap<String, Value>, transition_count: usize) {
654 self.store
655 .restore_scoped(self.tenant, snapshot, transition_count)
656 }
657
658 pub fn exists(&self, key: &str) -> bool {
659 self.store.exists(&self.full_key(key))
660 }
661
662 pub fn set(&self, key: &str, value: Value, action_id: &str) -> StateTransition {
663 self.store.set(&self.full_key(key), value, action_id)
664 }
665
666 pub fn set_with_ttl(
667 &self,
668 key: &str,
669 value: Value,
670 action_id: &str,
671 ttl_secs: u64,
672 ) -> StateTransition {
673 self.store
674 .set_with_ttl(&self.full_key(key), value, action_id, ttl_secs)
675 }
676
677 pub fn delete(&self, key: &str, action_id: &str) -> Option<StateTransition> {
678 self.store.delete(&self.full_key(key), action_id)
679 }
680
681 pub fn keys(&self) -> Vec<String> {
687 self.store
688 .keys()
689 .into_iter()
690 .filter_map(|k| {
691 if self.tenant.map(|t| !t.is_empty()).unwrap_or(false) {
692 self.strip_prefix(&k).map(str::to_string)
693 } else if k.starts_with("tenant:") {
694 None
695 } else {
696 Some(k)
697 }
698 })
699 .collect()
700 }
701}
702
703impl Default for StateStore {
704 fn default() -> Self {
705 Self::new()
706 }
707}
708
709impl car_ir::precondition::StateView for StateStore {
710 fn get_value(&self, key: &str) -> Option<Value> {
711 self.get(key)
712 }
713 fn key_exists(&self, key: &str) -> bool {
714 self.exists(key)
715 }
716}
717
718#[cfg(test)]
719mod tests {
720 use super::*;
721 use serde_json::json;
722
723 #[test]
724 fn set_and_get() {
725 let store = StateStore::new();
726 store.set("x", Value::from(42), "test");
727 assert_eq!(store.get("x"), Some(Value::from(42)));
728 }
729
730 #[test]
731 fn exists() {
732 let store = StateStore::new();
733 assert!(!store.exists("x"));
734 store.set("x", Value::from(1), "test");
735 assert!(store.exists("x"));
736 }
737
738 #[test]
739 fn delete() {
740 let store = StateStore::new();
741 store.set("x", Value::from(1), "test");
742 let t = store.delete("x", "test");
743 assert!(t.is_some());
744 assert!(!store.exists("x"));
745 }
746
747 #[test]
748 fn delete_nonexistent() {
749 let store = StateStore::new();
750 assert!(store.delete("x", "test").is_none());
751 }
752
753 #[test]
754 fn snapshot_and_restore() {
755 let store = StateStore::new();
756 store.set("x", Value::from(1), "a");
757 let snap = store.snapshot();
758 let tc = store.transition_count();
759
760 store.set("y", Value::from(2), "b");
761 assert!(store.exists("y"));
762
763 store.restore(snap, tc);
764 assert!(store.exists("x"));
765 assert!(!store.exists("y"));
766 assert_eq!(store.transition_count(), 1);
767 }
768
769 #[test]
770 fn transitions_logged() {
771 let store = StateStore::new();
772 store.set("a", Value::from(1), "act1");
773 store.set("b", Value::from(2), "act2");
774
775 let transitions = store.transitions();
776 assert_eq!(transitions.len(), 2);
777 assert_eq!(transitions[0].key, "a");
778 assert_eq!(transitions[1].key, "b");
779 }
780
781 #[test]
782 fn transitions_since() {
783 let store = StateStore::new();
784 store.set("a", Value::from(1), "act1");
785 let idx = store.transition_count();
786 store.set("b", Value::from(2), "act2");
787
788 let since = store.transitions_since(idx);
789 assert_eq!(since.len(), 1);
790 assert_eq!(since[0].key, "b");
791 }
792
793 #[test]
794 fn transition_records_old_value() {
795 let store = StateStore::new();
796 store.set("x", Value::from(1), "first");
797 store.set("x", Value::from(2), "second");
798
799 let transitions = store.transitions();
800 assert_eq!(transitions[1].old_value, Some(Value::from(1)));
801 assert_eq!(transitions[1].new_value, Some(Value::from(2)));
802 }
803
804 #[test]
805 fn keys() {
806 let store = StateStore::new();
807 store.set("a", Value::from(1), "t");
808 store.set("b", Value::from(2), "t");
809 let mut keys = store.keys();
810 keys.sort();
811 assert_eq!(keys, vec!["a", "b"]);
812 }
813
814 #[test]
815 fn transitions_since_after_restore_does_not_panic() {
816 let store = StateStore::new();
817 store.set("a", serde_json::json!(1), "test");
818 store.set("b", serde_json::json!(2), "test");
819 let count_before = store.transition_count(); store.restore(HashMap::new(), 0);
823
824 let result = store.transitions_since(count_before);
826 assert!(result.is_empty());
827 }
828
829 #[test]
830 fn transitions_since_normal_usage() {
831 let store = StateStore::new();
832 store.set("a", serde_json::json!(1), "test");
833 let mark = store.transition_count();
834 store.set("b", serde_json::json!(2), "test");
835 let since = store.transitions_since(mark);
836 assert_eq!(since.len(), 1);
837 assert_eq!(since[0].key, "b");
838 }
839
840 #[test]
841 fn replace_all_swaps_state_without_transitions() {
842 let store = StateStore::new();
843 store.set("old_key", serde_json::json!("old"), "setup");
844
845 let mut new_state = HashMap::new();
846 new_state.insert("new_key".to_string(), serde_json::json!("new"));
847 store.replace_all(new_state);
848
849 assert_eq!(store.get("new_key"), Some(serde_json::json!("new")));
850 assert_eq!(store.get("old_key"), None);
851 assert_eq!(store.transition_count(), 0);
853 }
854
855 #[test]
856 fn durable_store_survives_reopen() {
857 let dir = tempfile::tempdir().unwrap();
858 let path = dir.path().join("state.jsonl");
859 {
860 let store = StateStore::durable(&path).unwrap();
861 store.set("agent", serde_json::json!("planner"), "boot");
862 store.set("turns", serde_json::json!(42), "tick");
863 store.sync().unwrap();
864 }
865 let store = StateStore::durable(&path).unwrap();
866 assert_eq!(store.get("agent"), Some(serde_json::json!("planner")));
867 assert_eq!(store.get("turns"), Some(serde_json::json!(42)));
868 }
869
870 #[test]
871 fn durable_store_replays_deletes() {
872 let dir = tempfile::tempdir().unwrap();
873 let path = dir.path().join("state.jsonl");
874 {
875 let store = StateStore::durable(&path).unwrap();
876 store.set("transient", serde_json::json!("x"), "boot");
877 store.delete("transient", "rm");
878 store.sync().unwrap();
879 }
880 let store = StateStore::durable(&path).unwrap();
881 assert!(!store.exists("transient"));
882 }
883
884 #[test]
885 fn ttl_reap_drops_expired_and_keeps_fresh() {
886 let store = StateStore::new();
887 store.set_with_ttl("short", serde_json::json!(1), "set", 0);
888 store.set_with_ttl("long", serde_json::json!(2), "set", 3600);
889 store.set("forever", serde_json::json!(3), "set");
890 let reaped = store
892 .reap_expired(Utc::now() + Duration::seconds(10))
893 .unwrap();
894 assert_eq!(reaped, vec!["short".to_string()]);
895 assert!(!store.exists("short"));
896 assert_eq!(store.get("long"), Some(serde_json::json!(2)));
897 assert_eq!(store.get("forever"), Some(serde_json::json!(3)));
898 }
899
900 #[test]
901 fn scoped_reap_isolates_tenants() {
902 let store = StateStore::new();
906 store
907 .scoped(Some("a"))
908 .set_with_ttl("k", serde_json::json!(1), "set", 0);
909 store
910 .scoped(Some("b"))
911 .set_with_ttl("k", serde_json::json!(2), "set", 0);
912 store.set_with_ttl("global", serde_json::json!(3), "set", 0);
913
914 let future = Utc::now() + Duration::seconds(10);
915 let reaped = store.reap_expired_scoped(future, Some("a")).unwrap();
916 assert_eq!(reaped, vec!["tenant:a:k".to_string()]);
917 assert!(!store.scoped(Some("a")).exists("k"));
919 assert!(store.scoped(Some("b")).exists("k"));
920 assert!(store.exists("global"));
921
922 let reaped = store.reap_expired_scoped(future, None).unwrap();
924 assert_eq!(reaped, vec!["global".to_string()]);
925 assert!(store.scoped(Some("b")).exists("k"));
926 }
927
928 #[test]
929 fn durable_ttl_compacts_journal() {
930 let dir = tempfile::tempdir().unwrap();
931 let path = dir.path().join("state.jsonl");
932 {
933 let store = StateStore::durable(&path).unwrap();
934 for i in 0..50 {
935 store.set_with_ttl(&format!("k{i}"), serde_json::json!(i), "set", 0);
936 }
937 store.set("survivor", serde_json::json!("kept"), "set");
938 store.sync().unwrap();
939 let pre = std::fs::metadata(&path).unwrap().len();
940 let reaped = store
942 .reap_expired(Utc::now() + Duration::seconds(1))
943 .unwrap();
944 assert_eq!(reaped.len(), 50);
945 store.sync().unwrap();
946 let post = std::fs::metadata(&path).unwrap().len();
947 assert!(
950 post < pre,
951 "post={post} pre={pre} — compaction did not shrink"
952 );
953 }
954 let store = StateStore::durable(&path).unwrap();
956 assert!(!store.exists("k0"));
957 assert!(!store.exists("k49"));
958 assert_eq!(store.get("survivor"), Some(serde_json::json!("kept")));
959 assert_eq!(store.version("survivor"), Some(1));
963 }
964
965 #[test]
966 fn version_is_monotonic_and_survives_compaction() {
967 let dir = tempfile::tempdir().unwrap();
968 let path = dir.path().join("v.jsonl");
969 {
970 let store = StateStore::durable(&path).unwrap();
971 for i in 0..3 {
972 store.set("cfg", serde_json::json!(i), "set");
973 }
974 assert_eq!(store.version("cfg"), Some(3));
975 store.set_with_ttl("tmp", serde_json::json!(1), "set", 0);
977 store.sync().unwrap();
978 store
979 .reap_expired(Utc::now() + Duration::seconds(1))
980 .unwrap();
981 store.sync().unwrap();
982 }
983 let store = StateStore::durable(&path).unwrap();
986 assert_eq!(store.version("cfg"), Some(3));
987 }
988
989 #[test]
990 fn reap_bumps_version() {
991 let store = StateStore::new();
992 store.set("k", serde_json::json!("v"), "set");
993 assert_eq!(store.version("k"), Some(1));
994 store.set_with_ttl("k", serde_json::json!("v2"), "set", 0);
995 assert_eq!(store.version("k"), Some(2));
996 store
997 .reap_expired(Utc::now() + Duration::seconds(1))
998 .unwrap();
999 assert_eq!(store.version("k"), Some(3));
1001 }
1002
1003 #[test]
1004 fn ttl_then_rewrite_without_ttl_does_not_reap() {
1005 let store = StateStore::new();
1006 store.set_with_ttl("k", serde_json::json!("a"), "first", 0);
1007 store.set("k", serde_json::json!("b"), "second"); let reaped = store
1009 .reap_expired(Utc::now() + Duration::seconds(10))
1010 .unwrap();
1011 assert!(reaped.is_empty());
1012 assert_eq!(store.get("k"), Some(serde_json::json!("b")));
1013 }
1014
1015 #[test]
1016 fn malformed_journal_line_is_skipped_not_fatal() {
1017 let dir = tempfile::tempdir().unwrap();
1018 let path = dir.path().join("state.jsonl");
1019 {
1021 std::fs::write(
1022 &path,
1023 "{\"key\":\"a\",\"old_value\":null,\"new_value\":1,\"action_id\":\"x\",\"timestamp\":\"2026-05-11T00:00:00Z\"}\n\
1024 not-json\n\
1025 {\"key\":\"b\",\"old_value\":null,\"new_value\":2,\"action_id\":\"x\",\"timestamp\":\"2026-05-11T00:00:00Z\"}\n",
1026 )
1027 .unwrap();
1028 }
1029 let store = StateStore::durable(&path).unwrap();
1030 assert_eq!(store.get("a"), Some(serde_json::json!(1)));
1031 assert_eq!(store.get("b"), Some(serde_json::json!(2)));
1032 }
1033
1034 #[test]
1037 fn scoped_view_writes_isolate_between_tenants() {
1038 let store = StateStore::new();
1039 store.scoped(Some("acme")).set("config", json!("A"), "act");
1040 store
1041 .scoped(Some("globex"))
1042 .set("config", json!("G"), "act");
1043
1044 assert_eq!(store.scoped(Some("acme")).get("config"), Some(json!("A")));
1046 assert_eq!(store.scoped(Some("globex")).get("config"), Some(json!("G")));
1047 }
1048
1049 #[test]
1050 fn scoped_view_isolates_existence_check() {
1051 let store = StateStore::new();
1052 store.scoped(Some("acme")).set("k", json!(1), "act");
1053 assert!(store.scoped(Some("acme")).exists("k"));
1054 assert!(!store.scoped(Some("globex")).exists("k"));
1055 }
1056
1057 #[test]
1058 fn scoped_view_keys_filters_to_tenant() {
1059 let store = StateStore::new();
1060 store.scoped(Some("acme")).set("a", json!(1), "act");
1061 store.scoped(Some("acme")).set("b", json!(2), "act");
1062 store.scoped(Some("globex")).set("g", json!(9), "act");
1063 store.set("unscoped", json!(0), "act");
1064
1065 let mut acme_keys = store.scoped(Some("acme")).keys();
1066 acme_keys.sort();
1067 assert_eq!(acme_keys, vec!["a", "b"]);
1068
1069 let globex_keys = store.scoped(Some("globex")).keys();
1070 assert_eq!(globex_keys, vec!["g"]);
1071 }
1072
1073 #[test]
1074 fn unscoped_view_skips_tenant_prefixed_keys() {
1075 let store = StateStore::new();
1081 store.set("legacy", json!("ok"), "act");
1082 store.scoped(Some("acme")).set("hidden", json!(42), "act");
1083
1084 let unscoped = store.scoped(None).keys();
1085 assert_eq!(unscoped, vec!["legacy"]);
1086 assert!(store.scoped(None).get("hidden").is_none());
1087 }
1088
1089 #[test]
1090 fn scoped_restore_does_not_clobber_other_tenants() {
1091 let store = StateStore::new();
1094 store.scoped(Some("acme")).set("k", json!("acme-v1"), "a");
1095 store
1096 .scoped(Some("globex"))
1097 .set("k", json!("globex-v1"), "a");
1098 store.set("global", json!("g-v1"), "a");
1099
1100 let acme_snap = store.scoped(Some("acme")).snapshot();
1102 store.scoped(Some("acme")).set("k", json!("acme-v2"), "a");
1103 store
1104 .scoped(Some("globex"))
1105 .set("k", json!("globex-v2"), "a");
1106 store.set("global", json!("g-v2"), "a");
1107
1108 store.scoped(Some("acme")).restore(acme_snap, 0);
1110 assert_eq!(store.scoped(Some("acme")).get("k"), Some(json!("acme-v1")));
1111 assert_eq!(
1112 store.scoped(Some("globex")).get("k"),
1113 Some(json!("globex-v2"))
1114 );
1115 assert_eq!(store.get("global"), Some(json!("g-v2")));
1116 }
1117
1118 #[test]
1119 fn snapshot_scoped_captures_only_its_namespace() {
1120 let store = StateStore::new();
1121 store.set("global", json!(1), "a");
1122 store.scoped(Some("acme")).set("x", json!(2), "a");
1123 store.scoped(Some("globex")).set("y", json!(3), "a");
1124
1125 let acme = store.snapshot_scoped(Some("acme"));
1126 assert_eq!(acme.len(), 1);
1127 assert!(acme.contains_key("tenant:acme:x"));
1128
1129 let global = store.snapshot_scoped(None);
1130 assert_eq!(global.len(), 1);
1131 assert!(global.contains_key("global"));
1132 }
1133
1134 #[test]
1135 fn unscoped_restore_leaves_tenant_keys_intact() {
1136 let store = StateStore::new();
1138 store.set("g", json!("v1"), "a");
1139 store.scoped(Some("acme")).set("k", json!("acme"), "a");
1140
1141 let snap = store.snapshot_scoped(None);
1142 store.set("g", json!("v2"), "a");
1143 store.restore_scoped(None, snap, 0);
1144
1145 assert_eq!(store.get("g"), Some(json!("v1")));
1146 assert_eq!(store.scoped(Some("acme")).get("k"), Some(json!("acme")));
1148 }
1149
1150 #[test]
1151 fn scoped_restore_preserves_other_tenants_transitions() {
1152 let store = StateStore::new();
1157 store.scoped(Some("acme")).set("k", json!("a1"), "act");
1158
1159 let snap = store.snapshot_scoped(Some("acme"));
1161 let count = store.transition_count();
1162
1163 store.scoped(Some("acme")).set("k", json!("a2"), "act");
1166 store.scoped(Some("globex")).set("g", json!("gv"), "act");
1167
1168 store.restore_scoped(Some("acme"), snap, count);
1169
1170 let tail = store.transitions_since(count);
1172 assert_eq!(
1173 tail.len(),
1174 1,
1175 "exactly globex's transition survives: {tail:?}"
1176 );
1177 assert_eq!(tail[0].key, "tenant:globex:g");
1178 assert_eq!(store.scoped(Some("acme")).get("k"), Some(json!("a1")));
1180 assert_eq!(store.scoped(Some("globex")).get("g"), Some(json!("gv")));
1181 }
1182
1183 #[test]
1184 fn scoped_view_delete_doesnt_touch_other_tenants() {
1185 let store = StateStore::new();
1186 store.scoped(Some("acme")).set("shared", json!(1), "act");
1187 store.scoped(Some("globex")).set("shared", json!(2), "act");
1188
1189 store.scoped(Some("acme")).delete("shared", "act");
1190 assert!(!store.scoped(Some("acme")).exists("shared"));
1191 assert!(store.scoped(Some("globex")).exists("shared"));
1192 }
1193
1194 #[test]
1195 fn empty_tenant_string_treated_as_unscoped() {
1196 let store = StateStore::new();
1200 store.scoped(Some("")).set("k", json!(1), "act");
1201 assert_eq!(store.get("k"), Some(json!(1)));
1202 assert_eq!(store.scoped(None).get("k"), Some(json!(1)));
1203 }
1204}