Skip to main content

corium_store/
discovery.rs

1//! Shared decoding and opening of storage advertised by a transactor.
2
3use std::fmt;
4use std::path::PathBuf;
5use std::time::SystemTime;
6#[cfg(feature = "s3")]
7use std::time::{Duration, UNIX_EPOCH};
8
9use corium_protocol::pb;
10
11#[cfg(feature = "postgres")]
12use crate::PostgresBlobStore;
13#[cfg(feature = "turso")]
14use crate::TursoBlobStore;
15use crate::{BlobId, BlobIdStream, BlobStore, FsStore, RootStore, StoreError};
16#[cfg(feature = "s3")]
17use crate::{S3BlobStore, S3ClientConfig};
18
19/// Failure translating storage information advertised by a transactor.
20#[derive(Debug, thiserror::Error)]
21pub enum StorageConnectionError {
22    /// The response carried no storage backend.
23    #[error("transactor returned no storage backend")]
24    Missing,
25    /// The backend cannot be opened by another process, lacks required
26    /// read-only credentials, or was omitted from this build.
27    #[error("{0}")]
28    Unsupported(String),
29}
30
31/// A read-only storage-service connection advertised by a transactor.
32///
33/// This is the single protobuf-to-store mapping used by peers, backups, and
34/// language facades. Local paths are resolved to the concrete store root here
35/// so the `{data_dir}/store` convention cannot drift between clients.
36#[derive(Clone)]
37pub enum DiscoveredStoreSpec {
38    /// Filesystem blobs and roots under an existing store root.
39    Filesystem {
40        /// Absolute or process-local path to the store root.
41        root: PathBuf,
42    },
43    /// Existing `PostgreSQL` store.
44    #[cfg(feature = "postgres")]
45    Postgres {
46        /// Read-only connection string.
47        connection_string: String,
48    },
49    /// Existing local Turso database.
50    #[cfg(feature = "turso")]
51    Turso {
52        /// Database file path.
53        path: PathBuf,
54    },
55    /// Existing S3 prefix.
56    #[cfg(feature = "s3")]
57    S3 {
58        /// Bucket name.
59        bucket: String,
60        /// Corium key prefix.
61        prefix: String,
62        /// Read-only client configuration.
63        client: S3ClientConfig,
64    },
65}
66
67impl fmt::Debug for DiscoveredStoreSpec {
68    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
69        match self {
70            Self::Filesystem { root } => formatter
71                .debug_struct("Filesystem")
72                .field("root", root)
73                .finish(),
74            #[cfg(feature = "postgres")]
75            Self::Postgres { .. } => formatter
76                .debug_struct("Postgres")
77                .field("connection_string", &"[REDACTED]")
78                .finish(),
79            #[cfg(feature = "turso")]
80            Self::Turso { path } => formatter.debug_struct("Turso").field("path", path).finish(),
81            #[cfg(feature = "s3")]
82            Self::S3 {
83                bucket,
84                prefix,
85                client,
86            } => formatter
87                .debug_struct("S3")
88                .field("bucket", bucket)
89                .field("prefix", prefix)
90                .field("client", client)
91                .finish(),
92        }
93    }
94}
95
96impl DiscoveredStoreSpec {
97    /// Decodes storage information returned by `GetStorageInfo`.
98    ///
99    /// # Errors
100    /// Returns an error for a missing or process-local memory backend,
101    /// missing read-only credentials, or a backend omitted from this build.
102    pub fn from_connection(
103        connection: pb::StorageConnection,
104    ) -> Result<Self, StorageConnectionError> {
105        use pb::storage_connection::Backend;
106
107        match connection.backend.ok_or(StorageConnectionError::Missing)? {
108            Backend::Memory(_) => Err(StorageConnectionError::Unsupported(
109                "memory storage is confined to the transactor process".into(),
110            )),
111            Backend::Filesystem(storage) => Ok(Self::Filesystem {
112                root: PathBuf::from(storage.data_dir).join("store"),
113            }),
114            Backend::Postgres(storage) => {
115                #[cfg(feature = "postgres")]
116                {
117                    Ok(Self::Postgres {
118                        connection_string: storage.connection_string,
119                    })
120                }
121                #[cfg(not(feature = "postgres"))]
122                {
123                    let _ = storage;
124                    Err(StorageConnectionError::Unsupported(
125                        "this build lacks PostgreSQL direct-storage support".into(),
126                    ))
127                }
128            }
129            Backend::Turso(storage) => {
130                #[cfg(feature = "turso")]
131                {
132                    Ok(Self::Turso {
133                        path: storage.path.into(),
134                    })
135                }
136                #[cfg(not(feature = "turso"))]
137                {
138                    let _ = storage;
139                    Err(StorageConnectionError::Unsupported(
140                        "this build lacks Turso direct-storage support".into(),
141                    ))
142                }
143            }
144            Backend::S3(storage) => {
145                #[cfg(feature = "s3")]
146                {
147                    let access_key_id = nonempty(storage.access_key_id).ok_or_else(|| {
148                        StorageConnectionError::Unsupported(
149                            "S3 storage info omitted the read-only access key id".into(),
150                        )
151                    })?;
152                    let secret_access_key =
153                        nonempty(storage.secret_access_key).ok_or_else(|| {
154                            StorageConnectionError::Unsupported(
155                                "S3 storage info omitted the read-only secret access key".into(),
156                            )
157                        })?;
158                    Ok(Self::S3 {
159                        bucket: storage.bucket,
160                        prefix: storage.prefix,
161                        client: S3ClientConfig {
162                            region: nonempty(storage.region),
163                            endpoint_url: nonempty(storage.endpoint_url),
164                            access_key_id: Some(access_key_id),
165                            secret_access_key: Some(secret_access_key),
166                            session_token: nonempty(storage.session_token),
167                            expires_after: unix_timestamp(storage.expires_unix_seconds),
168                            ..S3ClientConfig::default()
169                        },
170                    })
171                }
172                #[cfg(not(feature = "s3"))]
173                {
174                    let _ = storage;
175                    Err(StorageConnectionError::Unsupported(
176                        "this build lacks S3 direct-storage support".into(),
177                    ))
178                }
179            }
180        }
181    }
182
183    /// Opens the advertised store without creating storage or schema.
184    ///
185    /// # Errors
186    /// Returns [`StoreError::UnreachableLocalStorage`] before opening a
187    /// filesystem or Turso path that does not already exist. Other errors
188    /// report connection, credential, or backend failures.
189    #[allow(clippy::unused_async)]
190    pub async fn open_existing(self) -> Result<DiscoveredStore, StoreError> {
191        match self {
192            Self::Filesystem { root } => {
193                if !root.join("blobs").is_dir() || !root.join("roots").is_dir() {
194                    return Err(StoreError::UnreachableLocalStorage(root));
195                }
196                Ok(DiscoveredStore::Filesystem(FsStore::open(root)?))
197            }
198            #[cfg(feature = "postgres")]
199            Self::Postgres { connection_string } => Ok(DiscoveredStore::Postgres(
200                PostgresBlobStore::connect_existing(connection_string).await?,
201            )),
202            #[cfg(feature = "turso")]
203            Self::Turso { path } => {
204                if !path.is_file() {
205                    return Err(StoreError::UnreachableLocalStorage(path));
206                }
207                Ok(DiscoveredStore::Turso(
208                    TursoBlobStore::open_existing(path).await?,
209                ))
210            }
211            #[cfg(feature = "s3")]
212            Self::S3 {
213                bucket,
214                prefix,
215                client,
216            } => Ok(DiscoveredStore::S3(
217                S3BlobStore::connect_existing_with_config(bucket, prefix, &client).await?,
218            )),
219        }
220    }
221}
222
223/// An opened, read-only store discovered through the transactor.
224pub enum DiscoveredStore {
225    /// Existing filesystem store.
226    Filesystem(FsStore),
227    /// Existing `PostgreSQL` store.
228    #[cfg(feature = "postgres")]
229    Postgres(PostgresBlobStore),
230    /// Existing Turso store.
231    #[cfg(feature = "turso")]
232    Turso(TursoBlobStore),
233    /// Existing S3 store.
234    #[cfg(feature = "s3")]
235    S3(S3BlobStore),
236}
237
238impl DiscoveredStore {
239    /// Loads one immutable blob.
240    ///
241    /// # Errors
242    /// Returns an error when the discovered backend cannot read the blob.
243    pub async fn get(&self, id: &BlobId) -> Result<Option<Vec<u8>>, StoreError> {
244        match self {
245            Self::Filesystem(store) => store.get(id).await,
246            #[cfg(feature = "postgres")]
247            Self::Postgres(store) => store.get(id).await,
248            #[cfg(feature = "turso")]
249            Self::Turso(store) => store.get(id).await,
250            #[cfg(feature = "s3")]
251            Self::S3(store) => store.get(id).await,
252        }
253    }
254
255    /// Lists every immutable blob in the discovered backend.
256    ///
257    /// # Errors
258    /// Returns an error when the discovered backend cannot enumerate blobs.
259    pub async fn list(&self) -> Result<BlobIdStream, StoreError> {
260        match self {
261            Self::Filesystem(store) => store.list().await,
262            #[cfg(feature = "postgres")]
263            Self::Postgres(store) => store.list().await,
264            #[cfg(feature = "turso")]
265            Self::Turso(store) => store.list().await,
266            #[cfg(feature = "s3")]
267            Self::S3(store) => store.list().await,
268        }
269    }
270
271    /// Returns the blob's backend modification time, when available.
272    ///
273    /// # Errors
274    /// Returns an error when the discovered backend cannot inspect the blob.
275    pub async fn modified_at(&self, id: &BlobId) -> Result<Option<SystemTime>, StoreError> {
276        match self {
277            Self::Filesystem(store) => store.modified_at(id).await,
278            #[cfg(feature = "postgres")]
279            Self::Postgres(store) => store.modified_at(id).await,
280            #[cfg(feature = "turso")]
281            Self::Turso(store) => store.modified_at(id).await,
282            #[cfg(feature = "s3")]
283            Self::S3(store) => store.modified_at(id).await,
284        }
285    }
286
287    /// Loads one named root record.
288    ///
289    /// # Errors
290    /// Returns an error when the discovered backend cannot read the root.
291    pub async fn get_root(&self, name: &str) -> Result<Option<Vec<u8>>, StoreError> {
292        match self {
293            Self::Filesystem(store) => store.get_root(name).await,
294            #[cfg(feature = "postgres")]
295            Self::Postgres(store) => store.get_root(name).await,
296            #[cfg(feature = "turso")]
297            Self::Turso(store) => store.get_root(name).await,
298            #[cfg(feature = "s3")]
299            Self::S3(store) => store.get_root(name).await,
300        }
301    }
302
303    /// Lists root names with `prefix`.
304    ///
305    /// # Errors
306    /// Returns an error when the discovered backend cannot enumerate roots.
307    pub async fn list_roots(&self, prefix: &str) -> Result<Vec<String>, StoreError> {
308        match self {
309            Self::Filesystem(store) => store.list_roots(prefix).await,
310            #[cfg(feature = "postgres")]
311            Self::Postgres(store) => store.list_roots(prefix).await,
312            #[cfg(feature = "turso")]
313            Self::Turso(store) => store.list_roots(prefix).await,
314            #[cfg(feature = "s3")]
315            Self::S3(store) => store.list_roots(prefix).await,
316        }
317    }
318}
319
320#[cfg(feature = "s3")]
321fn nonempty(value: String) -> Option<String> {
322    (!value.is_empty()).then_some(value)
323}
324
325#[cfg(feature = "s3")]
326fn unix_timestamp(seconds: i64) -> Option<SystemTime> {
327    u64::try_from(seconds)
328        .ok()
329        .filter(|seconds| *seconds > 0)
330        .and_then(|seconds| UNIX_EPOCH.checked_add(Duration::from_secs(seconds)))
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    #[tokio::test]
338    async fn filesystem_discovery_does_not_create_an_unreachable_store() {
339        let directory = tempfile::tempdir().expect("temporary directory");
340        let root = directory.path().join("store");
341        let spec = DiscoveredStoreSpec::from_connection(pb::StorageConnection {
342            backend: Some(pb::storage_connection::Backend::Filesystem(
343                pb::FilesystemStorage {
344                    data_dir: directory.path().to_string_lossy().into_owned(),
345                },
346            )),
347        })
348        .expect("filesystem spec");
349
350        let error = spec
351            .open_existing()
352            .await
353            .err()
354            .expect("missing store must fail");
355        assert!(matches!(error, StoreError::UnreachableLocalStorage(path) if path == root));
356        assert!(!root.exists());
357    }
358
359    #[cfg(feature = "turso")]
360    #[tokio::test]
361    async fn turso_discovery_does_not_create_an_unreachable_database() {
362        let directory = tempfile::tempdir().expect("temporary directory");
363        let path = directory.path().join("missing.db");
364        let spec = DiscoveredStoreSpec::from_connection(pb::StorageConnection {
365            backend: Some(pb::storage_connection::Backend::Turso(pb::TursoStorage {
366                path: path.to_string_lossy().into_owned(),
367            })),
368        })
369        .expect("Turso spec");
370
371        let error = spec
372            .open_existing()
373            .await
374            .err()
375            .expect("missing database must fail");
376        assert!(matches!(error, StoreError::UnreachableLocalStorage(found) if found == path));
377        assert!(!path.exists());
378    }
379}