Skip to main content

fraiseql_storage/metadata/
mod.rs

1//! Object metadata storage and retrieval.
2//!
3//! Tracks uploaded objects in a PostgreSQL table (`_fraiseql_storage_objects`)
4//! for RLS enforcement, listing, and lifecycle management.
5
6#[cfg(test)]
7mod tests;
8
9use chrono::{DateTime, Utc};
10use fraiseql_error::{FileError, FraiseQLError};
11use sqlx::PgPool;
12
13use crate::backend::types::ObjectInfo;
14
15/// A row from the `_fraiseql_storage_objects` table.
16#[derive(Debug, Clone)]
17pub struct StorageMetadataRow {
18    /// Primary key.
19    pub pk_storage_object: i64,
20    /// Bucket name.
21    pub bucket:            String,
22    /// Object key (path within bucket).
23    pub key:               String,
24    /// MIME content type.
25    pub content_type:      String,
26    /// Object size in bytes.
27    pub size_bytes:        i64,
28    /// Entity tag for integrity verification.
29    pub etag:              Option<String>,
30    /// Owner identifier (user sub claim).
31    pub owner_id:          Option<String>,
32    /// Row creation time.
33    pub created_at:        DateTime<Utc>,
34    /// Last update time.
35    pub updated_at:        DateTime<Utc>,
36}
37
38/// Data required to insert a new storage object record.
39#[derive(Debug, Clone)]
40pub struct NewStorageObject {
41    /// Bucket name.
42    pub bucket:       String,
43    /// Object key (path within bucket).
44    pub key:          String,
45    /// MIME content type.
46    pub content_type: String,
47    /// Object size in bytes.
48    pub size_bytes:   i64,
49    /// Entity tag for integrity verification.
50    pub etag:         Option<String>,
51    /// Owner identifier (user sub claim).
52    pub owner_id:     Option<String>,
53}
54
55/// Storage metadata repository backed by PostgreSQL.
56pub struct StorageMetadataRepo {
57    pool: PgPool,
58}
59
60impl StorageMetadataRepo {
61    /// Create a new repository wrapping the given connection pool.
62    #[must_use]
63    pub const fn new(pool: PgPool) -> Self {
64        Self { pool }
65    }
66
67    /// Insert a new object metadata row, returning the generated primary key.
68    ///
69    /// # Errors
70    ///
71    /// Returns `FraiseQLError::File` if the database query fails
72    /// (e.g. duplicate `(bucket, key)` pair).
73    pub async fn insert(&self, row: &NewStorageObject) -> Result<i64, FraiseQLError> {
74        let (pk,): (i64,) = sqlx::query_as(
75            "INSERT INTO _fraiseql_storage_objects \
76                 (bucket, key, content_type, size_bytes, etag, owner_id) \
77             VALUES ($1, $2, $3, $4, $5, $6) \
78             RETURNING pk_storage_object",
79        )
80        .bind(&row.bucket)
81        .bind(&row.key)
82        .bind(&row.content_type)
83        .bind(row.size_bytes)
84        .bind(&row.etag)
85        .bind(&row.owner_id)
86        .fetch_one(&self.pool)
87        .await
88        .map_err(|e| {
89            FraiseQLError::File(FileError::Backend {
90                message: e.to_string(),
91                source:  Some(Box::new(e)),
92            })
93        })?;
94
95        Ok(pk)
96    }
97
98    /// Look up an object by bucket and key.
99    ///
100    /// # Errors
101    ///
102    /// Returns `FraiseQLError::File` if the database query fails.
103    pub async fn get(
104        &self,
105        bucket: &str,
106        key: &str,
107    ) -> Result<Option<StorageMetadataRow>, FraiseQLError> {
108        let row = sqlx::query_as::<_, MetadataQueryRow>(
109            "SELECT pk_storage_object, bucket, key, content_type, \
110                    size_bytes, etag, owner_id, created_at, updated_at \
111             FROM _fraiseql_storage_objects \
112             WHERE bucket = $1 AND key = $2",
113        )
114        .bind(bucket)
115        .bind(key)
116        .fetch_optional(&self.pool)
117        .await
118        .map_err(|e| {
119            FraiseQLError::File(FileError::Backend {
120                message: e.to_string(),
121                source:  Some(Box::new(e)),
122            })
123        })?;
124
125        Ok(row.map(Into::into))
126    }
127
128    /// Delete an object metadata row by bucket and key.
129    ///
130    /// Returns `true` if a row was actually deleted, `false` if no matching row existed.
131    ///
132    /// # Errors
133    ///
134    /// Returns `FraiseQLError::File` if the database query fails.
135    pub async fn delete(&self, bucket: &str, key: &str) -> Result<bool, FraiseQLError> {
136        let result =
137            sqlx::query("DELETE FROM _fraiseql_storage_objects WHERE bucket = $1 AND key = $2")
138                .bind(bucket)
139                .bind(key)
140                .execute(&self.pool)
141                .await
142                .map_err(|e| {
143                    FraiseQLError::File(FileError::Backend {
144                        message: e.to_string(),
145                        source:  Some(Box::new(e)),
146                    })
147                })?;
148
149        Ok(result.rows_affected() > 0)
150    }
151
152    /// List objects in a bucket, optionally filtered by key prefix.
153    ///
154    /// Results are ordered by key ascending. Use `limit` and `offset` for pagination.
155    ///
156    /// # Errors
157    ///
158    /// Returns `FraiseQLError::File` if the database query fails.
159    pub async fn list(
160        &self,
161        bucket: &str,
162        prefix: Option<&str>,
163        limit: u32,
164        offset: u32,
165    ) -> Result<Vec<StorageMetadataRow>, FraiseQLError> {
166        let rows = match prefix {
167            Some(pfx) => {
168                sqlx::query_as::<_, MetadataQueryRow>(
169                    "SELECT pk_storage_object, bucket, key, content_type, \
170                            size_bytes, etag, owner_id, created_at, updated_at \
171                     FROM _fraiseql_storage_objects \
172                     WHERE bucket = $1 AND key LIKE $2 \
173                     ORDER BY key ASC \
174                     LIMIT $3 OFFSET $4",
175                )
176                .bind(bucket)
177                .bind(format!("{pfx}%"))
178                .bind(i64::from(limit))
179                .bind(i64::from(offset))
180                .fetch_all(&self.pool)
181                .await
182            },
183            None => {
184                sqlx::query_as::<_, MetadataQueryRow>(
185                    "SELECT pk_storage_object, bucket, key, content_type, \
186                            size_bytes, etag, owner_id, created_at, updated_at \
187                     FROM _fraiseql_storage_objects \
188                     WHERE bucket = $1 \
189                     ORDER BY key ASC \
190                     LIMIT $2 OFFSET $3",
191                )
192                .bind(bucket)
193                .bind(i64::from(limit))
194                .bind(i64::from(offset))
195                .fetch_all(&self.pool)
196                .await
197            },
198        }
199        .map_err(|e| {
200            FraiseQLError::File(FileError::Backend {
201                message: e.to_string(),
202                source:  Some(Box::new(e)),
203            })
204        })?;
205
206        Ok(rows.into_iter().map(Into::into).collect())
207    }
208
209    /// Insert or update an object metadata row (upsert on `(bucket, key)`).
210    ///
211    /// On conflict, updates `content_type`, `size_bytes`, `etag`, and `updated_at`.
212    ///
213    /// # Errors
214    ///
215    /// Returns `FraiseQLError::File` if the database query fails.
216    pub async fn upsert(&self, row: &NewStorageObject) -> Result<i64, FraiseQLError> {
217        let (pk,): (i64,) = sqlx::query_as(
218            "INSERT INTO _fraiseql_storage_objects \
219                 (bucket, key, content_type, size_bytes, etag, owner_id) \
220             VALUES ($1, $2, $3, $4, $5, $6) \
221             ON CONFLICT (bucket, key) DO UPDATE SET \
222                 content_type = EXCLUDED.content_type, \
223                 size_bytes   = EXCLUDED.size_bytes, \
224                 etag         = EXCLUDED.etag, \
225                 updated_at   = now() \
226             RETURNING pk_storage_object",
227        )
228        .bind(&row.bucket)
229        .bind(&row.key)
230        .bind(&row.content_type)
231        .bind(row.size_bytes)
232        .bind(&row.etag)
233        .bind(&row.owner_id)
234        .fetch_one(&self.pool)
235        .await
236        .map_err(|e| {
237            FraiseQLError::File(FileError::Backend {
238                message: e.to_string(),
239                source:  Some(Box::new(e)),
240            })
241        })?;
242
243        Ok(pk)
244    }
245}
246
247// ---------------------------------------------------------------------------
248// Internal query row type for sqlx::FromRow derive
249// ---------------------------------------------------------------------------
250
251/// Internal row type that derives `sqlx::FromRow`.
252///
253/// Kept separate from the public `StorageMetadataRow` to avoid leaking the
254/// sqlx dependency into the public API.
255#[derive(sqlx::FromRow)]
256struct MetadataQueryRow {
257    pk_storage_object: i64,
258    bucket:            String,
259    key:               String,
260    content_type:      String,
261    size_bytes:        i64,
262    etag:              Option<String>,
263    owner_id:          Option<String>,
264    created_at:        DateTime<Utc>,
265    updated_at:        DateTime<Utc>,
266}
267
268impl From<MetadataQueryRow> for StorageMetadataRow {
269    fn from(row: MetadataQueryRow) -> Self {
270        Self {
271            pk_storage_object: row.pk_storage_object,
272            bucket:            row.bucket,
273            key:               row.key,
274            content_type:      row.content_type,
275            size_bytes:        row.size_bytes,
276            etag:              row.etag,
277            owner_id:          row.owner_id,
278            created_at:        row.created_at,
279            updated_at:        row.updated_at,
280        }
281    }
282}
283
284// ---------------------------------------------------------------------------
285// Public conversions
286// ---------------------------------------------------------------------------
287
288impl From<&StorageMetadataRow> for ObjectInfo {
289    fn from(row: &StorageMetadataRow) -> Self {
290        // Reason: size_bytes is non-negative (clamped above by .max(0)); cast to u64 is safe.
291        #[allow(clippy::cast_sign_loss)]
292        let size = row.size_bytes.max(0) as u64;
293        Self {
294            key: row.key.clone(),
295            size,
296            content_type: row.content_type.clone(),
297            etag: row.etag.clone().unwrap_or_default(),
298            last_modified: row.updated_at.to_rfc3339(),
299        }
300    }
301}