Skip to main content

firestore/cache/
configuration.rs

1use crate::errors::FirestoreError;
2use crate::{FirestoreDb, FirestoreListenerTarget, FirestoreResult};
3use rvstruct::ValueStruct;
4use std::collections::HashMap;
5
6/// Describes which collections a [`FirestoreCache`](crate::FirestoreCache) holds and how each of
7/// them is loaded.
8///
9/// Collections are keyed by their *absolute* document path (for example
10/// `projects/my-project/databases/(default)/documents/users`), which is why building a
11/// configuration needs a [`FirestoreDb`] to resolve the project and database.
12///
13/// You normally do not construct this directly — the cache builder
14/// ([`FirestoreCache::memory`](crate::FirestoreCache::memory) /
15/// [`FirestoreCache::persistent`](crate::FirestoreCache::persistent)) builds it for you.
16#[derive(Debug, Clone)]
17pub struct FirestoreCacheConfiguration {
18    /// The configured collections, keyed by absolute collection path.
19    pub collections: HashMap<String, FirestoreCacheCollectionConfiguration>,
20    /// Controls whether collections that are not fully preloaded may serve `list`/`query`
21    /// requests from the cache. See [`FirestoreCacheIncompleteCollectionPolicy`].
22    pub incomplete_collection_policy: FirestoreCacheIncompleteCollectionPolicy,
23}
24
25impl FirestoreCacheConfiguration {
26    /// Creates an empty configuration.
27    #[inline]
28    pub fn new() -> Self {
29        Self {
30            collections: HashMap::new(),
31            incomplete_collection_policy: FirestoreCacheIncompleteCollectionPolicy::default(),
32        }
33    }
34
35    /// Adds a collection to the configuration.
36    ///
37    /// The `db` reference is used to resolve the collection's absolute path.
38    #[inline]
39    pub fn add_collection_config(
40        self,
41        db: &FirestoreDb,
42        config: FirestoreCacheCollectionConfiguration,
43    ) -> Self {
44        self.add_collection_config_at(db.get_documents_path(), config)
45    }
46
47    /// Sets the policy for collections that are not fully preloaded.
48    #[inline]
49    pub fn with_incomplete_collection_policy(
50        self,
51        incomplete_collection_policy: FirestoreCacheIncompleteCollectionPolicy,
52    ) -> Self {
53        Self {
54            incomplete_collection_policy,
55            ..self
56        }
57    }
58
59    /// Same as [`add_collection_config`](Self::add_collection_config), but takes the resolved
60    /// documents path directly so that configurations can be built without a live `FirestoreDb`.
61    #[inline]
62    pub(crate) fn add_collection_config_at(
63        mut self,
64        documents_path: &str,
65        config: FirestoreCacheCollectionConfiguration,
66    ) -> Self {
67        let collection_path = config.resolve_collection_path(documents_path);
68        self.collections.insert(collection_path, config);
69        self
70    }
71
72    /// Decides what a Firestore target change means for the collections of this cache.
73    ///
74    /// A backend's [`on_listen_event`](crate::FirestoreCacheBackend::on_listen_event) should act on
75    /// this rather than interpreting the target change itself: it resolves Firestore's "empty
76    /// target IDs means all targets" rule, and scopes the result to the documents a target actually
77    /// covers.
78    #[inline]
79    pub fn target_change_action(
80        &self,
81        target_change: &gcloud_sdk::google::firestore::v1::TargetChange,
82    ) -> crate::FirestoreCacheTargetChangeAction {
83        crate::cache::cache_target_change_action(self, target_change)
84    }
85
86    /// Whether the cache holds this document: its collection is cached, and the collection is not
87    /// restricted to a different set of document IDs.
88    #[inline]
89    pub fn is_document_cached(&self, collection_path: &str, document_id: &str) -> bool {
90        match self.collections.get(collection_path) {
91            Some(config) => match config.watched_document_ids() {
92                Some(document_ids) => document_ids.iter().any(|id| id == document_id),
93                None => true,
94            },
95            None => false,
96        }
97    }
98
99    /// Returns the first listener target ID at or above `from` that no collection uses.
100    ///
101    /// Shared by the builder's up-front assignment and by adding a collection at runtime, so the
102    /// two cannot drift apart.
103    pub(crate) fn allocate_listener_target(
104        &self,
105        from: u32,
106    ) -> FirestoreResult<FirestoreListenerTarget> {
107        let used: std::collections::HashSet<u32> = self
108            .collections
109            .values()
110            .map(|config| *config.listener_target.value())
111            .collect();
112
113        let mut candidate = from.max(1);
114        while used.contains(&candidate) {
115            candidate = candidate.checked_add(1).ok_or_else(|| {
116                FirestoreError::invalid_parameters(
117                    "listener_target",
118                    "Ran out of listener target IDs while assigning them automatically.",
119                )
120            })?;
121        }
122
123        let target = FirestoreListenerTarget::new(candidate);
124        target.validate()?;
125        Ok(target)
126    }
127
128    /// The highest listener target ID in use, if any.
129    pub(crate) fn max_listener_target(&self) -> Option<u32> {
130        self.collections
131            .values()
132            .map(|config| *config.listener_target.value())
133            .max()
134    }
135
136    /// Returns what a listener target covers, or `None` if the target does not belong to this
137    /// cache.
138    ///
139    /// Used to scope the invalidation when Firestore resets or removes a target. A linear scan is
140    /// deliberate: the map is small, and this runs only on target changes, never per document.
141    pub(crate) fn target_scope(
142        &self,
143        target: &FirestoreListenerTarget,
144    ) -> Option<FirestoreCacheTargetScope<'_>> {
145        self.collections
146            .iter()
147            .find(|(_, config)| config.listener_target == *target)
148            .map(|(collection_path, config)| target_scope_of(collection_path, config))
149    }
150
151    /// Returns what every listener target of this cache covers.
152    ///
153    /// Firestore uses an empty set of target IDs to mean "all targets", which is what this is for.
154    pub(crate) fn all_target_scopes(&self) -> Vec<FirestoreCacheTargetScope<'_>> {
155        self.collections
156            .iter()
157            .map(|(collection_path, config)| target_scope_of(collection_path, config))
158            .collect()
159    }
160
161    /// Returns `true` when the cache is configured to hold a *complete* copy of the collection at
162    /// `collection_path`, meaning `list` and `query` requests may be served from the cache.
163    ///
164    /// Only preloaded collections ([`PreloadAllDocs`](FirestoreCacheCollectionLoadMode::PreloadAllDocs)
165    /// / [`PreloadAllIfEmpty`](FirestoreCacheCollectionLoadMode::PreloadAllIfEmpty)) qualify. A
166    /// [`PreloadNone`](FirestoreCacheCollectionLoadMode::PreloadNone) collection holds an
167    /// arbitrary subset of documents — whatever happens to have been read through it so far — so
168    /// serving a listing from it would silently return partial results.
169    ///
170    /// This can be relaxed with
171    /// [`FirestoreCacheIncompleteCollectionPolicy::PartialResults`].
172    #[inline]
173    pub fn is_collection_listable(&self, collection_path: &str) -> bool {
174        match self.collections.get(collection_path) {
175            // A collection watched by document ID holds only those documents, however it is
176            // loaded, so it can never answer a listing of the collection completely.
177            Some(config) if config.watched_document_ids().is_some() => false,
178            Some(config) => match self.incomplete_collection_policy {
179                FirestoreCacheIncompleteCollectionPolicy::SkipCache => {
180                    config.collection_load_mode.is_preloading()
181                }
182                FirestoreCacheIncompleteCollectionPolicy::PartialResults => true,
183            },
184            None => false,
185        }
186    }
187}
188
189impl Default for FirestoreCacheConfiguration {
190    #[inline]
191    fn default() -> Self {
192        Self::new()
193    }
194}
195
196/// Which documents of a collection the cache holds and listens to.
197///
198/// This is independent of [`FirestoreCacheCollectionLoadMode`]: that decides *when* documents are
199/// fetched, this decides *which* ones exist as far as the cache is concerned.
200#[derive(Debug, Clone, Eq, PartialEq)]
201pub enum FirestoreCacheCollectionWatch {
202    /// Listen to every document in the collection. The default.
203    WholeCollection,
204    /// Listen only to these document IDs.
205    ///
206    /// The listener watches exactly these documents rather than the whole collection, so unrelated
207    /// changes are never streamed to your process or written into the cache. Such a collection is
208    /// never listable: it holds a chosen subset, so answering `list` or `query` from it would
209    /// return a partial result that looks complete.
210    Documents(Vec<String>),
211}
212
213impl Default for FirestoreCacheCollectionWatch {
214    #[inline]
215    fn default() -> Self {
216        Self::WholeCollection
217    }
218}
219
220/// The scope of the listener target that keeps one cached collection up to date.
221#[inline]
222pub(crate) fn target_scope_of<'a>(
223    collection_path: &'a str,
224    config: &'a FirestoreCacheCollectionConfiguration,
225) -> FirestoreCacheTargetScope<'a> {
226    match config.watched_document_ids() {
227        Some(document_ids) => FirestoreCacheTargetScope::Documents {
228            collection_path,
229            document_ids,
230        },
231        None => FirestoreCacheTargetScope::Collection(collection_path),
232    }
233}
234
235/// What a Firestore listener target of this cache covers.
236///
237/// Every cached target is currently a whole-collection query, so only
238/// [`Collection`](Self::Collection) is produced today. Invalidation is written against this rather
239/// than against a bare collection path so that a target watching a handful of documents does not
240/// end up wiping the entire collection.
241#[derive(Debug, Clone, Eq, PartialEq)]
242pub(crate) enum FirestoreCacheTargetScope<'a> {
243    /// The target covers every document in the collection at this path.
244    Collection(&'a str),
245    /// The target covers only these document IDs within the collection at this path.
246    Documents {
247        collection_path: &'a str,
248        document_ids: &'a [String],
249    },
250}
251
252impl FirestoreCacheTargetScope<'_> {
253    /// The collection this scope belongs to.
254    #[inline]
255    pub(crate) fn collection_path(&self) -> &str {
256        match self {
257            Self::Collection(collection_path) => collection_path,
258            Self::Documents {
259                collection_path, ..
260            } => collection_path,
261        }
262    }
263}
264
265/// Controls whether collections that are not fully preloaded may serve `list`/`query` requests
266/// from the cache.
267#[derive(Debug, Clone, Copy, Eq, PartialEq, Default)]
268pub enum FirestoreCacheIncompleteCollectionPolicy {
269    /// The default and recommended behaviour.
270    ///
271    /// Only preloaded collections may serve `list`/`query` from the cache. For other collections
272    /// `read_through_cache` transparently falls back to Firestore, and `read_cached_only` returns
273    /// an error rather than a partial result.
274    #[default]
275    SkipCache,
276    /// The pre-0.52 behaviour: serve `list`/`query` from whatever the cache happens to hold.
277    ///
278    /// **Results may be silently partial.** Only use this if your application can tolerate an
279    /// incomplete view of a collection.
280    PartialResults,
281}
282
283/// Configuration for a single cached collection.
284#[derive(Debug, Clone)]
285pub struct FirestoreCacheCollectionConfiguration {
286    /// The collection ID, for example `users`.
287    pub collection_name: String,
288    /// An optional parent document path, for caching a sub-collection.
289    pub parent: Option<String>,
290    /// The Firestore listener target ID used to keep this collection up to date. It must be
291    /// unique across every listener in your application, not just within the cache.
292    pub listener_target: FirestoreListenerTarget,
293    /// How this collection is populated at startup, which also determines whether `list`/`query`
294    /// may be served from the cache.
295    pub collection_load_mode: FirestoreCacheCollectionLoadMode,
296    /// Which documents of the collection the cache holds and listens to.
297    pub collection_watch: FirestoreCacheCollectionWatch,
298    #[doc(hidden)]
299    /// Not implemented and has no effect. Scheduled for removal.
300    pub indices: Vec<FirestoreCacheIndexConfiguration>,
301}
302
303impl FirestoreCacheCollectionConfiguration {
304    /// Creates a configuration for a collection.
305    #[inline]
306    pub fn new<S>(
307        collection_name: S,
308        listener_target: FirestoreListenerTarget,
309        collection_load_mode: FirestoreCacheCollectionLoadMode,
310    ) -> Self
311    where
312        S: AsRef<str>,
313    {
314        Self {
315            collection_name: collection_name.as_ref().to_string(),
316            parent: None,
317            listener_target,
318            collection_load_mode,
319            collection_watch: FirestoreCacheCollectionWatch::WholeCollection,
320            indices: Vec::new(),
321        }
322    }
323
324    /// The listener target that keeps this collection up to date.
325    ///
326    /// Backends use this in [`load`](crate::FirestoreCacheBackend::load) so that a collection
327    /// limited to named documents is listened to as a documents target rather than as a query over
328    /// the whole collection.
329    #[inline]
330    pub fn listener_target_params(
331        &self,
332        resume_type: Option<crate::FirestoreListenerTargetResumeType>,
333    ) -> crate::FirestoreListenerTargetParams {
334        crate::cache::target_params_for_collection(self, resume_type)
335    }
336
337    /// The point in time a newly attached listener target should be resumed from.
338    ///
339    /// Deliberately a little behind the local clock: a read time in the server's future is rejected
340    /// as invalid, and a client clock can easily run slightly ahead. The cost is a few redundant
341    /// document changes, which are idempotent.
342    #[inline]
343    pub fn listener_read_time() -> crate::FirestoreInstant {
344        crate::cache::cache_target_read_time()
345    }
346
347    /// Caches and listens to only these documents of the collection, instead of all of them.
348    #[inline]
349    pub fn with_documents<I>(self, document_ids: I) -> Self
350    where
351        I: IntoIterator,
352        I::Item: AsRef<str>,
353    {
354        Self {
355            collection_watch: FirestoreCacheCollectionWatch::Documents(
356                document_ids
357                    .into_iter()
358                    .map(|id| id.as_ref().to_string())
359                    .collect(),
360            ),
361            ..self
362        }
363    }
364
365    /// The document IDs this collection is limited to, if any.
366    #[inline]
367    pub fn watched_document_ids(&self) -> Option<&[String]> {
368        match &self.collection_watch {
369            FirestoreCacheCollectionWatch::WholeCollection => None,
370            FirestoreCacheCollectionWatch::Documents(ids) => Some(ids),
371        }
372    }
373
374    /// Caches a sub-collection under the given parent document path.
375    ///
376    /// Use [`FirestoreDb::parent_path`](crate::FirestoreDb::parent_path) to build the parent.
377    #[inline]
378    pub fn with_parent<S>(self, parent: S) -> Self
379    where
380        S: AsRef<str>,
381    {
382        Self {
383            parent: Some(parent.as_ref().to_string()),
384            ..self
385        }
386    }
387
388    #[doc(hidden)]
389    #[deprecated(
390        since = "0.52.0",
391        note = "Cache indices are not implemented and have no effect. This method is scheduled for removal."
392    )]
393    #[inline]
394    pub fn with_index(self, index: FirestoreCacheIndexConfiguration) -> Self {
395        let mut indices = self.indices;
396        indices.push(index);
397        Self { indices, ..self }
398    }
399
400    /// Resolves the absolute path of this collection against the given documents path.
401    #[inline]
402    pub(crate) fn resolve_collection_path(&self, documents_path: &str) -> String {
403        match self.parent {
404            Some(ref parent) => format!("{}/{}", parent, self.collection_name),
405            None => format!("{}/{}", documents_path, self.collection_name),
406        }
407    }
408}
409
410/// Determines how a cached collection is populated when the cache starts.
411///
412/// This also decides whether `list` and `query` operations may be served from the cache: only
413/// preloaded collections hold a complete copy, so only they can answer a listing correctly.
414#[derive(Debug, Clone, Copy, Eq, PartialEq)]
415pub enum FirestoreCacheCollectionLoadMode {
416    /// Download every document in the collection at startup.
417    ///
418    /// Enables cached `list` and `query`.
419    PreloadAllDocs,
420    /// Download every document only if the cache is currently empty.
421    ///
422    /// Enables cached `list` and `query`. Mainly useful for the persistent backend, where it
423    /// avoids re-downloading a collection that a previous run already stored. For the in-memory
424    /// backend this is equivalent to [`PreloadAllDocs`](Self::PreloadAllDocs), since an in-memory
425    /// cache always starts empty.
426    PreloadAllIfEmpty,
427    /// Do not preload anything; fill the cache lazily as documents are read through it.
428    ///
429    /// Reads by ID are cached, but `list` and `query` are **not** served from the cache, because
430    /// the collection is only ever partially present.
431    PreloadNone,
432}
433
434impl FirestoreCacheCollectionLoadMode {
435    /// Returns `true` if this mode populates the collection completely at startup.
436    #[inline]
437    pub fn is_preloading(&self) -> bool {
438        matches!(self, Self::PreloadAllDocs | Self::PreloadAllIfEmpty)
439    }
440}
441
442#[doc(hidden)]
443/// Not implemented and has no effect. Scheduled for removal.
444#[derive(Debug, Clone)]
445pub struct FirestoreCacheIndexConfiguration {
446    pub fields: Vec<String>,
447    pub unique: bool,
448}
449
450impl FirestoreCacheIndexConfiguration {
451    #[inline]
452    pub fn new<I>(fields: I) -> Self
453    where
454        I: IntoIterator,
455        I::Item: AsRef<str>,
456    {
457        Self {
458            fields: fields.into_iter().map(|s| s.as_ref().to_string()).collect(),
459            unique: false,
460        }
461    }
462
463    #[inline]
464    pub fn unique(self, value: bool) -> Self {
465        Self {
466            unique: value,
467            ..self
468        }
469    }
470}