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}