Skip to main content

corium_transactor/
backend.rs

1//! Pluggable transactor storage backends.
2//!
3//! A transactor keeps two kinds of durable state: the content-addressed
4//! blob store plus fenced root pointers (the "storage service"), and the
5//! per-database transaction log. [`StoreSpec`] selects the storage service
6//! backend — in-memory, filesystem, `PostgreSQL`, Turso, or S3 — and [`NodeStore`]
7//! dispatches the [`BlobStore`]/[`RootStore`] operations to it. Native
8//! service backends keep transaction logs in the same storage system as blobs
9//! and roots; memory and filesystem retain their existing log stores.
10
11use std::fmt;
12use std::path::PathBuf;
13use std::sync::Arc;
14use std::time::SystemTime;
15
16use async_trait::async_trait;
17use corium_log::{LogError, MemLogRegistry, TransactionLog, TxRecord, VersionedLog};
18#[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
19use corium_log::{NativeLogStorage, NativeVersionedLog};
20use corium_store::{BlobId, BlobIdStream, BlobStore, FsStore, MemoryStore, RootStore, StoreError};
21
22#[cfg(feature = "postgres")]
23use corium_store::PostgresBlobStore;
24#[cfg(feature = "s3")]
25use corium_store::S3BlobStore;
26#[cfg(feature = "turso")]
27use corium_store::TursoBlobStore;
28
29/// Selects the transactor's storage-service backend (blobs + roots).
30#[derive(Clone, Default)]
31pub enum StoreSpec {
32    /// In-memory blobs and roots; fully ephemeral and confined to one
33    /// process. The transaction log is in memory too, so the whole database
34    /// vanishes when the process exits — ideal for demos and tests.
35    Memory,
36    /// Blobs and roots under `{data_dir}/store`, log under `{data_dir}/logs`.
37    #[default]
38    Fs,
39    /// Blobs, roots, and transaction logs in `PostgreSQL`.
40    #[cfg(feature = "postgres")]
41    Postgres {
42        /// `PostgreSQL` URL or keyword/value connection string.
43        connection_string: String,
44    },
45    /// Blobs, roots, and transaction logs in a Turso (embeddable `SQLite`)
46    /// database at `path`. `path` is a local database file.
47    #[cfg(feature = "turso")]
48    Turso {
49        /// Filesystem path of the Turso database.
50        path: String,
51    },
52    /// Blobs, roots, and transaction logs in an S3 (or S3-compatible) bucket.
53    #[cfg(feature = "s3")]
54    S3 {
55        /// Target bucket name.
56        bucket: String,
57        /// Key prefix namespacing every object this store touches.
58        prefix: String,
59    },
60}
61
62impl fmt::Debug for StoreSpec {
63    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
64        match self {
65            Self::Memory => formatter.write_str("Memory"),
66            Self::Fs => formatter.write_str("Fs"),
67            #[cfg(feature = "postgres")]
68            Self::Postgres { .. } => formatter
69                .debug_struct("Postgres")
70                .field("connection_string", &"[REDACTED]")
71                .finish(),
72            #[cfg(feature = "turso")]
73            Self::Turso { path } => formatter.debug_struct("Turso").field("path", path).finish(),
74            #[cfg(feature = "s3")]
75            Self::S3 { bucket, prefix } => formatter
76                .debug_struct("S3")
77                .field("bucket", bucket)
78                .field("prefix", prefix)
79                .finish(),
80        }
81    }
82}
83
84/// Failure translating a transactor's advertised [`StorageConnection`] into a
85/// [`StoreSpec`] the client can open directly.
86///
87/// [`StorageConnection`]: corium_protocol::pb::StorageConnection
88#[derive(Debug, thiserror::Error)]
89pub enum StorageConnectionError {
90    /// The response carried no storage backend.
91    #[error("transactor returned no storage backend")]
92    Missing,
93    /// The backend cannot be opened by another process (memory), or this
94    /// build lacks support for it.
95    #[error("{0}")]
96    Unsupported(String),
97}
98
99impl StoreSpec {
100    /// Reconstructs a spec (and the filesystem data directory, empty for
101    /// backends that carry their own location) from the connection info a
102    /// transactor advertises through `GetStorageInfo`. The inverse of
103    /// [`Self::connection_info`], letting a client open the transactor's
104    /// storage service directly from just the transactor address.
105    ///
106    /// # Errors
107    /// Returns an error for a missing backend, an in-memory backend (confined
108    /// to the transactor process), or a backend omitted from this build.
109    pub fn from_connection(
110        connection: corium_protocol::pb::StorageConnection,
111    ) -> Result<(Self, PathBuf), StorageConnectionError> {
112        use corium_protocol::pb::storage_connection::Backend;
113
114        let backend = connection.backend.ok_or(StorageConnectionError::Missing)?;
115        let resolved = match backend {
116            Backend::Memory(_) => {
117                return Err(StorageConnectionError::Unsupported(
118                    "memory storage is confined to the transactor process".into(),
119                ));
120            }
121            Backend::Filesystem(storage) => (Self::Fs, PathBuf::from(storage.data_dir)),
122            Backend::Postgres(storage) => {
123                #[cfg(feature = "postgres")]
124                {
125                    (
126                        Self::Postgres {
127                            connection_string: storage.connection_string,
128                        },
129                        PathBuf::new(),
130                    )
131                }
132                #[cfg(not(feature = "postgres"))]
133                {
134                    let _ = storage;
135                    return Err(StorageConnectionError::Unsupported(
136                        "this build lacks PostgreSQL support".into(),
137                    ));
138                }
139            }
140            Backend::Turso(storage) => {
141                #[cfg(feature = "turso")]
142                {
143                    (Self::Turso { path: storage.path }, PathBuf::new())
144                }
145                #[cfg(not(feature = "turso"))]
146                {
147                    let _ = storage;
148                    return Err(StorageConnectionError::Unsupported(
149                        "this build lacks Turso support".into(),
150                    ));
151                }
152            }
153            Backend::S3(storage) => {
154                #[cfg(feature = "s3")]
155                {
156                    (
157                        Self::S3 {
158                            bucket: storage.bucket,
159                            prefix: storage.prefix,
160                        },
161                        PathBuf::new(),
162                    )
163                }
164                #[cfg(not(feature = "s3"))]
165                {
166                    let _ = storage;
167                    return Err(StorageConnectionError::Unsupported(
168                        "this build lacks S3 support".into(),
169                    ));
170                }
171            }
172        };
173        Ok(resolved)
174    }
175
176    /// Describes how an administrative client can independently open this
177    /// node's storage service.
178    ///
179    /// Local paths are made absolute because the client need not share the
180    /// transactor's working directory. The memory backend is described too,
181    /// but cannot be opened by another process.
182    ///
183    /// # Errors
184    /// Returns an error when a local path cannot be represented on the wire.
185    pub fn connection_info(
186        &self,
187        data_dir: &std::path::Path,
188    ) -> Result<corium_protocol::pb::StorageConnection, String> {
189        use corium_protocol::pb;
190        use pb::storage_connection::Backend;
191
192        fn absolute(path: &std::path::Path) -> Result<String, String> {
193            let path = if path.is_absolute() {
194                path.to_path_buf()
195            } else {
196                std::env::current_dir()
197                    .map_err(|error| error.to_string())?
198                    .join(path)
199            };
200            path.into_os_string()
201                .into_string()
202                .map_err(|_| "storage path is not valid UTF-8".to_owned())
203        }
204
205        let backend = match self {
206            Self::Memory => Backend::Memory(pb::MemoryStorage {}),
207            Self::Fs => Backend::Filesystem(pb::FilesystemStorage {
208                data_dir: absolute(data_dir)?,
209            }),
210            #[cfg(feature = "postgres")]
211            Self::Postgres { connection_string } => Backend::Postgres(pb::PostgreSqlStorage {
212                connection_string: connection_string.clone(),
213            }),
214            #[cfg(feature = "turso")]
215            Self::Turso { path } => Backend::Turso(pb::TursoStorage {
216                path: absolute(std::path::Path::new(path))?,
217            }),
218            #[cfg(feature = "s3")]
219            Self::S3 { bucket, prefix } => Backend::S3(pb::S3Storage {
220                bucket: bucket.clone(),
221                prefix: prefix.clone(),
222            }),
223        };
224        Ok(pb::StorageConnection {
225            backend: Some(backend),
226        })
227    }
228}
229
230/// The blob + root storage service a [`crate::node::TransactorNode`] runs
231/// over, chosen by [`StoreSpec`]. Dispatch is an enum rather than a trait
232/// object so every existing `impl BlobStore + RootStore` / `&dyn RootStore`
233/// call site keeps working unchanged.
234pub enum NodeStore {
235    /// In-memory backend.
236    Mem(MemoryStore),
237    /// Filesystem backend.
238    Fs(FsStore),
239    /// `PostgreSQL` backend.
240    #[cfg(feature = "postgres")]
241    Postgres(PostgresBlobStore),
242    /// Turso backend.
243    #[cfg(feature = "turso")]
244    Turso(TursoBlobStore),
245    /// S3 backend.
246    #[cfg(feature = "s3")]
247    S3(S3BlobStore),
248}
249
250impl NodeStore {
251    /// Opens the storage service for `spec`, relative to `data_dir` for the
252    /// filesystem backend.
253    ///
254    /// # Errors
255    /// Returns an error when the backing store cannot be opened.
256    // Only optional database-backed arms await; mem/fs are synchronous.
257    #[allow(clippy::unused_async)]
258    pub async fn open(spec: &StoreSpec, data_dir: &std::path::Path) -> Result<Self, StoreError> {
259        match spec {
260            StoreSpec::Memory => Ok(Self::Mem(MemoryStore::default())),
261            StoreSpec::Fs => Ok(Self::Fs(FsStore::open(data_dir.join("store"))?)),
262            #[cfg(feature = "postgres")]
263            StoreSpec::Postgres { connection_string } => Ok(Self::Postgres(
264                PostgresBlobStore::connect(connection_string).await?,
265            )),
266            #[cfg(feature = "turso")]
267            StoreSpec::Turso { path } => Ok(Self::Turso(TursoBlobStore::open(path).await?)),
268            #[cfg(feature = "s3")]
269            StoreSpec::S3 { bucket, prefix } => {
270                Ok(Self::S3(S3BlobStore::connect(bucket, prefix).await?))
271            }
272        }
273    }
274
275    /// Opens an existing storage service for peer reads without running
276    /// backend schema initialization.
277    ///
278    /// # Errors
279    /// Returns an error when the backing store cannot be opened.
280    #[allow(clippy::unused_async)]
281    pub async fn open_existing(
282        spec: &StoreSpec,
283        data_dir: &std::path::Path,
284    ) -> Result<Self, StoreError> {
285        match spec {
286            StoreSpec::Memory => Ok(Self::Mem(MemoryStore::default())),
287            StoreSpec::Fs => Ok(Self::Fs(FsStore::open(data_dir.join("store"))?)),
288            #[cfg(feature = "postgres")]
289            StoreSpec::Postgres { connection_string } => Ok(Self::Postgres(
290                PostgresBlobStore::connect_existing(connection_string).await?,
291            )),
292            #[cfg(feature = "turso")]
293            StoreSpec::Turso { path } => {
294                Ok(Self::Turso(TursoBlobStore::open_existing(path).await?))
295            }
296            #[cfg(feature = "s3")]
297            StoreSpec::S3 { bucket, prefix } => {
298                Ok(Self::S3(S3BlobStore::connect(bucket, prefix).await?))
299            }
300        }
301    }
302}
303
304#[async_trait]
305impl BlobStore for NodeStore {
306    async fn put(&self, bytes: &[u8]) -> Result<BlobId, StoreError> {
307        match self {
308            Self::Mem(store) => store.put(bytes).await,
309            Self::Fs(store) => store.put(bytes).await,
310            #[cfg(feature = "postgres")]
311            Self::Postgres(store) => store.put(bytes).await,
312            #[cfg(feature = "turso")]
313            Self::Turso(store) => store.put(bytes).await,
314            #[cfg(feature = "s3")]
315            Self::S3(store) => store.put(bytes).await,
316        }
317    }
318
319    async fn get(&self, id: &BlobId) -> Result<Option<Vec<u8>>, StoreError> {
320        match self {
321            Self::Mem(store) => store.get(id).await,
322            Self::Fs(store) => store.get(id).await,
323            #[cfg(feature = "postgres")]
324            Self::Postgres(store) => store.get(id).await,
325            #[cfg(feature = "turso")]
326            Self::Turso(store) => store.get(id).await,
327            #[cfg(feature = "s3")]
328            Self::S3(store) => store.get(id).await,
329        }
330    }
331
332    async fn contains(&self, id: &BlobId) -> Result<bool, StoreError> {
333        match self {
334            Self::Mem(store) => store.contains(id).await,
335            Self::Fs(store) => store.contains(id).await,
336            #[cfg(feature = "postgres")]
337            Self::Postgres(store) => store.contains(id).await,
338            #[cfg(feature = "turso")]
339            Self::Turso(store) => store.contains(id).await,
340            #[cfg(feature = "s3")]
341            Self::S3(store) => store.contains(id).await,
342        }
343    }
344
345    async fn delete(&self, id: &BlobId) -> Result<(), StoreError> {
346        match self {
347            Self::Mem(store) => store.delete(id).await,
348            Self::Fs(store) => store.delete(id).await,
349            #[cfg(feature = "postgres")]
350            Self::Postgres(store) => store.delete(id).await,
351            #[cfg(feature = "turso")]
352            Self::Turso(store) => store.delete(id).await,
353            #[cfg(feature = "s3")]
354            Self::S3(store) => store.delete(id).await,
355        }
356    }
357
358    async fn list(&self) -> Result<BlobIdStream, StoreError> {
359        match self {
360            Self::Mem(store) => store.list().await,
361            Self::Fs(store) => store.list().await,
362            #[cfg(feature = "postgres")]
363            Self::Postgres(store) => store.list().await,
364            #[cfg(feature = "turso")]
365            Self::Turso(store) => store.list().await,
366            #[cfg(feature = "s3")]
367            Self::S3(store) => store.list().await,
368        }
369    }
370
371    async fn modified_at(&self, id: &BlobId) -> Result<Option<SystemTime>, StoreError> {
372        match self {
373            Self::Mem(store) => store.modified_at(id).await,
374            Self::Fs(store) => store.modified_at(id).await,
375            #[cfg(feature = "postgres")]
376            Self::Postgres(store) => store.modified_at(id).await,
377            #[cfg(feature = "turso")]
378            Self::Turso(store) => store.modified_at(id).await,
379            #[cfg(feature = "s3")]
380            Self::S3(store) => store.modified_at(id).await,
381        }
382    }
383}
384
385#[async_trait]
386impl RootStore for NodeStore {
387    async fn get_root(&self, name: &str) -> Result<Option<Vec<u8>>, StoreError> {
388        match self {
389            Self::Mem(store) => store.get_root(name).await,
390            Self::Fs(store) => store.get_root(name).await,
391            #[cfg(feature = "postgres")]
392            Self::Postgres(store) => store.get_root(name).await,
393            #[cfg(feature = "turso")]
394            Self::Turso(store) => store.get_root(name).await,
395            #[cfg(feature = "s3")]
396            Self::S3(store) => store.get_root(name).await,
397        }
398    }
399
400    async fn cas_root(
401        &self,
402        name: &str,
403        expected: Option<&[u8]>,
404        new: &[u8],
405    ) -> Result<(), StoreError> {
406        match self {
407            Self::Mem(store) => store.cas_root(name, expected, new).await,
408            Self::Fs(store) => store.cas_root(name, expected, new).await,
409            #[cfg(feature = "postgres")]
410            Self::Postgres(store) => store.cas_root(name, expected, new).await,
411            #[cfg(feature = "turso")]
412            Self::Turso(store) => store.cas_root(name, expected, new).await,
413            #[cfg(feature = "s3")]
414            Self::S3(store) => store.cas_root(name, expected, new).await,
415        }
416    }
417
418    async fn delete_root(&self, name: &str) -> Result<(), StoreError> {
419        match self {
420            Self::Mem(store) => store.delete_root(name).await,
421            Self::Fs(store) => store.delete_root(name).await,
422            #[cfg(feature = "postgres")]
423            Self::Postgres(store) => store.delete_root(name).await,
424            #[cfg(feature = "turso")]
425            Self::Turso(store) => store.delete_root(name).await,
426            #[cfg(feature = "s3")]
427            Self::S3(store) => store.delete_root(name).await,
428        }
429    }
430
431    async fn list_roots(&self, prefix: &str) -> Result<Vec<String>, StoreError> {
432        match self {
433            Self::Mem(store) => store.list_roots(prefix).await,
434            Self::Fs(store) => store.list_roots(prefix).await,
435            #[cfg(feature = "postgres")]
436            Self::Postgres(store) => store.list_roots(prefix).await,
437            #[cfg(feature = "turso")]
438            Self::Turso(store) => store.list_roots(prefix).await,
439            #[cfg(feature = "s3")]
440            Self::S3(store) => store.list_roots(prefix).await,
441        }
442    }
443}
444
445/// Where a node's per-database transaction logs live.
446pub enum LogBackend {
447    /// Versioned log files under this directory.
448    Fs(PathBuf),
449    /// In-memory versioned logs shared across a process.
450    Mem(MemLogRegistry),
451    /// Versioned logs stored through the native root store.
452    #[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
453    Native(Arc<dyn NativeLogStorage>),
454}
455
456/// Runs filesystem log operations on Tokio's blocking pool while exposing the
457/// same async interface used by native storage logs.
458struct BlockingTransactionLog(Arc<dyn TransactionLog>);
459
460#[async_trait]
461impl TransactionLog for BlockingTransactionLog {
462    fn append(&self, record: &TxRecord) -> Result<(), LogError> {
463        self.0.append(record)
464    }
465
466    async fn append_async(&self, record: &TxRecord) -> Result<(), LogError> {
467        let log = Arc::clone(&self.0);
468        let record = record.clone();
469        tokio::task::spawn_blocking(move || log.append(&record))
470            .await
471            .map_err(|error| LogError::Native(format!("log task failed: {error}")))?
472    }
473
474    fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError> {
475        self.0.tx_range(start, end)
476    }
477
478    async fn tx_range_async(
479        &self,
480        start: u64,
481        end: Option<u64>,
482    ) -> Result<Vec<TxRecord>, LogError> {
483        let log = Arc::clone(&self.0);
484        tokio::task::spawn_blocking(move || log.tx_range(start, end))
485            .await
486            .map_err(|error| LogError::Native(format!("log task failed: {error}")))?
487    }
488}
489
490/// Prevents a storage handle opened for replay from being used to append even
491/// when its concrete implementation also supports writes.
492struct ReadOnlyTransactionLog(Arc<dyn TransactionLog>);
493
494#[async_trait]
495impl TransactionLog for ReadOnlyTransactionLog {
496    fn append(&self, _record: &TxRecord) -> Result<(), LogError> {
497        Err(LogError::Native("transaction log is read-only".into()))
498    }
499
500    fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError> {
501        self.0.tx_range(start, end)
502    }
503
504    async fn tx_range_async(
505        &self,
506        start: u64,
507        end: Option<u64>,
508    ) -> Result<Vec<TxRecord>, LogError> {
509        self.0.tx_range_async(start, end).await
510    }
511}
512
513impl LogBackend {
514    /// The log backend that pairs with `spec`.
515    #[must_use]
516    #[allow(clippy::needless_pass_by_value)]
517    pub fn for_spec(
518        spec: &StoreSpec,
519        data_dir: &std::path::Path,
520        #[cfg_attr(
521            not(any(feature = "postgres", feature = "turso", feature = "s3")),
522            allow(unused_variables)
523        )]
524        store: Arc<NodeStore>,
525    ) -> Self {
526        match spec {
527            StoreSpec::Memory => Self::Mem(MemLogRegistry::new()),
528            StoreSpec::Fs => Self::Fs(data_dir.join("logs")),
529            #[cfg(feature = "postgres")]
530            StoreSpec::Postgres { .. } => Self::Native(Arc::new(NativeRootLogStore::new(store))),
531            #[cfg(feature = "turso")]
532            StoreSpec::Turso { .. } => Self::Native(Arc::new(NativeRootLogStore::new(store))),
533            #[cfg(feature = "s3")]
534            StoreSpec::S3 { .. } => Self::Native(Arc::new(NativeRootLogStore::new(store))),
535        }
536    }
537
538    /// Opens the named log for writing under `write_version`.
539    ///
540    /// # Errors
541    /// Returns an error when a transaction log cannot be opened.
542    pub async fn open(
543        &self,
544        name: &str,
545        write_version: u64,
546    ) -> Result<Arc<dyn TransactionLog>, LogError> {
547        match self {
548            Self::Fs(dir) => {
549                let dir = dir.clone();
550                let name = name.to_owned();
551                let log = tokio::task::spawn_blocking(move || {
552                    VersionedLog::open(dir, &name, write_version)
553                })
554                .await
555                .map_err(|error| LogError::Native(format!("log task failed: {error}")))??;
556                Ok(Arc::new(BlockingTransactionLog(Arc::new(log))))
557            }
558            Self::Mem(registry) => Ok(Arc::new(registry.open(name, write_version))),
559            #[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
560            Self::Native(storage) => Ok(Arc::new(
561                NativeVersionedLog::open(Arc::clone(storage), name, write_version).await?,
562            )),
563        }
564    }
565
566    /// Opens the named log for independent read-only replay.
567    ///
568    /// Native logs use a non-writing versioned-log handle; filesystem logs
569    /// use the explicit read-only opener so this path never creates or
570    /// truncates a source log.
571    ///
572    /// # Errors
573    /// Returns an error when a transaction log cannot be opened.
574    pub async fn open_read_only(&self, name: &str) -> Result<Arc<dyn TransactionLog>, LogError> {
575        match self {
576            Self::Fs(dir) => {
577                let dir = dir.clone();
578                let name = name.to_owned();
579                let log =
580                    tokio::task::spawn_blocking(move || VersionedLog::open_read_only(dir, &name))
581                        .await
582                        .map_err(|error| LogError::Native(format!("log task failed: {error}")))??;
583                Ok(Arc::new(ReadOnlyTransactionLog(Arc::new(
584                    BlockingTransactionLog(Arc::new(log)),
585                ))))
586            }
587            Self::Mem(registry) => Ok(Arc::new(ReadOnlyTransactionLog(Arc::new(
588                registry.open(name, 0),
589            )))),
590            #[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
591            Self::Native(storage) => Ok(Arc::new(ReadOnlyTransactionLog(Arc::new(
592                NativeVersionedLog::open_read_only(Arc::clone(storage), name),
593            )))),
594        }
595    }
596
597    /// Reports whether any log exists for `name`.
598    #[must_use]
599    pub async fn exists(&self, name: &str) -> bool {
600        match self {
601            Self::Fs(dir) => {
602                let dir = dir.clone();
603                let name = name.to_owned();
604                tokio::task::spawn_blocking(move || VersionedLog::exists(dir, &name))
605                    .await
606                    .unwrap_or(false)
607            }
608            Self::Mem(registry) => registry.exists(name),
609            #[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
610            Self::Native(storage) => {
611                storage
612                    .list_records(name)
613                    .await
614                    .is_ok_and(|records| !records.is_empty())
615                    || storage
616                        .list_legacy_chunks(name)
617                        .await
618                        .is_ok_and(|chunks| !chunks.is_empty())
619            }
620        }
621    }
622
623    /// Deletes every log for `name`.
624    ///
625    /// # Errors
626    /// Returns an error when a transaction log cannot be removed.
627    pub async fn delete_all(&self, name: &str) -> Result<(), LogError> {
628        match self {
629            Self::Fs(dir) => {
630                let dir = dir.clone();
631                let name = name.to_owned();
632                tokio::task::spawn_blocking(move || VersionedLog::delete_all(dir, &name))
633                    .await
634                    .map_err(|error| LogError::Native(format!("log task failed: {error}")))?
635            }
636            Self::Mem(registry) => {
637                registry.delete_all(name);
638                Ok(())
639            }
640            #[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
641            Self::Native(storage) => storage.delete_all(name).await,
642        }
643    }
644}
645
646#[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
647struct NativeRootLogStore {
648    store: Arc<NodeStore>,
649}
650
651#[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
652impl NativeRootLogStore {
653    fn new(store: Arc<NodeStore>) -> Self {
654        Self { store }
655    }
656}
657
658/// A parsed log object key: either a per-transaction record or a legacy chunk
659/// written by the pre-per-record layout.
660#[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
661enum LogKey {
662    /// Per-transaction record object `(version, t)`.
663    Record(u64, u64),
664    /// Legacy chunk object `(version, chunk)`.
665    Legacy(u64, u64),
666}
667
668#[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
669impl NativeRootLogStore {
670    /// Object key for one per-transaction record: `log:<db>:v<version>:r<t>`,
671    /// with both numbers zero-padded so a listing sorts by `(version, t)`.
672    fn record_key(name: &str, version: u64, t: u64) -> String {
673        format!("log:{name}:v{version:020}:r{t:020}")
674    }
675
676    /// Object key for one legacy chunk. Chunk `0` keeps the historical
677    /// unsuffixed key, so logs written by earlier releases read back without
678    /// migration.
679    fn legacy_key(name: &str, version: u64, chunk: u64) -> String {
680        if chunk == 0 {
681            format!("log:{name}:v{version:020}")
682        } else {
683            format!("log:{name}:v{version:020}:c{chunk:020}")
684        }
685    }
686
687    fn prefix(name: &str) -> String {
688        format!("log:{name}:v")
689    }
690
691    /// Classifies a listed log key. A `:r` suffix marks a per-record object; a
692    /// `:c` suffix or a bare version marks a legacy chunk (chunk `0` when
693    /// bare). The version is pure digits, so it never contains either marker.
694    fn parse_key(prefix: &str, key: &str) -> Option<LogKey> {
695        let rest = key.strip_prefix(prefix)?;
696        if let Some((version, t)) = rest.split_once(":r") {
697            Some(LogKey::Record(version.parse().ok()?, t.parse().ok()?))
698        } else if let Some((version, chunk)) = rest.split_once(":c") {
699            Some(LogKey::Legacy(version.parse().ok()?, chunk.parse().ok()?))
700        } else {
701            Some(LogKey::Legacy(rest.parse().ok()?, 0))
702        }
703    }
704}
705
706#[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
707#[async_trait]
708impl NativeLogStorage for NativeRootLogStore {
709    async fn put_batch(
710        &self,
711        name: &str,
712        version: u64,
713        records: &[(u64, Vec<u8>)],
714    ) -> Result<bool, LogError> {
715        let Some((last_t, _)) = records.last() else {
716            return Ok(true);
717        };
718        // The whole batch is one immutable object keyed by its last `t`,
719        // holding the batch's framed records concatenated — the same encoding
720        // a multi-record chunk uses, so the reader decodes it unchanged. On
721        // SQL backends this is one row insert (one fsync for the batch); on an
722        // object store, one create-only `PUT`. A create-only root CAS
723        // (expected `None`) makes it atomic and fenced; a lost race surfaces
724        // as `CasFailed`, which the caller maps to `false`.
725        let mut bytes = Vec::new();
726        for (_, framed) in records {
727            bytes.extend_from_slice(framed);
728        }
729        match self
730            .store
731            .cas_root(&Self::record_key(name, version, *last_t), None, &bytes)
732            .await
733        {
734            Ok(()) => Ok(true),
735            Err(StoreError::CasFailed { .. }) => Ok(false),
736            Err(error) => Err(LogError::Native(error.to_string())),
737        }
738    }
739
740    async fn read_record(
741        &self,
742        name: &str,
743        version: u64,
744        t: u64,
745    ) -> Result<Option<Vec<u8>>, LogError> {
746        self.store
747            .get_root(&Self::record_key(name, version, t))
748            .await
749            .map_err(|error| LogError::Native(error.to_string()))
750    }
751
752    async fn list_records(&self, name: &str) -> Result<Vec<(u64, u64)>, LogError> {
753        let prefix = Self::prefix(name);
754        let names = self
755            .store
756            .list_roots(&prefix)
757            .await
758            .map_err(|error| LogError::Native(error.to_string()))?;
759        names
760            .into_iter()
761            .filter_map(|key| match Self::parse_key(&prefix, &key) {
762                Some(LogKey::Record(version, t)) => Some(Ok((version, t))),
763                Some(LogKey::Legacy(..)) => None,
764                None => Some(Err(LogError::Corrupt)),
765            })
766            .collect()
767    }
768
769    async fn read_legacy_chunk(
770        &self,
771        name: &str,
772        version: u64,
773        chunk: u64,
774    ) -> Result<Option<Vec<u8>>, LogError> {
775        self.store
776            .get_root(&Self::legacy_key(name, version, chunk))
777            .await
778            .map_err(|error| LogError::Native(error.to_string()))
779    }
780
781    async fn list_legacy_chunks(&self, name: &str) -> Result<Vec<(u64, u64)>, LogError> {
782        let prefix = Self::prefix(name);
783        let names = self
784            .store
785            .list_roots(&prefix)
786            .await
787            .map_err(|error| LogError::Native(error.to_string()))?;
788        names
789            .into_iter()
790            .filter_map(|key| match Self::parse_key(&prefix, &key) {
791                Some(LogKey::Legacy(version, chunk)) => Some(Ok((version, chunk))),
792                Some(LogKey::Record(..)) => None,
793                None => Some(Err(LogError::Corrupt)),
794            })
795            .collect()
796    }
797
798    async fn delete_all(&self, name: &str) -> Result<(), LogError> {
799        let prefix = Self::prefix(name);
800        let names = self
801            .store
802            .list_roots(&prefix)
803            .await
804            .map_err(|error| LogError::Native(error.to_string()))?;
805        for key in names {
806            self.store
807                .delete_root(&key)
808                .await
809                .map_err(|error| LogError::Native(error.to_string()))?;
810        }
811        Ok(())
812    }
813}
814
815#[cfg(all(test, any(feature = "postgres", feature = "turso", feature = "s3")))]
816mod tests {
817    use super::*;
818    use corium_core::{Datom, EntityId, Value};
819
820    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
821    async fn native_root_log_writes_one_object_per_record_on_the_runtime() {
822        let run = async {
823            let dir = tempfile::tempdir().expect("tempdir");
824            let store = Arc::new(
825                NodeStore::open(&StoreSpec::Memory, dir.path())
826                    .await
827                    .expect("memory store"),
828            );
829            let storage = Arc::new(NativeRootLogStore::new(store));
830            let log = NativeVersionedLog::open(Arc::clone(&storage), "db", 1)
831                .await
832                .expect("open native log");
833            // Large records too: each transaction is its own object, so there
834            // is no chunk cap to cross.
835            for t in 1..=3 {
836                log.append_async(&TxRecord {
837                    t,
838                    tx_instant: i64::try_from(t).expect("small t"),
839                    datoms: vec![Datom {
840                        e: EntityId::from_raw(t),
841                        a: EntityId::from_raw(1),
842                        v: Value::Bytes(vec![0; 300 * 1024].into()),
843                        tx: EntityId::from_raw(t),
844                        added: true,
845                    }],
846                })
847                .await
848                .expect("append");
849            }
850            let records = storage.list_records("db").await.expect("list records");
851            assert_eq!(records.len(), 3);
852            assert!(
853                storage
854                    .list_legacy_chunks("db")
855                    .await
856                    .expect("list legacy")
857                    .is_empty()
858            );
859            assert_eq!(log.replay_async().await.expect("replay").len(), 3);
860        };
861        tokio::time::timeout(std::time::Duration::from_secs(5), run)
862            .await
863            .expect("native log operation stalled on its runtime");
864    }
865}