Skip to main content

alien_bindings/providers/kv/
gcp_firestore.rs

1use crate::error::{ErrorData, Result};
2use crate::traits::{Binding, Kv, PutOptions, ScanResult};
3use alien_error::{AlienError, Context, IntoAlienError};
4use alien_gcp_clients::firestore::{
5    CollectionSelector, Direction, Document, FieldFilter, FieldFilterOperator, FieldReference,
6    Filter, FirestoreApi, FirestoreClient, Order, QueryType, RunQueryRequest, StructuredQuery,
7    Value,
8};
9use async_trait::async_trait;
10use base64::{self, Engine};
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::fmt::{Debug, Formatter};
15
16use super::{validate_key, validate_value};
17
18/// Firestore document for KV storage
19#[derive(Debug, Clone, Serialize, Deserialize)]
20struct KvDocument {
21    value: String, // Base64-encoded binary data
22    created_at: DateTime<Utc>,
23    expires_at: Option<DateTime<Utc>>, // For TTL policy
24}
25
26/// GCP Firestore implementation of the KV trait
27pub struct GcpFirestoreKv {
28    client: FirestoreClient,
29    project_id: String,
30    database_id: String,
31    collection_name: String,
32}
33
34impl Debug for GcpFirestoreKv {
35    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
36        f.debug_struct("GcpFirestoreKv")
37            .field("project_id", &self.project_id)
38            .field("database_id", &self.database_id)
39            .field("collection_name", &self.collection_name)
40            .finish()
41    }
42}
43
44impl GcpFirestoreKv {
45    pub fn new(
46        client: FirestoreClient,
47        project_id: String,
48        database_id: String,
49        collection_name: String,
50    ) -> Result<Self> {
51        Ok(Self {
52            client,
53            project_id,
54            database_id,
55            collection_name,
56        })
57    }
58
59    /// Checks if an item has expired based on TTL
60    fn is_expired(&self, expires_at: Option<DateTime<Utc>>) -> bool {
61        if let Some(expiry) = expires_at {
62            Utc::now() >= expiry
63        } else {
64            false
65        }
66    }
67
68    /// Converts a KV document to Firestore Document format
69    fn kv_document_to_firestore(&self, _key: &str, kv_doc: &KvDocument) -> Document {
70        let mut fields = HashMap::new();
71
72        fields.insert(
73            "value".to_string(),
74            Value::StringValue(kv_doc.value.clone()),
75        );
76        fields.insert(
77            "created_at".to_string(),
78            Value::TimestampValue(kv_doc.created_at.to_rfc3339()),
79        );
80
81        if let Some(expires_at) = kv_doc.expires_at {
82            fields.insert(
83                "expires_at".to_string(),
84                Value::TimestampValue(expires_at.to_rfc3339()),
85            );
86        }
87
88        Document::builder().fields(fields).build()
89    }
90
91    /// Converts a KV document to Firestore Document format with name (for updates)
92    fn kv_document_to_firestore_with_name(&self, key: &str, kv_doc: &KvDocument) -> Document {
93        let mut fields = HashMap::new();
94
95        fields.insert(
96            "value".to_string(),
97            Value::StringValue(kv_doc.value.clone()),
98        );
99        fields.insert(
100            "created_at".to_string(),
101            Value::TimestampValue(kv_doc.created_at.to_rfc3339()),
102        );
103
104        if let Some(expires_at) = kv_doc.expires_at {
105            fields.insert(
106                "expires_at".to_string(),
107                Value::TimestampValue(expires_at.to_rfc3339()),
108            );
109        }
110
111        Document::builder()
112            .name(format!(
113                "projects/{}/databases/{}/documents/{}/{}",
114                self.project_id, self.database_id, self.collection_name, key
115            ))
116            .fields(fields)
117            .build()
118    }
119
120    /// Converts a Firestore Document to KV document
121    /// Attempt to take over a logically-expired document after a
122    /// conditional create conflict. Returns `Ok(true)` only when THIS
123    /// caller replaced the expired document (an `updateTime` precondition
124    /// makes the replace atomic — a racing taker bumps the update time and
125    /// this patch loses); a live document, a lost race, or a deletion in
126    /// between all resolve to `Ok(false)`.
127    async fn try_take_over_expired(&self, key: &str, document: &Document) -> Result<bool> {
128        use alien_client_core::ErrorData as CloudErrorData;
129        use alien_gcp_clients::gcp::firestore::{Precondition, PreconditionType};
130
131        let document_path = format!("{}/{}", self.collection_name, key);
132        let existing = match self
133            .client
134            .get_document(
135                self.database_id.clone(),
136                document_path.clone(),
137                None,
138                None,
139                None,
140            )
141            .await
142        {
143            Ok(existing) => existing,
144            Err(e)
145                if matches!(
146                    e.error.as_ref(),
147                    Some(CloudErrorData::RemoteResourceNotFound { .. })
148                ) =>
149            {
150                return Ok(false);
151            }
152            Err(e) => {
153                return Err(crate::error::map_cloud_client_error(
154                    e,
155                    format!("Failed to read existing document for key '{}'", key),
156                    Some(key.to_string()),
157                ));
158            }
159        };
160
161        let kv_doc = self.firestore_to_kv_document(&existing)?;
162        if !self.is_expired(kv_doc.expires_at) {
163            return Ok(false);
164        }
165        let Some(update_time) = existing.update_time.clone() else {
166            return Ok(false);
167        };
168
169        match self
170            .client
171            .patch_document(
172                self.database_id.clone(),
173                document_path,
174                document.clone(),
175                None,
176                None,
177                Some(Precondition {
178                    condition: PreconditionType::UpdateTime(update_time),
179                }),
180            )
181            .await
182        {
183            Ok(_) => Ok(true),
184            Err(e) => match e.error.as_ref() {
185                // A lost race: the precondition mismatch maps to Conflict
186                // (FAILED_PRECONDITION → RemoteResourceConflict in
187                // map_gcp_error), and a deletion in between maps to NotFound.
188                Some(CloudErrorData::RemoteResourceConflict { .. })
189                | Some(CloudErrorData::RemoteResourceNotFound { .. }) => Ok(false),
190                _ => Err(crate::error::map_cloud_client_error(
191                    e,
192                    format!("Failed to take over expired document for key '{}'", key),
193                    Some(key.to_string()),
194                )),
195            },
196        }
197    }
198
199    fn firestore_to_kv_document(&self, doc: &Document) -> Result<KvDocument> {
200        let fields = doc.fields.as_ref().ok_or_else(|| {
201            AlienError::new(ErrorData::UnexpectedResponseFormat {
202                provider: "gcp".to_string(),
203                binding_name: "firestore".to_string(),
204                field: "fields".to_string(),
205                response_json: serde_json::to_string(doc).unwrap_or_default(),
206            })
207        })?;
208
209        let value = match fields.get("value") {
210            Some(Value::StringValue(v)) => v.clone(),
211            _ => {
212                return Err(AlienError::new(ErrorData::UnexpectedResponseFormat {
213                    provider: "gcp".to_string(),
214                    binding_name: "firestore".to_string(),
215                    field: "value".to_string(),
216                    response_json: serde_json::to_string(doc).unwrap_or_default(),
217                }))
218            }
219        };
220
221        let created_at = match fields.get("created_at") {
222            Some(Value::TimestampValue(t)) => DateTime::parse_from_rfc3339(t)
223                .map_err(|_| {
224                    AlienError::new(ErrorData::UnexpectedResponseFormat {
225                        provider: "gcp".to_string(),
226                        binding_name: "firestore".to_string(),
227                        field: "created_at".to_string(),
228                        response_json: serde_json::to_string(doc).unwrap_or_default(),
229                    })
230                })?
231                .with_timezone(&Utc),
232            _ => {
233                return Err(AlienError::new(ErrorData::UnexpectedResponseFormat {
234                    provider: "gcp".to_string(),
235                    binding_name: "firestore".to_string(),
236                    field: "created_at".to_string(),
237                    response_json: serde_json::to_string(doc).unwrap_or_default(),
238                }))
239            }
240        };
241
242        let expires_at = match fields.get("expires_at") {
243            Some(Value::TimestampValue(t)) => Some(
244                DateTime::parse_from_rfc3339(t)
245                    .map_err(|_| {
246                        AlienError::new(ErrorData::UnexpectedResponseFormat {
247                            provider: "gcp".to_string(),
248                            binding_name: "firestore".to_string(),
249                            field: "expires_at".to_string(),
250                            response_json: serde_json::to_string(doc).unwrap_or_default(),
251                        })
252                    })?
253                    .with_timezone(&Utc),
254            ),
255            _ => None,
256        };
257
258        Ok(KvDocument {
259            value,
260            created_at,
261            expires_at,
262        })
263    }
264}
265
266impl Binding for GcpFirestoreKv {}
267
268#[async_trait]
269impl Kv for GcpFirestoreKv {
270    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>> {
271        validate_key(key)?;
272
273        let document_id = key;
274        let document_path = format!("{}/{}", self.collection_name, document_id);
275
276        match self
277            .client
278            .get_document(self.database_id.clone(), document_path, None, None, None)
279            .await
280        {
281            Ok(doc) => {
282                let kv_doc = self.firestore_to_kv_document(&doc)?;
283
284                // Check TTL expiry (logical expiry contract)
285                if self.is_expired(kv_doc.expires_at) {
286                    return Ok(None); // Logically expired
287                }
288
289                let value = base64::engine::general_purpose::STANDARD
290                    .decode(&kv_doc.value)
291                    .into_alien_error()
292                    .context(ErrorData::KvOperationFailed {
293                        operation: "get".to_string(),
294                        key: key.to_string(),
295                        reason: "Failed to decode base64 value".to_string(),
296                    })?;
297
298                Ok(Some(value))
299            }
300            Err(e) => {
301                // Check if this is a "not found" error
302                match &e.error {
303                    Some(alien_client_core::ErrorData::RemoteResourceNotFound { .. }) => {
304                        Ok(None) // Document doesn't exist
305                    }
306                    _ => Err(crate::error::map_cloud_client_error(
307                        e,
308                        "Failed to get Firestore document".to_string(),
309                        Some(key.to_string()),
310                    )),
311                }
312            }
313        }
314    }
315
316    async fn put(&self, key: &str, value: Vec<u8>, options: Option<PutOptions>) -> Result<bool> {
317        validate_key(key)?;
318        validate_value(&value)?;
319
320        let options = options.unwrap_or_default();
321
322        let encoded_value = base64::engine::general_purpose::STANDARD.encode(&value);
323        let kv_doc = KvDocument {
324            value: encoded_value,
325            created_at: Utc::now(),
326            expires_at: options.ttl.map(|d| Utc::now() + d),
327        };
328
329        let document = self.kv_document_to_firestore(key, &kv_doc);
330
331        if options.if_not_exists {
332            let document_id = key.to_string();
333            match self
334                .client
335                .create_document(
336                    self.database_id.clone(),
337                    self.collection_name.clone(),
338                    Some(document_id),
339                    document.clone(),
340                    None,
341                )
342                .await
343            {
344                Ok(_) => Ok(true),
345                Err(e) => {
346                    // Check if this is a conflict (document already exists)
347                    match &e.error {
348                        Some(alien_client_core::ErrorData::RemoteResourceConflict { .. }) => {
349                            // Expired documents count as ABSENT, matching the
350                            // local provider's atomic takeover: Firestore's
351                            // TTL deletion can lag the logical expiry by
352                            // hours, and without a takeover a conditional
353                            // put (e.g. a command lease after the previous
354                            // holder died) stays blocked until then.
355                            self.try_take_over_expired(key, &document).await
356                        }
357                        _ => Err(crate::error::map_cloud_client_error(
358                            e,
359                            "Failed to create Firestore document".to_string(),
360                            Some(key.to_string()),
361                        )),
362                    }
363                }
364            }
365        } else {
366            let document_id = key;
367            let document_path = format!("{}/{}", self.collection_name, document_id);
368            let document_with_name = self.kv_document_to_firestore_with_name(key, &kv_doc);
369
370            self.client
371                .patch_document(
372                    self.database_id.clone(),
373                    document_path,
374                    document_with_name,
375                    None,
376                    None,
377                    None,
378                )
379                .await
380                .map_err(|e| {
381                    crate::error::map_cloud_client_error(
382                        e,
383                        "Failed to patch Firestore document".to_string(),
384                        Some(key.to_string()),
385                    )
386                })?;
387
388            Ok(true)
389        }
390    }
391
392    async fn delete(&self, key: &str) -> Result<()> {
393        validate_key(key)?;
394
395        let document_id = key;
396        let document_path = format!("{}/{}", self.collection_name, document_id);
397
398        self.client
399            .delete_document(self.database_id.clone(), document_path, None)
400            .await
401            .map_err(|e| {
402                crate::error::map_cloud_client_error(
403                    e,
404                    "Failed to delete Firestore document".to_string(),
405                    Some(key.to_string()),
406                )
407            })?;
408
409        Ok(())
410    }
411
412    async fn exists(&self, key: &str) -> Result<bool> {
413        validate_key(key)?;
414
415        let document_id = key;
416        let document_path = format!("{}/{}", self.collection_name, document_id);
417
418        match self
419            .client
420            .get_document(self.database_id.clone(), document_path, None, None, None)
421            .await
422        {
423            Ok(doc) => {
424                let kv_doc = self.firestore_to_kv_document(&doc)?;
425
426                // Check TTL expiry (logical expiry contract)
427                Ok(!self.is_expired(kv_doc.expires_at))
428            }
429            Err(e) => {
430                match &e.error {
431                    Some(alien_client_core::ErrorData::RemoteResourceNotFound { .. }) => {
432                        Ok(false) // Document doesn't exist
433                    }
434                    _ => Err(crate::error::map_cloud_client_error(
435                        e,
436                        "Failed to get Firestore document".to_string(),
437                        Some(key.to_string()),
438                    )),
439                }
440            }
441        }
442    }
443
444    async fn scan_prefix(
445        &self,
446        prefix: &str,
447        limit: Option<usize>,
448        cursor: Option<String>,
449    ) -> Result<ScanResult> {
450        validate_key(prefix)?; // Prefix follows same key validation rules
451
452        let collection_selector = CollectionSelector::builder()
453            .collection_id(self.collection_name.clone())
454            .build();
455
456        let mut structured_query = StructuredQuery::builder()
457            .from(vec![collection_selector])
458            .order_by(vec![Order::builder()
459                .field(
460                    FieldReference::builder()
461                        .field_path("__name__".to_string())
462                        .build(),
463                )
464                .direction(Direction::Ascending)
465                .build()])
466            .build();
467
468        // Add prefix filter
469        if !prefix.is_empty() {
470            let document_id_prefix = prefix;
471            let prefix_filter = Filter::FieldFilter(
472                FieldFilter::builder()
473                    .field(
474                        FieldReference::builder()
475                            .field_path("__name__".to_string())
476                            .build(),
477                    )
478                    .op(FieldFilterOperator::GreaterThanOrEqual)
479                    .value(Value::ReferenceValue(format!(
480                        "projects/{}/databases/{}/documents/{}/{}",
481                        self.project_id, self.database_id, self.collection_name, document_id_prefix
482                    )))
483                    .build(),
484            );
485
486            structured_query.r#where = Some(prefix_filter);
487        }
488
489        if let Some(limit) = limit {
490            structured_query.limit = Some(limit as i32);
491        }
492
493        if let Some(ref cursor) = cursor {
494            // For simplicity, use offset-based pagination
495            if let Ok(offset) = cursor.parse::<i32>() {
496                structured_query.offset = Some(offset);
497            }
498        }
499
500        let query_request = RunQueryRequest::builder()
501            .parent(format!(
502                "projects/{}/databases/{}/documents",
503                self.project_id, self.database_id
504            ))
505            .query_type(QueryType::StructuredQuery(structured_query))
506            .build();
507
508        let query_responses = self
509            .client
510            .run_query(self.database_id.clone(), query_request)
511            .await
512            .map_err(|e| {
513                crate::error::map_cloud_client_error(
514                    e,
515                    "Failed to run Firestore query".to_string(),
516                    Some(prefix.to_string()),
517                )
518            })?;
519
520        let items: Vec<(String, Vec<u8>)> = query_responses
521            .iter()
522            .filter_map(|response| {
523                let doc = response.document.as_ref()?;
524                let doc_name = doc.name.as_ref()?;
525
526                // Extract document ID from document name
527                let document_id = doc_name.split('/').last()?.to_string();
528
529                // Document ID is now the key directly (no encoding needed)
530                let key = document_id;
531
532                // Check if key starts with prefix
533                if !key.starts_with(prefix) {
534                    return None;
535                }
536
537                let kv_doc = self.firestore_to_kv_document(doc).ok()?;
538
539                // Check TTL expiry
540                if self.is_expired(kv_doc.expires_at) {
541                    return None; // Skip expired items
542                }
543
544                let value = base64::engine::general_purpose::STANDARD
545                    .decode(&kv_doc.value)
546                    .ok()?;
547                Some((key, value))
548            })
549            .collect();
550
551        let next_cursor = if items.len() == limit.unwrap_or(usize::MAX) {
552            // Simple offset-based pagination
553            let current_offset = cursor
554                .as_ref()
555                .and_then(|c| c.parse::<usize>().ok())
556                .unwrap_or(0);
557            Some((current_offset + items.len()).to_string())
558        } else {
559            None
560        };
561
562        Ok(ScanResult { items, next_cursor })
563    }
564}