Skip to main content

firestore/cache/
builder.rs

1//! A builder for [`FirestoreCache`].
2//!
3//! This is the recommended way to create a cache. Compared with the raw
4//! [`FirestoreCache::new`] constructor it assigns Firestore listener target IDs automatically,
5//! picks a listener state storage that matches the backend, and loads the cache for you.
6
7use crate::*;
8use std::collections::HashSet;
9
10/// The first Firestore listener target ID assigned to cached collections.
11///
12/// Targets are handed out sequentially from here. If your application runs its own listeners,
13/// either keep their target IDs away from this range or move the cache's range with
14/// [`FirestoreCacheBuilder::listener_target_base`].
15pub const FIRESTORE_CACHE_DEFAULT_LISTENER_TARGET_BASE: u32 = 1000;
16
17/// The cache name used when none is given.
18pub const FIRESTORE_CACHE_DEFAULT_NAME: &str = "firestore-cache";
19
20mod sealed {
21    pub trait Sealed {}
22}
23
24/// Describes a cache backend flavour - in-memory or persistent - for [`FirestoreCacheBuilder`].
25///
26/// This trait is sealed: it exists so that one builder can serve both backends, and is not
27/// intended to be implemented outside this crate. To plug in your own storage, implement
28/// [`FirestoreCacheBackend`] and construct [`FirestoreCache`] directly.
29pub trait FirestoreCacheBackendKind: sealed::Sealed + Sized {
30    /// The backend this kind builds.
31    type Backend: FirestoreCacheBackend + Send + Sync + 'static;
32    /// Backend-specific tuning options.
33    type Options: Default;
34    /// The listener state storage used unless overridden.
35    type DefaultStorage: FirestoreResumeStateStorage + Send + Sync + 'static;
36
37    /// The load mode used by [`FirestoreCacheBuilder::preloaded_collection`].
38    fn default_preload_mode() -> FirestoreCacheCollectionLoadMode;
39
40    /// Creates the default listener state storage for this backend.
41    fn create_default_storage() -> Self::DefaultStorage;
42
43    /// Creates the backend itself.
44    fn create_backend(
45        options: Self::Options,
46        config: FirestoreCacheConfiguration,
47    ) -> FirestoreResult<Self::Backend>;
48}
49
50/// The in-memory backend flavour. See [`FirestoreCache::memory`].
51#[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        // An in-memory cache always starts empty, so PreloadAllIfEmpty would be equivalent.
66        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/// The persistent backend flavour. See [`FirestoreCache::persistent`].
84#[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        // Data survives restarts, so avoid re-downloading a collection a previous run stored.
99        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/// One collection to cache, as described to [`FirestoreCacheBuilder`].
120#[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    /// Describes a collection to cache, filled lazily unless a preloading mode is chosen.
131    ///
132    /// This is what [`FirestoreCache::add_collection`](crate::FirestoreCache::add_collection)
133    /// takes; the builder has its own `collection` / `preloaded_collection` / `collection_with`
134    /// methods for describing collections up front.
135    ///
136    /// ```rust,no_run
137    /// # use firestore::*;
138    /// let collection = FirestoreCacheCollection::new("orders").preload_all();
139    /// ```
140    #[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    /// The listener target this collection was pinned to, if any.
163    #[inline]
164    pub(crate) fn requested_listener_target(&self) -> Option<u32> {
165        self.listener_target
166    }
167
168    /// Turns this description into a configuration entry using the given listener target.
169    #[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    /// Caches a sub-collection under the given parent document path.
190    ///
191    /// Build the parent with [`FirestoreDb::parent_path`].
192    #[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    /// Downloads every document at startup. Enables cached `list` and `query`.
201    #[inline]
202    pub fn preload_all(self) -> Self {
203        Self {
204            load_mode: FirestoreCacheCollectionLoadMode::PreloadAllDocs,
205            ..self
206        }
207    }
208
209    /// Downloads every document only when the cache is empty. Enables cached `list` and `query`.
210    #[inline]
211    pub fn preload_all_if_empty(self) -> Self {
212        Self {
213            load_mode: FirestoreCacheCollectionLoadMode::PreloadAllIfEmpty,
214            ..self
215        }
216    }
217
218    /// Fills the cache lazily. Reads by ID are cached; `list` and `query` are not.
219    #[inline]
220    pub fn preload_none(self) -> Self {
221        Self {
222            load_mode: FirestoreCacheCollectionLoadMode::PreloadNone,
223            ..self
224        }
225    }
226
227    /// Caches and listens to only these documents of the collection.
228    ///
229    /// The listener watches exactly these documents, so unrelated changes in the collection are
230    /// never streamed to your process. This is the shape to reach for when you cache a known set
231    /// of documents - configuration, feature flags, reference data - rather than a collection:
232    /// `.collection(name)` subscribes to the whole collection even when it does not preload it.
233    ///
234    /// Such a collection is never listable, whatever its load mode: it holds a chosen subset, so
235    /// `list` and `query` would return a partial answer that looks complete.
236    ///
237    /// ```rust,no_run
238    /// # use firestore::*;
239    /// let collection = FirestoreCacheCollection::new("configs")
240    ///     .documents(["site", "billing"])
241    ///     .preload_all();
242    /// ```
243    #[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    /// Pins this collection to a specific Firestore listener target ID instead of an
261    /// automatically assigned one.
262    #[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
283/// Builds a [`FirestoreCache`].
284///
285/// Created with [`FirestoreCache::memory`] or [`FirestoreCache::persistent`].
286///
287/// ```rust,no_run
288/// # use firestore::*;
289/// # async fn example(db: &FirestoreDb) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
290/// let cache = FirestoreCache::memory(db)
291///     .preloaded_collection("countries")
292///     .collection("users")
293///     .build()
294///     .await?;
295/// # Ok(())
296/// # }
297/// ```
298///
299/// Note that [`listen_state_storage`](Self::listen_state_storage) changes the builder's type, so
300/// call it last if you use it.
301pub 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    /// Names the cache. Used in logs and tracing spans. Defaults to `firestore-cache`.
341    #[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    /// Caches a collection lazily: reads by ID populate the cache, but `list` and `query` are not
350    /// served from it because only part of the collection is ever present.
351    ///
352    /// Use [`preloaded_collection`](Self::preloaded_collection) if you need cached listings.
353    #[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    /// Caches a complete copy of a collection, so that `list` and `query` can be served from it.
364    #[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    /// Caches a collection, configured through a closure.
375    ///
376    /// ```rust,no_run
377    /// # use firestore::*;
378    /// # async fn example(db: &FirestoreDb) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
379    /// let cache = FirestoreCache::memory(db)
380    ///     .collection_with("orders", |c| c.preload_all().listener_target(9000))
381    ///     .build()
382    ///     .await?;
383    /// # Ok(())
384    /// # }
385    /// ```
386    #[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    /// Adds a collection from an existing [`FirestoreCacheCollectionConfiguration`].
401    #[inline]
402    pub fn collection_config(mut self, config: FirestoreCacheCollectionConfiguration) -> Self {
403        self.collections.push(config.into());
404        self
405    }
406
407    /// Sets the first Firestore listener target ID to assign. Defaults to
408    /// [`FIRESTORE_CACHE_DEFAULT_LISTENER_TARGET_BASE`].
409    ///
410    /// Target IDs must be unique across every listener in your application, so move this range if
411    /// you run listeners of your own.
412    #[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    /// Configures the cache's internal Firestore listener.
421    #[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    /// Controls whether collections that are not preloaded may serve `list`/`query` from the
430    /// cache. The default refuses to, because the results would be silently partial.
431    #[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    /// Overrides where the internal listener stores its resume tokens.
443    ///
444    /// This changes the builder's type, so call it last.
445    #[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    /// Creates the cache, loads it and starts listening for changes.
463    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    /// Creates the cache without loading it.
470    ///
471    /// You must call [`FirestoreCache::load`] yourself before the cache serves anything or
472    /// receives updates. Prefer [`build`](Self::build) unless you need to control when loading
473    /// happens.
474    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    /// Sets the maximum number of documents kept per collection. Defaults to 50 000.
500    #[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    /// Evicts a document this long after it was written.
507    #[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    /// Evicts a document this long after it was last read.
514    #[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    /// Stores the cache database at an explicit file path.
527    ///
528    /// Prefer [`data_dir`](Self::data_dir), which also keeps the listener resume tokens beside
529    /// the data.
530    #[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    /// Stores both the cache database and the listener resume tokens in the given directory.
540    ///
541    /// This is the recommended way to configure a persistent cache: keeping the two together
542    /// means they are lost or kept as a unit, so the cache can never resume from a token that
543    /// does not match its data.
544    #[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
553/// Turns the collections described to the builder into a [`FirestoreCacheConfiguration`],
554/// assigning listener target IDs.
555///
556/// Kept free of [`FirestoreDb`] so that it can be unit-tested without credentials.
557fn build_configuration(
558    documents_path: &str,
559    listener_target_base: u32,
560    collections: &[FirestoreCacheCollection],
561    incomplete_collection_policy: FirestoreCacheIncompleteCollectionPolicy,
562) -> FirestoreResult<FirestoreCacheConfiguration> {
563    // An empty cache is allowed: collections can be added at runtime with
564    // `FirestoreCache::add_collection`.
565
566    // Explicitly requested targets are reserved first, so that automatic assignment can route
567    // around them regardless of the order collections were added in.
568    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        // Every collection gets its own listener target, and they are all distinct.
798        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        // Load modes are tracked per collection, not per cache.
807        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        // Sub-collections live under their parent and work alongside root collections.
813        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        // Still false for a collection the cache does not know about at all.
828        assert!(!config.is_collection_listable(&format!("{DOCS}/not_configured")));
829    }
830}