1use std::collections::{BTreeMap, BTreeSet};
2use std::rc::Rc;
3
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6
7use crate::{
8 ComponentInstancePath, ComponentStateSchema, SchemaValidationError, UiValue, UiValuePath,
9 UiValuePathError,
10};
11
12#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
13#[serde(tag = "scope", content = "id", rename_all = "snake_case")]
14pub enum StoreScope {
15 App,
16 Window(String),
17}
18
19#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
20pub struct StoreId {
21 pub scope: StoreScope,
22 pub name: String,
23}
24
25impl StoreId {
26 #[must_use]
27 pub fn app(name: impl Into<String>) -> Self {
28 Self {
29 scope: StoreScope::App,
30 name: name.into(),
31 }
32 }
33
34 #[must_use]
35 pub fn window(window: impl Into<String>, name: impl Into<String>) -> Self {
36 Self {
37 scope: StoreScope::Window(window.into()),
38 name: name.into(),
39 }
40 }
41}
42
43#[derive(Clone, Debug, Default)]
44pub struct StoreRegistry {
45 stores: Rc<BTreeMap<StoreId, StoreState>>,
46 readers: Rc<BTreeMap<StoreField, BTreeSet<ComponentInstancePath>>>,
47}
48
49#[derive(Clone, Debug)]
50struct StoreState {
51 schema: Rc<ComponentStateSchema>,
52 values: Rc<BTreeMap<String, UiValue>>,
53}
54
55#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
56struct StoreField {
57 store: StoreId,
58 field: String,
59 path: Option<UiValuePath>,
60}
61
62impl StoreRegistry {
63 #[must_use]
64 pub fn new() -> Self {
65 Self::default()
66 }
67
68 pub fn declare(&mut self, id: StoreId, schema: ComponentStateSchema) -> Result<(), StoreError> {
74 if self.stores.contains_key(&id) {
75 return Err(StoreError::DuplicateStore(id));
76 }
77 ComponentStateSchema::new(schema.fields().clone())?;
78 let values = schema
79 .fields()
80 .iter()
81 .map(|(name, field)| (name.clone(), field.default.clone()))
82 .collect();
83 Rc::make_mut(&mut self.stores).insert(
84 id,
85 StoreState {
86 schema: Rc::new(schema),
87 values: Rc::new(values),
88 },
89 );
90 Ok(())
91 }
92
93 pub fn begin_read<'a>(&'a mut self, reader: &'a ComponentInstancePath) -> StoreReadSession<'a> {
98 self.reset_reader(reader);
99 StoreReadSession {
100 registry: self,
101 reader,
102 }
103 }
104
105 pub fn reset_reader(&mut self, reader: &ComponentInstancePath) {
106 let tracked = Rc::make_mut(&mut self.readers);
107 for readers in tracked.values_mut() {
108 readers.remove(reader);
109 }
110 tracked.retain(|_, readers| !readers.is_empty());
111 }
112
113 pub fn retain_reader_scope(
115 &mut self,
116 root: &ComponentInstancePath,
117 active: &BTreeSet<ComponentInstancePath>,
118 ) {
119 let tracked = Rc::make_mut(&mut self.readers);
120 for readers in tracked.values_mut() {
121 readers.retain(|reader| {
122 !reader.is_within(root) || reader == root || active.contains(reader)
123 });
124 }
125 tracked.retain(|_, readers| !readers.is_empty());
126 }
127
128 pub fn read_tracked(
134 &mut self,
135 reader: &ComponentInstancePath,
136 store: &StoreId,
137 field: &str,
138 ) -> Result<UiValue, StoreError> {
139 let value = self
140 .stores
141 .get(store)
142 .ok_or_else(|| StoreError::UnknownStore(store.clone()))?
143 .values
144 .get(field)
145 .cloned()
146 .ok_or_else(|| StoreError::UnknownField {
147 store: store.clone(),
148 field: field.to_owned(),
149 })?;
150 Rc::make_mut(&mut self.readers)
151 .entry(StoreField {
152 store: store.clone(),
153 field: field.to_owned(),
154 path: None,
155 })
156 .or_default()
157 .insert(reader.clone());
158 Ok(value)
159 }
160
161 pub fn read_path_tracked(
167 &mut self,
168 reader: &ComponentInstancePath,
169 store: &StoreId,
170 field: &str,
171 path: &UiValuePath,
172 ) -> Result<UiValue, StoreError> {
173 let value = self
174 .stores
175 .get(store)
176 .ok_or_else(|| StoreError::UnknownStore(store.clone()))?
177 .values
178 .get(field)
179 .ok_or_else(|| StoreError::UnknownField {
180 store: store.clone(),
181 field: field.to_owned(),
182 })?
183 .get_path(path)?
184 .clone();
185 Rc::make_mut(&mut self.readers)
186 .entry(StoreField {
187 store: store.clone(),
188 field: field.to_owned(),
189 path: Some(path.clone()),
190 })
191 .or_default()
192 .insert(reader.clone());
193 Ok(value)
194 }
195
196 pub fn write(
203 &mut self,
204 store: &StoreId,
205 field: &str,
206 value: UiValue,
207 ) -> Result<BTreeSet<ComponentInstancePath>, StoreError> {
208 let state = self
209 .stores
210 .get(store)
211 .ok_or_else(|| StoreError::UnknownStore(store.clone()))?;
212 let declared = state
213 .schema
214 .field(field)
215 .ok_or_else(|| StoreError::UnknownField {
216 store: store.clone(),
217 field: field.to_owned(),
218 })?;
219 declared
220 .schema
221 .validate_ui_value(&value)
222 .map_err(|source| StoreError::InvalidValue {
223 store: store.clone(),
224 field: field.to_owned(),
225 source,
226 })?;
227
228 let previous =
229 state
230 .values
231 .get(field)
232 .cloned()
233 .ok_or_else(|| StoreError::UnknownField {
234 store: store.clone(),
235 field: field.to_owned(),
236 })?;
237 if previous == value {
238 return Ok(BTreeSet::new());
239 }
240 let invalidated = self
241 .readers
242 .iter()
243 .filter(|(read, _)| read.store == *store && read.field == field)
244 .filter(|(read, _)| {
245 read.path
246 .as_ref()
247 .is_none_or(|path| previous.get_path(path).ok() != value.get_path(path).ok())
248 })
249 .flat_map(|(_, readers)| readers.iter().cloned())
250 .collect();
251 let state = Rc::make_mut(&mut self.stores)
252 .get_mut(store)
253 .ok_or_else(|| StoreError::UnknownStore(store.clone()))?;
254 Rc::make_mut(&mut state.values).insert(field.to_owned(), value);
255 Ok(invalidated)
256 }
257
258 pub fn write_path(
264 &mut self,
265 store: &StoreId,
266 field: &str,
267 path: &UiValuePath,
268 value: UiValue,
269 ) -> Result<BTreeSet<ComponentInstancePath>, StoreError> {
270 let mut root = self
271 .stores
272 .get(store)
273 .ok_or_else(|| StoreError::UnknownStore(store.clone()))?
274 .values
275 .get(field)
276 .cloned()
277 .ok_or_else(|| StoreError::UnknownField {
278 store: store.clone(),
279 field: field.to_owned(),
280 })?;
281 root.set_path(path, value)?;
282 self.write(store, field, root)
283 }
284
285 pub fn remove_window(&mut self, window: &str) {
287 Rc::make_mut(&mut self.stores)
288 .retain(|id, _| !matches!(&id.scope, StoreScope::Window(id) if id == window));
289 Rc::make_mut(&mut self.readers).retain(
290 |field, _| !matches!(&field.store.scope, StoreScope::Window(id) if id == window),
291 );
292 }
293
294 #[must_use]
295 pub fn is_sensitive(&self, store: &StoreId, field: &str) -> bool {
296 self.stores
297 .get(store)
298 .and_then(|state| state.schema.field(field))
299 .is_some_and(|field| field.sensitive)
300 }
301
302 #[must_use]
303 pub fn inspect(&self) -> Vec<StoreSnapshot> {
304 self.stores
305 .iter()
306 .map(|(id, state)| StoreSnapshot {
307 id: id.clone(),
308 fields: state
309 .values
310 .iter()
311 .map(|(name, value)| {
312 let sensitive = state
313 .schema
314 .field(name)
315 .is_some_and(|field| field.sensitive);
316 (
317 name.clone(),
318 crate::StateValueSnapshot {
319 value: if sensitive {
320 UiValue::String("<sensitive>".to_owned())
321 } else {
322 value.clone()
323 },
324 sensitive,
325 },
326 )
327 })
328 .collect(),
329 })
330 .collect()
331 }
332}
333
334#[derive(Clone, Debug, PartialEq)]
335pub struct StoreSnapshot {
336 pub id: StoreId,
337 pub fields: BTreeMap<String, crate::StateValueSnapshot>,
338}
339
340pub struct StoreReadSession<'a> {
341 registry: &'a mut StoreRegistry,
342 reader: &'a ComponentInstancePath,
343}
344
345impl StoreReadSession<'_> {
346 pub fn read(&mut self, store: &StoreId, field: &str) -> Result<UiValue, StoreError> {
352 self.registry.read_tracked(self.reader, store, field)
353 }
354
355 pub fn read_path(
361 &mut self,
362 store: &StoreId,
363 field: &str,
364 path: &UiValuePath,
365 ) -> Result<UiValue, StoreError> {
366 self.registry
367 .read_path_tracked(self.reader, store, field, path)
368 }
369}
370
371#[derive(Clone, Debug, Error, Eq, PartialEq)]
372pub enum StoreError {
373 #[error("store `{0:?}` is already declared")]
374 DuplicateStore(StoreId),
375 #[error("store `{0:?}` is not declared")]
376 UnknownStore(StoreId),
377 #[error("field `{field}` is not declared in store `{store:?}`")]
378 UnknownField { store: StoreId, field: String },
379 #[error("value for `{store:?}.{field}` is invalid: {source}")]
380 InvalidValue {
381 store: StoreId,
382 field: String,
383 source: SchemaValidationError,
384 },
385 #[error(transparent)]
386 State(#[from] crate::StateError),
387 #[error(transparent)]
388 Path(#[from] UiValuePathError),
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394 use crate::{StateField, ValueSchema};
395
396 fn session_schema() -> ComponentStateSchema {
397 ComponentStateSchema::new(BTreeMap::from([
398 (
399 "project".to_owned(),
400 StateField::new(ValueSchema::string(), UiValue::String("alpha".to_owned())),
401 ),
402 (
403 "sidebar_open".to_owned(),
404 StateField::new(ValueSchema::Bool, UiValue::Bool(true)),
405 ),
406 ]))
407 .unwrap()
408 }
409
410 fn nested_schema() -> ComponentStateSchema {
411 let row = |id: &str, label: &str| {
412 UiValue::Map(BTreeMap::from([
413 ("id".to_owned(), UiValue::String(id.to_owned())),
414 ("label".to_owned(), UiValue::String(label.to_owned())),
415 ]))
416 };
417 ComponentStateSchema::new(BTreeMap::from([(
418 "data".to_owned(),
419 StateField::new(
420 ValueSchema::UiValue,
421 UiValue::Map(BTreeMap::from([
422 (
423 "profile".to_owned(),
424 UiValue::Map(BTreeMap::from([
425 ("name".to_owned(), UiValue::String("Ada".to_owned())),
426 ("avatar".to_owned(), UiValue::String("ada.png".to_owned())),
427 ])),
428 ),
429 (
430 "rows".to_owned(),
431 UiValue::Array(vec![row("alpha", "Alpha"), row("beta", "Beta")]),
432 ),
433 ])),
434 ),
435 )]))
436 .unwrap()
437 }
438
439 #[test]
440 fn writes_invalidate_only_exact_field_readers() {
441 let store_id = StoreId::app("session");
442 let project = ComponentInstancePath::root("ProjectView", "project");
443 let sidebar = ComponentInstancePath::root("Sidebar", "sidebar");
444 let mut stores = StoreRegistry::new();
445 stores.declare(store_id.clone(), session_schema()).unwrap();
446
447 stores
448 .begin_read(&project)
449 .read(&store_id, "project")
450 .unwrap();
451 stores
452 .begin_read(&sidebar)
453 .read(&store_id, "sidebar_open")
454 .unwrap();
455
456 let invalidated = stores
457 .write(&store_id, "project", UiValue::String("beta".to_owned()))
458 .unwrap();
459 assert_eq!(invalidated, BTreeSet::from([project]));
460 }
461
462 #[test]
463 fn deserialized_schema_defaults_are_validated_before_store_declaration() {
464 let schema: ComponentStateSchema = serde_json::from_str(
465 r#"{"fields":{"count":{"schema":{"type":"integer"},"default":{"type":"string","value":"wrong"}}}}"#,
466 )
467 .unwrap();
468 let mut stores = StoreRegistry::new();
469 assert!(matches!(
470 stores.declare(StoreId::app("invalid"), schema),
471 Err(StoreError::State(crate::StateError::InvalidDefault { .. }))
472 ));
473 }
474
475 #[test]
476 fn snapshots_share_large_store_values_until_a_write() {
477 let id = StoreId::app("session");
478 let mut stores = StoreRegistry::new();
479 stores.declare(id.clone(), session_schema()).unwrap();
480 let snapshot = stores.clone();
481 assert!(Rc::ptr_eq(&stores.stores, &snapshot.stores));
482 stores
483 .write(&id, "sidebar_open", UiValue::Bool(false))
484 .unwrap();
485 assert!(!Rc::ptr_eq(&stores.stores, &snapshot.stores));
486 assert_eq!(
487 snapshot.stores[&id].values["sidebar_open"],
488 UiValue::Bool(true)
489 );
490 }
491
492 #[test]
493 fn repeated_value_does_not_invalidate() {
494 let store_id = StoreId::app("session");
495 let reader = ComponentInstancePath::root("App", "root");
496 let mut stores = StoreRegistry::new();
497 stores.declare(store_id.clone(), session_schema()).unwrap();
498 stores
499 .begin_read(&reader)
500 .read(&store_id, "project")
501 .unwrap();
502
503 assert!(
504 stores
505 .write(&store_id, "project", UiValue::String("alpha".to_owned()),)
506 .unwrap()
507 .is_empty()
508 );
509 }
510
511 #[test]
512 fn removing_window_clears_window_store() {
513 let store_id = StoreId::window("settings", "draft");
514 let mut stores = StoreRegistry::new();
515 stores.declare(store_id.clone(), session_schema()).unwrap();
516 stores.remove_window("settings");
517 assert!(matches!(
518 stores.write(&store_id, "sidebar_open", UiValue::Bool(false)),
519 Err(StoreError::UnknownStore(_))
520 ));
521 }
522
523 #[test]
524 fn successful_subtree_reconcile_removes_stale_app_store_readers() {
525 let id = StoreId::app("shared");
526 let root = ComponentInstancePath::root("App", "main");
527 let removed = root.child("Panel", "removed");
528 let mut stores = StoreRegistry::new();
529 stores.declare(id.clone(), session_schema()).unwrap();
530 stores.read_tracked(&removed, &id, "sidebar_open").unwrap();
531 stores.retain_reader_scope(&root, &BTreeSet::new());
532 assert!(
533 stores
534 .write(&id, "sidebar_open", UiValue::Bool(false))
535 .unwrap()
536 .is_empty()
537 );
538 }
539
540 #[test]
541 fn nested_writes_invalidate_only_changed_path_and_whole_readers() {
542 let store_id = StoreId::app("model");
543 let whole = ComponentInstancePath::root("Whole", "whole");
544 let name = ComponentInstancePath::root("Name", "name");
545 let avatar = ComponentInstancePath::root("Avatar", "avatar");
546 let mut stores = StoreRegistry::new();
547 stores.declare(store_id.clone(), nested_schema()).unwrap();
548 let name_path = UiValuePath::new(vec![
549 crate::UiValuePathSegment::Key("profile".to_owned()),
550 crate::UiValuePathSegment::Key("name".to_owned()),
551 ])
552 .unwrap();
553 let avatar_path = UiValuePath::new(vec![
554 crate::UiValuePathSegment::Key("profile".to_owned()),
555 crate::UiValuePathSegment::Key("avatar".to_owned()),
556 ])
557 .unwrap();
558 stores.read_tracked(&whole, &store_id, "data").unwrap();
559 stores
560 .read_path_tracked(&name, &store_id, "data", &name_path)
561 .unwrap();
562 stores
563 .read_path_tracked(&avatar, &store_id, "data", &avatar_path)
564 .unwrap();
565
566 assert_eq!(
567 stores
568 .write_path(
569 &store_id,
570 "data",
571 &name_path,
572 UiValue::String("Grace".to_owned()),
573 )
574 .unwrap(),
575 BTreeSet::from([whole, name])
576 );
577 assert!(
578 stores
579 .write_path(
580 &store_id,
581 "data",
582 &name_path,
583 UiValue::String("Grace".to_owned()),
584 )
585 .unwrap()
586 .is_empty()
587 );
588 }
589
590 #[test]
591 fn keyed_item_reader_ignores_reorder_but_observes_item_change() {
592 let store_id = StoreId::app("model");
593 let reader = ComponentInstancePath::root("Row", "beta");
594 let whole = ComponentInstancePath::root("Whole", "whole");
595 let mut stores = StoreRegistry::new();
596 stores.declare(store_id.clone(), nested_schema()).unwrap();
597 let row_path = UiValuePath::new(vec![
598 crate::UiValuePathSegment::Key("rows".to_owned()),
599 crate::UiValuePathSegment::Item {
600 key_field: "id".to_owned(),
601 key: "beta".to_owned(),
602 },
603 crate::UiValuePathSegment::Key("label".to_owned()),
604 ])
605 .unwrap();
606 stores
607 .read_path_tracked(&reader, &store_id, "data", &row_path)
608 .unwrap();
609 stores.read_tracked(&whole, &store_id, "data").unwrap();
610
611 let mut reordered = stores
612 .stores
613 .get(&store_id)
614 .unwrap()
615 .values
616 .get("data")
617 .unwrap()
618 .clone();
619 if let UiValue::Map(root) = &mut reordered
620 && let Some(UiValue::Array(rows)) = root.get_mut("rows")
621 {
622 rows.reverse();
623 }
624 assert_eq!(
625 stores.write(&store_id, "data", reordered).unwrap(),
626 BTreeSet::from([whole.clone()])
627 );
628 assert_eq!(
629 stores
630 .write_path(
631 &store_id,
632 "data",
633 &row_path,
634 UiValue::String("Updated".to_owned()),
635 )
636 .unwrap(),
637 BTreeSet::from([whole, reader])
638 );
639 }
640}