1use 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#[derive(Debug, thiserror::Error)]
21pub enum StorageConnectionError {
22 #[error("transactor returned no storage backend")]
24 Missing,
25 #[error("{0}")]
28 Unsupported(String),
29}
30
31#[derive(Clone)]
37pub enum DiscoveredStoreSpec {
38 Filesystem {
40 root: PathBuf,
42 },
43 #[cfg(feature = "postgres")]
45 Postgres {
46 connection_string: String,
48 },
49 #[cfg(feature = "turso")]
51 Turso {
52 path: PathBuf,
54 },
55 #[cfg(feature = "s3")]
57 S3 {
58 bucket: String,
60 prefix: String,
62 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 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 #[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
223pub enum DiscoveredStore {
225 Filesystem(FsStore),
227 #[cfg(feature = "postgres")]
229 Postgres(PostgresBlobStore),
230 #[cfg(feature = "turso")]
232 Turso(TursoBlobStore),
233 #[cfg(feature = "s3")]
235 S3(S3BlobStore),
236}
237
238impl DiscoveredStore {
239 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 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 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 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 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}