1use crate::*;
8use std::collections::HashSet;
9
10pub const FIRESTORE_CACHE_DEFAULT_LISTENER_TARGET_BASE: u32 = 1000;
16
17pub const FIRESTORE_CACHE_DEFAULT_NAME: &str = "firestore-cache";
19
20mod sealed {
21 pub trait Sealed {}
22}
23
24pub trait FirestoreCacheBackendKind: sealed::Sealed + Sized {
30 type Backend: FirestoreCacheBackend + Send + Sync + 'static;
32 type Options: Default;
34 type DefaultStorage: FirestoreResumeStateStorage + Send + Sync + 'static;
36
37 fn default_preload_mode() -> FirestoreCacheCollectionLoadMode;
39
40 fn create_default_storage() -> Self::DefaultStorage;
42
43 fn create_backend(
45 options: Self::Options,
46 config: FirestoreCacheConfiguration,
47 ) -> FirestoreResult<Self::Backend>;
48}
49
50#[cfg(feature = "caching-memory")]
52pub struct FirestoreMemoryCacheKind;
53
54#[cfg(feature = "caching-memory")]
55impl sealed::Sealed for FirestoreMemoryCacheKind {}
56
57#[cfg(feature = "caching-memory")]
58impl FirestoreCacheBackendKind for FirestoreMemoryCacheKind {
59 type Backend = FirestoreMemoryCacheBackend;
60 type Options = FirestoreMemoryCacheOptions;
61 type DefaultStorage = FirestoreMemListenStateStorage;
62
63 #[inline]
64 fn default_preload_mode() -> FirestoreCacheCollectionLoadMode {
65 FirestoreCacheCollectionLoadMode::PreloadAllDocs
67 }
68
69 #[inline]
70 fn create_default_storage() -> Self::DefaultStorage {
71 FirestoreMemListenStateStorage::new()
72 }
73
74 #[inline]
75 fn create_backend(
76 options: Self::Options,
77 config: FirestoreCacheConfiguration,
78 ) -> FirestoreResult<Self::Backend> {
79 FirestoreMemoryCacheBackend::with_options(config, options)
80 }
81}
82
83#[cfg(feature = "caching-persistent")]
85pub struct FirestorePersistentCacheKind;
86
87#[cfg(feature = "caching-persistent")]
88impl sealed::Sealed for FirestorePersistentCacheKind {}
89
90#[cfg(feature = "caching-persistent")]
91impl FirestoreCacheBackendKind for FirestorePersistentCacheKind {
92 type Backend = FirestorePersistentCacheBackend;
93 type Options = FirestorePersistentCacheOptions;
94 type DefaultStorage = FirestoreTempFilesListenStateStorage;
95
96 #[inline]
97 fn default_preload_mode() -> FirestoreCacheCollectionLoadMode {
98 FirestoreCacheCollectionLoadMode::PreloadAllIfEmpty
100 }
101
102 #[inline]
103 fn create_default_storage() -> Self::DefaultStorage {
104 FirestoreTempFilesListenStateStorage::new()
105 }
106
107 #[inline]
108 fn create_backend(
109 options: Self::Options,
110 config: FirestoreCacheConfiguration,
111 ) -> FirestoreResult<Self::Backend> {
112 match options.data_file_path {
113 Some(path) => FirestorePersistentCacheBackend::with_options(config, path),
114 None => FirestorePersistentCacheBackend::new(config),
115 }
116 }
117}
118
119#[derive(Debug, Clone)]
121pub struct FirestoreCacheCollection {
122 collection_name: String,
123 parent: Option<String>,
124 load_mode: FirestoreCacheCollectionLoadMode,
125 listener_target: Option<u32>,
126 documents: Option<Vec<String>>,
127}
128
129impl FirestoreCacheCollection {
130 #[inline]
141 pub fn new<S: AsRef<str>>(collection_name: S) -> Self {
142 Self::with_load_mode(
143 collection_name,
144 FirestoreCacheCollectionLoadMode::PreloadNone,
145 )
146 }
147
148 #[inline]
149 pub(crate) fn with_load_mode<S: AsRef<str>>(
150 collection_name: S,
151 load_mode: FirestoreCacheCollectionLoadMode,
152 ) -> Self {
153 Self {
154 collection_name: collection_name.as_ref().to_string(),
155 parent: None,
156 load_mode,
157 listener_target: None,
158 documents: None,
159 }
160 }
161
162 #[inline]
164 pub(crate) fn requested_listener_target(&self) -> Option<u32> {
165 self.listener_target
166 }
167
168 #[inline]
170 pub(crate) fn into_configuration(
171 self,
172 listener_target: FirestoreListenerTarget,
173 ) -> FirestoreCacheCollectionConfiguration {
174 let config = FirestoreCacheCollectionConfiguration::new(
175 &self.collection_name,
176 listener_target,
177 self.load_mode,
178 );
179 let config = match self.parent {
180 Some(parent) => config.with_parent(parent),
181 None => config,
182 };
183 match self.documents {
184 Some(document_ids) => config.with_documents(document_ids),
185 None => config,
186 }
187 }
188
189 #[inline]
193 pub fn parent<S: AsRef<str>>(self, parent: S) -> Self {
194 Self {
195 parent: Some(parent.as_ref().to_string()),
196 ..self
197 }
198 }
199
200 #[inline]
202 pub fn preload_all(self) -> Self {
203 Self {
204 load_mode: FirestoreCacheCollectionLoadMode::PreloadAllDocs,
205 ..self
206 }
207 }
208
209 #[inline]
211 pub fn preload_all_if_empty(self) -> Self {
212 Self {
213 load_mode: FirestoreCacheCollectionLoadMode::PreloadAllIfEmpty,
214 ..self
215 }
216 }
217
218 #[inline]
220 pub fn preload_none(self) -> Self {
221 Self {
222 load_mode: FirestoreCacheCollectionLoadMode::PreloadNone,
223 ..self
224 }
225 }
226
227 #[inline]
244 pub fn documents<I>(self, document_ids: I) -> Self
245 where
246 I: IntoIterator,
247 I::Item: AsRef<str>,
248 {
249 Self {
250 documents: Some(
251 document_ids
252 .into_iter()
253 .map(|id| id.as_ref().to_string())
254 .collect(),
255 ),
256 ..self
257 }
258 }
259
260 #[inline]
263 pub fn listener_target(self, target: u32) -> Self {
264 Self {
265 listener_target: Some(target),
266 ..self
267 }
268 }
269}
270
271impl From<FirestoreCacheCollectionConfiguration> for FirestoreCacheCollection {
272 fn from(config: FirestoreCacheCollectionConfiguration) -> Self {
273 Self {
274 documents: config.watched_document_ids().map(|ids| ids.to_vec()),
275 collection_name: config.collection_name,
276 parent: config.parent,
277 load_mode: config.collection_load_mode,
278 listener_target: Some(*config.listener_target.value()),
279 }
280 }
281}
282
283pub struct FirestoreCacheBuilder<K, LS = <K as FirestoreCacheBackendKind>::DefaultStorage>
302where
303 K: FirestoreCacheBackendKind,
304 LS: FirestoreResumeStateStorage + Send + Sync + 'static,
305{
306 db: FirestoreDb,
307 name: Option<FirestoreCacheName>,
308 listener_params: Option<FirestoreListenerParams>,
309 listener_target_base: u32,
310 collections: Vec<FirestoreCacheCollection>,
311 backend_options: K::Options,
312 storage: LS,
313 incomplete_collection_policy: FirestoreCacheIncompleteCollectionPolicy,
314}
315
316impl<K> FirestoreCacheBuilder<K, K::DefaultStorage>
317where
318 K: FirestoreCacheBackendKind,
319{
320 #[inline]
321 pub(crate) fn new(db: &FirestoreDb) -> Self {
322 Self {
323 db: db.clone(),
324 name: None,
325 listener_params: None,
326 listener_target_base: FIRESTORE_CACHE_DEFAULT_LISTENER_TARGET_BASE,
327 collections: Vec::new(),
328 backend_options: K::Options::default(),
329 storage: K::create_default_storage(),
330 incomplete_collection_policy: FirestoreCacheIncompleteCollectionPolicy::default(),
331 }
332 }
333}
334
335impl<K, LS> FirestoreCacheBuilder<K, LS>
336where
337 K: FirestoreCacheBackendKind,
338 LS: FirestoreResumeStateStorage + Send + Sync + 'static,
339{
340 #[inline]
342 pub fn name<S: Into<String>>(self, name: S) -> Self {
343 Self {
344 name: Some(FirestoreCacheName::new(name.into())),
345 ..self
346 }
347 }
348
349 #[inline]
354 pub fn collection<S: AsRef<str>>(mut self, collection_name: S) -> Self {
355 self.collections
356 .push(FirestoreCacheCollection::with_load_mode(
357 collection_name,
358 FirestoreCacheCollectionLoadMode::PreloadNone,
359 ));
360 self
361 }
362
363 #[inline]
365 pub fn preloaded_collection<S: AsRef<str>>(mut self, collection_name: S) -> Self {
366 self.collections
367 .push(FirestoreCacheCollection::with_load_mode(
368 collection_name,
369 K::default_preload_mode(),
370 ));
371 self
372 }
373
374 #[inline]
387 pub fn collection_with<S, F>(mut self, collection_name: S, f: F) -> Self
388 where
389 S: AsRef<str>,
390 F: FnOnce(FirestoreCacheCollection) -> FirestoreCacheCollection,
391 {
392 self.collections
393 .push(f(FirestoreCacheCollection::with_load_mode(
394 collection_name,
395 FirestoreCacheCollectionLoadMode::PreloadNone,
396 )));
397 self
398 }
399
400 #[inline]
402 pub fn collection_config(mut self, config: FirestoreCacheCollectionConfiguration) -> Self {
403 self.collections.push(config.into());
404 self
405 }
406
407 #[inline]
413 pub fn listener_target_base(self, listener_target_base: u32) -> Self {
414 Self {
415 listener_target_base,
416 ..self
417 }
418 }
419
420 #[inline]
422 pub fn listener_params(self, listener_params: FirestoreListenerParams) -> Self {
423 Self {
424 listener_params: Some(listener_params),
425 ..self
426 }
427 }
428
429 #[inline]
432 pub fn incomplete_collection_policy(
433 self,
434 incomplete_collection_policy: FirestoreCacheIncompleteCollectionPolicy,
435 ) -> Self {
436 Self {
437 incomplete_collection_policy,
438 ..self
439 }
440 }
441
442 #[inline]
446 pub fn listen_state_storage<LS2>(self, storage: LS2) -> FirestoreCacheBuilder<K, LS2>
447 where
448 LS2: FirestoreResumeStateStorage + Send + Sync + 'static,
449 {
450 FirestoreCacheBuilder {
451 db: self.db,
452 name: self.name,
453 listener_params: self.listener_params,
454 listener_target_base: self.listener_target_base,
455 collections: self.collections,
456 backend_options: self.backend_options,
457 storage,
458 incomplete_collection_policy: self.incomplete_collection_policy,
459 }
460 }
461
462 pub async fn build(self) -> FirestoreResult<FirestoreCache<K::Backend, LS>> {
464 let cache = self.build_without_load().await?;
465 cache.load().await?;
466 Ok(cache)
467 }
468
469 pub async fn build_without_load(self) -> FirestoreResult<FirestoreCache<K::Backend, LS>> {
475 let config = build_configuration(
476 self.db.get_documents_path(),
477 self.listener_target_base,
478 &self.collections,
479 self.incomplete_collection_policy,
480 )?;
481
482 let backend = K::create_backend(self.backend_options, config)?;
483
484 let name = self
485 .name
486 .unwrap_or_else(|| FirestoreCacheName::new(FIRESTORE_CACHE_DEFAULT_NAME.to_string()));
487
488 let options = FirestoreCacheOptions::new(name).opt_listener_params(self.listener_params);
489
490 FirestoreCache::create(options, &self.db, backend, self.storage).await
491 }
492}
493
494#[cfg(feature = "caching-memory")]
495impl<LS> FirestoreCacheBuilder<FirestoreMemoryCacheKind, LS>
496where
497 LS: FirestoreResumeStateStorage + Send + Sync + 'static,
498{
499 #[inline]
501 pub fn max_capacity(mut self, max_capacity: u64) -> Self {
502 self.backend_options = self.backend_options.with_max_capacity(max_capacity);
503 self
504 }
505
506 #[inline]
508 pub fn time_to_live(mut self, time_to_live: std::time::Duration) -> Self {
509 self.backend_options = self.backend_options.with_time_to_live(time_to_live);
510 self
511 }
512
513 #[inline]
515 pub fn time_to_idle(mut self, time_to_idle: std::time::Duration) -> Self {
516 self.backend_options = self.backend_options.with_time_to_idle(time_to_idle);
517 self
518 }
519}
520
521#[cfg(feature = "caching-persistent")]
522impl<LS> FirestoreCacheBuilder<FirestorePersistentCacheKind, LS>
523where
524 LS: FirestoreResumeStateStorage + Send + Sync + 'static,
525{
526 #[inline]
531 pub fn data_file_path<P: Into<std::path::PathBuf>>(mut self, path: P) -> Self {
532 self.backend_options = self.backend_options.with_data_file_path(path.into());
533 self
534 }
535}
536
537#[cfg(feature = "caching-persistent")]
538impl FirestoreCacheBuilder<FirestorePersistentCacheKind, FirestoreTempFilesListenStateStorage> {
539 #[inline]
545 pub fn data_dir<P: AsRef<std::path::Path>>(mut self, dir: P) -> Self {
546 let dir = dir.as_ref();
547 self.backend_options = self.backend_options.with_data_file_path(dir.join("redb"));
548 self.storage = FirestoreTempFilesListenStateStorage::with_temp_dir(dir);
549 self
550 }
551}
552
553fn build_configuration(
558 documents_path: &str,
559 listener_target_base: u32,
560 collections: &[FirestoreCacheCollection],
561 incomplete_collection_policy: FirestoreCacheIncompleteCollectionPolicy,
562) -> FirestoreResult<FirestoreCacheConfiguration> {
563 let mut used_targets: HashSet<u32> = HashSet::new();
569 for collection in collections {
570 if let Some(target) = collection.listener_target {
571 if !used_targets.insert(target) {
572 return Err(FirestoreError::invalid_parameters(
573 "listener_target",
574 format!(
575 "Listener target {target} was requested by more than one cached \
576 collection. Target IDs must be unique."
577 ),
578 ));
579 }
580 }
581 }
582
583 let mut config = FirestoreCacheConfiguration::new()
584 .with_incomplete_collection_policy(incomplete_collection_policy);
585 let mut next_target = listener_target_base;
586 let mut seen_paths: HashSet<String> = HashSet::new();
587
588 for collection in collections {
589 let target = match collection.listener_target {
590 Some(target) => target,
591 None => {
592 while used_targets.contains(&next_target) {
593 next_target = next_target.checked_add(1).ok_or_else(|| {
594 FirestoreError::invalid_parameters(
595 "listener_target_base",
596 "Ran out of listener target IDs while assigning them automatically.",
597 )
598 })?;
599 }
600 used_targets.insert(next_target);
601 next_target
602 }
603 };
604
605 let listener_target = FirestoreListenerTarget::new(target);
606 listener_target.validate()?;
607
608 let mut collection_config = FirestoreCacheCollectionConfiguration::new(
609 &collection.collection_name,
610 listener_target,
611 collection.load_mode,
612 );
613 if let Some(ref parent) = collection.parent {
614 collection_config = collection_config.with_parent(parent);
615 }
616 if let Some(ref documents) = collection.documents {
617 collection_config = collection_config.with_documents(documents);
618 }
619
620 let collection_path = collection_config.resolve_collection_path(documents_path);
621 if !seen_paths.insert(collection_path.clone()) {
622 return Err(FirestoreError::invalid_parameters(
623 "collection_name",
624 format!(
625 "The collection `{collection_path}` was configured more than once for \
626 this cache."
627 ),
628 ));
629 }
630
631 config = config.add_collection_config_at(documents_path, collection_config);
632 }
633
634 Ok(config)
635}
636
637#[cfg(test)]
638mod tests {
639 use super::*;
640
641 const DOCS: &str = "projects/test/databases/(default)/documents";
642
643 fn lazy(name: &str) -> FirestoreCacheCollection {
644 FirestoreCacheCollection::with_load_mode(
645 name,
646 FirestoreCacheCollectionLoadMode::PreloadNone,
647 )
648 }
649
650 fn target_of(config: &FirestoreCacheConfiguration, path: &str) -> u32 {
651 *config.collections[path].listener_target.value()
652 }
653
654 #[test]
655 fn assigns_listener_targets_sequentially_from_the_base() {
656 let config = build_configuration(
657 DOCS,
658 1000,
659 &[lazy("a"), lazy("b"), lazy("c")],
660 FirestoreCacheIncompleteCollectionPolicy::default(),
661 )
662 .unwrap();
663
664 assert_eq!(target_of(&config, &format!("{DOCS}/a")), 1000);
665 assert_eq!(target_of(&config, &format!("{DOCS}/b")), 1001);
666 assert_eq!(target_of(&config, &format!("{DOCS}/c")), 1002);
667 }
668
669 #[test]
670 fn honours_a_custom_listener_target_base() {
671 let config = build_configuration(
672 DOCS,
673 5000,
674 &[lazy("a")],
675 FirestoreCacheIncompleteCollectionPolicy::default(),
676 )
677 .unwrap();
678
679 assert_eq!(target_of(&config, &format!("{DOCS}/a")), 5000);
680 }
681
682 #[test]
683 fn automatic_targets_do_not_collide_with_explicit_ones() {
684 let config = build_configuration(
685 DOCS,
686 1000,
687 &[lazy("a"), lazy("b").listener_target(1000), lazy("c")],
688 FirestoreCacheIncompleteCollectionPolicy::default(),
689 )
690 .unwrap();
691
692 assert_eq!(target_of(&config, &format!("{DOCS}/b")), 1000);
693 assert_eq!(target_of(&config, &format!("{DOCS}/a")), 1001);
694 assert_eq!(target_of(&config, &format!("{DOCS}/c")), 1002);
695 }
696
697 #[test]
698 fn rejects_duplicate_explicit_targets() {
699 let err = build_configuration(
700 DOCS,
701 1000,
702 &[lazy("a").listener_target(7), lazy("b").listener_target(7)],
703 FirestoreCacheIncompleteCollectionPolicy::default(),
704 )
705 .unwrap_err();
706
707 assert!(matches!(err, FirestoreError::InvalidParametersError(_)));
708 }
709
710 #[test]
711 fn rejects_the_same_collection_twice() {
712 let err = build_configuration(
713 DOCS,
714 1000,
715 &[lazy("a"), lazy("a")],
716 FirestoreCacheIncompleteCollectionPolicy::default(),
717 )
718 .unwrap_err();
719
720 assert!(matches!(err, FirestoreError::InvalidParametersError(_)));
721 }
722
723 #[test]
724 fn allows_an_empty_collection_list_for_a_cache_filled_at_runtime() {
725 let config = build_configuration(
726 DOCS,
727 1000,
728 &[],
729 FirestoreCacheIncompleteCollectionPolicy::default(),
730 )
731 .unwrap();
732
733 assert!(config.collections.is_empty());
734 }
735
736 #[test]
737 fn rejects_the_reserved_zero_target() {
738 let err = build_configuration(
739 DOCS,
740 0,
741 &[lazy("a")],
742 FirestoreCacheIncompleteCollectionPolicy::default(),
743 )
744 .unwrap_err();
745
746 assert!(matches!(err, FirestoreError::InvalidParametersError(_)));
747 }
748
749 #[test]
750 fn resolves_sub_collections_under_their_parent() {
751 let parent = format!("{DOCS}/users/user-1");
752 let config = build_configuration(
753 DOCS,
754 1000,
755 &[lazy("orders").parent(&parent)],
756 FirestoreCacheIncompleteCollectionPolicy::default(),
757 )
758 .unwrap();
759
760 assert!(config.collections.contains_key(&format!("{parent}/orders")));
761 }
762
763 #[test]
764 fn only_preloaded_collections_are_listable() {
765 let config = build_configuration(
766 DOCS,
767 1000,
768 &[lazy("lazy_one"), lazy("preloaded").preload_all()],
769 FirestoreCacheIncompleteCollectionPolicy::default(),
770 )
771 .unwrap();
772
773 assert!(!config.is_collection_listable(&format!("{DOCS}/lazy_one")));
774 assert!(config.is_collection_listable(&format!("{DOCS}/preloaded")));
775 assert!(!config.is_collection_listable(&format!("{DOCS}/not_configured")));
776 }
777
778 #[test]
779 fn supports_many_collections_with_mixed_modes_in_one_cache() {
780 let parent = format!("{DOCS}/tenants/tenant-1");
781 let config = build_configuration(
782 DOCS,
783 1000,
784 &[
785 lazy("users"),
786 lazy("countries").preload_all(),
787 lazy("currencies").preload_all_if_empty(),
788 lazy("sessions").listener_target(2500),
789 lazy("orders").parent(&parent).preload_all(),
790 ],
791 FirestoreCacheIncompleteCollectionPolicy::default(),
792 )
793 .unwrap();
794
795 assert_eq!(config.collections.len(), 5);
796
797 let targets: HashSet<u32> = config
799 .collections
800 .values()
801 .map(|c| *c.listener_target.value())
802 .collect();
803 assert_eq!(targets.len(), 5, "listener targets must be unique");
804 assert!(targets.contains(&2500), "explicit target must be honoured");
805
806 assert!(!config.is_collection_listable(&format!("{DOCS}/users")));
808 assert!(!config.is_collection_listable(&format!("{DOCS}/sessions")));
809 assert!(config.is_collection_listable(&format!("{DOCS}/countries")));
810 assert!(config.is_collection_listable(&format!("{DOCS}/currencies")));
811
812 assert!(config.is_collection_listable(&format!("{parent}/orders")));
814 }
815
816 #[test]
817 fn partial_results_policy_allows_listing_lazy_collections() {
818 let config = build_configuration(
819 DOCS,
820 1000,
821 &[lazy("lazy_one")],
822 FirestoreCacheIncompleteCollectionPolicy::PartialResults,
823 )
824 .unwrap();
825
826 assert!(config.is_collection_listable(&format!("{DOCS}/lazy_one")));
827 assert!(!config.is_collection_listable(&format!("{DOCS}/not_configured")));
829 }
830}